Skip to main content

backdrop_blur_core/
geometry.rs

1//! Geometry and the request bundle. The seam speaks **physical pixels**: a [`Region`] is a
2//! physical-pixel rectangle that carries its own logical→physical [`Scale`], so the source
3//! intermediate and the swapchain target can differ in DPI without a single global factor
4//! (DESIGN §4.1). [`ResolvedMask`] is the one shader input core computes; [`BlurRequest`] is
5//! the bundle that crosses the seam.
6
7use crate::material::{BlurStrength, CornerRadius, Opacity, Tint};
8
9/// A logical→physical scale factor (DPI) for one region. Strictly positive by construction:
10/// a zero or negative factor would make the resolution math degenerate (every resolved radius
11/// collapses to `0`), so [`Self::new`] floors it at the smallest positive `f32`.
12///
13/// This guards only against zero/negative. A *near-zero* (sub-pixel) factor is almost always
14/// an uninitialized-DPI caller bug that this type does **not** catch, so a backend should not
15/// read `factor() > 0` as "meaningfully large".
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Scale(f32);
18
19impl Scale {
20    /// Construct from a factor, flooring at the smallest positive `f32`.
21    pub fn new(factor: f32) -> Self {
22        Self(factor.max(f32::MIN_POSITIVE))
23    }
24
25    /// The scale factor (always `> 0`).
26    pub fn factor(self) -> f32 {
27        self.0
28    }
29}
30
31impl Default for Scale {
32    /// 1 physical pixel per logical point.
33    fn default() -> Self {
34        Self(1.0)
35    }
36}
37
38/// A physical-pixel rectangle carrying its own logical→physical [`Scale`]. Construct with a
39/// struct literal so the two `[u32; 2]` fields are named at the call site (no positional
40/// origin/size swap is possible).
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct Region {
43    /// Top-left corner in physical pixels.
44    pub origin: [u32; 2],
45    /// Width and height in physical pixels.
46    pub size: [u32; 2],
47    /// This region's logical→physical scale.
48    pub scale: Scale,
49}
50
51impl Region {
52    /// Clip this region to a source texture of size `source_extent`, returning the in-bounds
53    /// intersection (its scale preserved).
54    ///
55    /// Returns `None` when the intersection is empty — i.e. the region is zero-area or lies
56    /// fully outside the texture. That `None` is the blur **no-op**: `prepare` returns
57    /// `Ok(None)` and `record` is never called (DESIGN §4.4/§4.5). A *partially* offscreen
58    /// region is clipped to the in-bounds sub-rect rather than dropped, so the backend never
59    /// samples outside the source (the "Region clipping" core operation, DESIGN §11). All
60    /// arithmetic saturates, so an `origin + size` past `u32::MAX` cannot overflow.
61    pub fn clip_to(&self, source_extent: [u32; 2]) -> Option<Region> {
62        let [ox, oy] = self.origin;
63        let [w, h] = self.size;
64        let [ex, ey] = source_extent;
65
66        let x0 = ox.min(ex);
67        let y0 = oy.min(ey);
68        let x1 = ox.saturating_add(w).min(ex);
69        let y1 = oy.saturating_add(h).min(ey);
70
71        let clipped_w = x1.saturating_sub(x0);
72        let clipped_h = y1.saturating_sub(y0);
73
74        if clipped_w == 0 || clipped_h == 0 {
75            None
76        } else {
77            Some(Region {
78                origin: [x0, y0],
79                size: [clipped_w, clipped_h],
80                scale: self.scale,
81            })
82        }
83    }
84}
85
86impl std::fmt::Display for Region {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        let [x, y] = self.origin;
89        let [w, h] = self.size;
90        let scale = self.scale.factor();
91        write!(f, "origin ({x}, {y}), size {w}×{h}, scale {scale}")
92    }
93}
94
95/// What core computes for the shader: the target surface's half-extents (physical px) and the
96/// physical corner radius, **clamped to `min(half_extent)`** so the rounded-rect SDF can never
97/// overshoot into a malformed shape. The per-pixel SDF is the shader's job; this is its
98/// resolved, GPU-agnostic input — which is exactly what makes the clamp headless-testable
99/// (DESIGN §4.3, §11).
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct ResolvedMask {
102    /// Half the target rect's width and height, in physical pixels.
103    pub half_extents: [f32; 2],
104    /// The clamped physical corner radius, in physical pixels.
105    pub corner_radius_px: f32,
106}
107
108impl ResolvedMask {
109    /// Resolve the mask for a target rect: half-extents from its size, and the corner radius
110    /// scaled to physical pixels then clamped to `min(half_width, half_height)`. The
111    /// half-extents derive from `u32` sizes, so they are non-negative and the clamp's
112    /// `min <= max` precondition always holds.
113    pub fn from_target(target: &Region, corner_radius: CornerRadius) -> Self {
114        let half_extents = [target.size[0] as f32 / 2.0, target.size[1] as f32 / 2.0];
115        let max_radius = half_extents[0].min(half_extents[1]);
116        let corner_radius_px =
117            (corner_radius.points() * target.scale.factor()).clamp(0.0, max_radius);
118        Self {
119            half_extents,
120            corner_radius_px,
121        }
122    }
123}
124
125/// The one backend-agnostic bundle that crosses the seam. `source_region` says where the
126/// backdrop lives in the source texture; `target_rect` says where to composite the frosted
127/// surface in the target. Both carry independent sizes and scales (DESIGN §4.1/§4.3).
128#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct BlurRequest {
130    /// Where the backdrop to blur lives in the `source` texture (physical px + its scale).
131    pub source_region: Region,
132    /// Where to composite the frosted surface in the `target` (physical px + its scale).
133    pub target_rect: Region,
134    /// How much blur (logical points).
135    pub strength: BlurStrength,
136    /// The glass film.
137    pub tint: Tint,
138    /// How round the surface corners are (logical points).
139    pub corner_radius: CornerRadius,
140    /// Surface-global fade coverage `[0, 1]` — how present the whole surface is (default `1.0`).
141    pub opacity: Opacity,
142}
143
144impl BlurRequest {
145    /// The physical-pixel blur radius, resolved against the **source** region's scale.
146    ///
147    /// The blur convolution happens in source-texture pixel space, so this is the only correct
148    /// scale; pinning it here (rather than exposing a bare `strength.to_physical_radius(scale)`)
149    /// means a backend cannot accidentally resolve against `target_rect.scale` on a
150    /// mismatched-DPI surface. Mirrors how [`ResolvedMask::from_target`] pins the target scale
151    /// for the corner radius.
152    pub fn physical_blur_radius(&self) -> f32 {
153        self.strength.to_physical_radius(self.source_region.scale)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::material::LinearRgba;
161
162    fn close(a: f32, b: f32) -> bool {
163        (a - b).abs() < 1e-4
164    }
165
166    fn region(origin: [u32; 2], size: [u32; 2], scale: f32) -> Region {
167        Region {
168            origin,
169            size,
170            scale: Scale::new(scale),
171        }
172    }
173
174    #[test]
175    fn scale_new_floors_nonpositive_to_positive() {
176        assert!(Scale::new(0.0).factor() > 0.0);
177        assert!(Scale::new(-2.0).factor() > 0.0);
178        assert!(close(Scale::new(2.5).factor(), 2.5));
179    }
180
181    #[test]
182    fn scale_default_is_one() {
183        assert!(close(Scale::default().factor(), 1.0));
184    }
185
186    #[test]
187    fn clip_to_leaves_an_in_bounds_region_unchanged() {
188        let r = region([10, 10], [20, 20], 1.0);
189        assert_eq!(r.clip_to([100, 100]), Some(r));
190    }
191
192    #[test]
193    fn clip_to_clamps_a_partially_offscreen_region() {
194        // origin in-bounds, extent runs 10px past each edge of a 100×100 source.
195        let r = region([90, 90], [20, 20], 2.0);
196        let clipped = r
197            .clip_to([100, 100])
198            .expect("partial overlap is not a no-op");
199        assert_eq!(clipped.origin, [90, 90]);
200        assert_eq!(clipped.size, [10, 10]);
201        // The scale is preserved through the clip.
202        assert!(close(clipped.scale.factor(), 2.0));
203    }
204
205    #[test]
206    fn clip_to_is_none_when_origin_past_extent() {
207        let r = region([100, 0], [10, 10], 1.0);
208        assert_eq!(r.clip_to([100, 100]), None);
209    }
210
211    #[test]
212    fn clip_to_is_none_for_zero_area() {
213        let r = region([0, 0], [0, 10], 1.0);
214        assert_eq!(r.clip_to([100, 100]), None);
215    }
216
217    #[test]
218    fn clip_to_saturates_instead_of_overflowing() {
219        // origin + size would overflow u32; saturating arithmetic clips to the extent.
220        let r = region([u32::MAX - 1, 0], [10, 10], 1.0);
221        assert_eq!(r.clip_to([100, 100]), None);
222    }
223
224    #[test]
225    fn region_display_reads_as_a_sentence_fragment() {
226        let r = region([4, 8], [100, 60], 2.0);
227        assert_eq!(r.to_string(), "origin (4, 8), size 100×60, scale 2");
228    }
229
230    #[test]
231    fn resolved_mask_half_extents_are_half_the_size() {
232        let mask =
233            ResolvedMask::from_target(&region([0, 0], [80, 40], 1.0), CornerRadius::new(8.0));
234        assert!(close(mask.half_extents[0], 40.0));
235        assert!(close(mask.half_extents[1], 20.0));
236        assert!(close(mask.corner_radius_px, 8.0));
237    }
238
239    #[test]
240    fn resolved_mask_scales_corner_radius_to_physical() {
241        // 8 logical points × 2.0 = 16 physical px, well under the 100px half-extent.
242        let mask =
243            ResolvedMask::from_target(&region([0, 0], [200, 200], 2.0), CornerRadius::new(8.0));
244        assert!(close(mask.corner_radius_px, 16.0));
245    }
246
247    #[test]
248    fn resolved_mask_clamps_radius_to_min_half_extent() {
249        // A 999pt radius cannot exceed min(half) = min(20, 50) = 20.
250        let mask =
251            ResolvedMask::from_target(&region([0, 0], [40, 100], 1.0), CornerRadius::new(999.0));
252        assert!(close(mask.corner_radius_px, 20.0));
253    }
254
255    #[test]
256    fn physical_blur_radius_resolves_against_the_source_scale() {
257        // Source DPI 2.0, target DPI 1.0 — the blur must use the SOURCE scale (2.0).
258        let request = BlurRequest {
259            source_region: region([0, 0], [100, 100], 2.0),
260            target_rect: region([0, 0], [80, 60], 1.0),
261            strength: BlurStrength::new(8.0),
262            tint: Tint::new(LinearRgba::new(0.1, 0.1, 0.12, 0.7)),
263            corner_radius: CornerRadius::new(10.0),
264            opacity: Opacity::default(),
265        };
266        assert!(close(request.physical_blur_radius(), 16.0));
267    }
268
269    #[test]
270    fn blur_request_constructs_by_named_fields() {
271        // Compile-time evidence that the request is assembled by name (swap-safe).
272        let r = region([0, 0], [100, 60], 1.0);
273        let request = BlurRequest {
274            source_region: r,
275            target_rect: r,
276            strength: BlurStrength::new(12.0),
277            tint: Tint::new(LinearRgba::new(0.1, 0.1, 0.12, 0.7)),
278            corner_radius: CornerRadius::new(10.0),
279            opacity: Opacity::default(),
280        };
281        assert_eq!(request.strength.points(), 12.0);
282        assert!(close(request.tint.color().a(), 0.7));
283    }
284}