Skip to main content

kozan_primitives/
rounded_rect.rs

1use crate::geometry::{Corners, Point, Rect, Size};
2
3/// A rectangle with independently rounded corners.
4///
5/// Used throughout the platform for border-radius rendering, clipping
6/// regions, and hit testing. Each corner has its own elliptical radius
7/// defined as a [`Size`] (horizontal × vertical), supporting non-circular
8/// corners like `border-radius: 20px / 10px`.
9///
10/// When all four radii are zero this degenerates to a plain [`Rect`].
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct RoundedRect {
13    pub rect: Rect,
14    pub radii: Corners<Size>,
15}
16
17impl RoundedRect {
18    #[must_use]
19    pub fn new(rect: Rect, radii: Corners<Size>) -> Self {
20        Self { rect, radii }
21    }
22
23    /// A rounded rect with no rounding — equivalent to a plain rect.
24    #[must_use]
25    pub fn from_rect(rect: Rect) -> Self {
26        Self {
27            rect,
28            radii: Corners::all(Size::ZERO),
29        }
30    }
31
32    /// All four corners share the same circular radius.
33    #[must_use]
34    pub fn uniform(rect: Rect, radius: f32) -> Self {
35        Self {
36            rect,
37            radii: Corners::all(Size::new(radius, radius)),
38        }
39    }
40
41    /// True when all corner radii are zero.
42    #[must_use]
43    pub fn is_sharp(&self) -> bool {
44        self.radii.top_left == Size::ZERO
45            && self.radii.top_right == Size::ZERO
46            && self.radii.bottom_right == Size::ZERO
47            && self.radii.bottom_left == Size::ZERO
48    }
49
50    /// True when the rounded rect forms a full ellipse/circle (each
51    /// corner's radius is exactly half the rect's dimension).
52    #[must_use]
53    pub fn is_ellipse(&self) -> bool {
54        let hw = self.rect.width() * 0.5;
55        let hh = self.rect.height() * 0.5;
56        let expected = Size::new(hw, hh);
57        self.radii.top_left == expected
58            && self.radii.top_right == expected
59            && self.radii.bottom_right == expected
60            && self.radii.bottom_left == expected
61    }
62
63    /// Scale radii down proportionally when the sum of adjacent radii
64    /// exceeds the rect's dimension. This implements the CSS spec's
65    /// corner-overlap rule.
66    #[must_use]
67    pub fn normalized(mut self) -> Self {
68        let w = self.rect.width();
69        let h = self.rect.height();
70
71        if w <= 0.0 || h <= 0.0 {
72            return Self::from_rect(self.rect);
73        }
74
75        // For each edge, if the sum of its two corner radii exceeds the
76        // edge length, compute a scale factor. The final factor is the
77        // minimum across all four edges.
78        let mut scale = 1.0f32;
79
80        // Top edge
81        let top_sum = self.radii.top_left.width + self.radii.top_right.width;
82        if top_sum > w {
83            scale = scale.min(w / top_sum);
84        }
85
86        // Bottom edge
87        let bottom_sum = self.radii.bottom_left.width + self.radii.bottom_right.width;
88        if bottom_sum > w {
89            scale = scale.min(w / bottom_sum);
90        }
91
92        // Left edge
93        let left_sum = self.radii.top_left.height + self.radii.bottom_left.height;
94        if left_sum > h {
95            scale = scale.min(h / left_sum);
96        }
97
98        // Right edge
99        let right_sum = self.radii.top_right.height + self.radii.bottom_right.height;
100        if right_sum > h {
101            scale = scale.min(h / right_sum);
102        }
103
104        if scale < 1.0 {
105            self.radii.top_left = scale_size(self.radii.top_left, scale);
106            self.radii.top_right = scale_size(self.radii.top_right, scale);
107            self.radii.bottom_right = scale_size(self.radii.bottom_right, scale);
108            self.radii.bottom_left = scale_size(self.radii.bottom_left, scale);
109        }
110
111        self
112    }
113
114    /// Hit test: is `point` inside this rounded rect?
115    ///
116    /// First checks the bounding rect, then tests against the elliptical
117    /// corner arcs for points that fall within a corner region.
118    #[must_use]
119    pub fn contains(&self, point: Point) -> bool {
120        if !self.rect.contains_point(point) {
121            return false;
122        }
123
124        let norm = self.normalized();
125
126        // Check each corner's elliptical region
127        let x = point.x;
128        let y = point.y;
129        let left = norm.rect.left();
130        let top = norm.rect.top();
131        let right = norm.rect.right();
132        let bottom = norm.rect.bottom();
133
134        // Top-left corner
135        let r = norm.radii.top_left;
136        if x < left + r.width
137            && y < top + r.height
138            && !point_in_ellipse(
139                x - (left + r.width),
140                y - (top + r.height),
141                r.width,
142                r.height,
143            )
144        {
145            return false;
146        }
147
148        // Top-right corner
149        let r = norm.radii.top_right;
150        if x > right - r.width
151            && y < top + r.height
152            && !point_in_ellipse(
153                x - (right - r.width),
154                y - (top + r.height),
155                r.width,
156                r.height,
157            )
158        {
159            return false;
160        }
161
162        // Bottom-right corner
163        let r = norm.radii.bottom_right;
164        if x > right - r.width
165            && y > bottom - r.height
166            && !point_in_ellipse(
167                x - (right - r.width),
168                y - (bottom - r.height),
169                r.width,
170                r.height,
171            )
172        {
173            return false;
174        }
175
176        // Bottom-left corner
177        let r = norm.radii.bottom_left;
178        if x < left + r.width
179            && y > bottom - r.height
180            && !point_in_ellipse(
181                x - (left + r.width),
182                y - (bottom - r.height),
183                r.width,
184                r.height,
185            )
186        {
187            return false;
188        }
189
190        true
191    }
192}
193
194fn scale_size(s: Size, factor: f32) -> Size {
195    Size::new(s.width * factor, s.height * factor)
196}
197
198/// Test whether (dx, dy) relative to an ellipse center lies inside
199/// the ellipse with semi-axes (rx, ry).
200fn point_in_ellipse(dx: f32, dy: f32, rx: f32, ry: f32) -> bool {
201    if rx <= 0.0 || ry <= 0.0 {
202        return true;
203    }
204    let nx = dx / rx;
205    let ny = dy / ry;
206    nx * nx + ny * ny <= 1.0
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn square() -> Rect {
214        Rect::new(0.0, 0.0, 100.0, 100.0)
215    }
216
217    #[test]
218    fn sharp_rect() {
219        let rr = RoundedRect::from_rect(square());
220        assert!(rr.is_sharp());
221        assert!(!rr.is_ellipse());
222    }
223
224    #[test]
225    fn uniform_radius() {
226        let rr = RoundedRect::uniform(square(), 10.0);
227        assert!(!rr.is_sharp());
228        assert!(!rr.is_ellipse());
229    }
230
231    #[test]
232    fn full_ellipse() {
233        let rr = RoundedRect::uniform(square(), 50.0);
234        assert!(rr.is_ellipse());
235    }
236
237    #[test]
238    fn contains_center() {
239        let rr = RoundedRect::uniform(square(), 20.0);
240        assert!(rr.contains(Point::new(50.0, 50.0)));
241    }
242
243    #[test]
244    fn excludes_outside() {
245        let rr = RoundedRect::uniform(square(), 20.0);
246        assert!(!rr.contains(Point::new(-1.0, 50.0)));
247        assert!(!rr.contains(Point::new(101.0, 50.0)));
248    }
249
250    #[test]
251    fn excludes_rounded_corner() {
252        // Point at (1, 1) is inside the bounding rect but outside the
253        // rounded corner with radius 20.
254        let rr = RoundedRect::uniform(square(), 20.0);
255        assert!(!rr.contains(Point::new(1.0, 1.0)));
256    }
257
258    #[test]
259    fn includes_just_inside_corner() {
260        let rr = RoundedRect::uniform(square(), 20.0);
261        // Point at (10, 10) is inside the corner arc (distance from
262        // corner center (20,20) is ~14.1, less than radius 20).
263        assert!(rr.contains(Point::new(10.0, 10.0)));
264    }
265
266    #[test]
267    fn normalize_scales_overlapping_radii() {
268        // Radii sum to 200 on each edge but rect is only 100 wide.
269        let rr = RoundedRect::uniform(square(), 100.0);
270        let norm = rr.normalized();
271        // After normalization, each radius should be 50 (half the edge).
272        assert!((norm.radii.top_left.width - 50.0).abs() < 0.01);
273    }
274
275    #[test]
276    fn sharp_rect_contains_like_rect() {
277        let rr = RoundedRect::from_rect(square());
278        assert!(rr.contains(Point::new(0.0, 0.0)));
279        assert!(rr.contains(Point::new(99.0, 99.0)));
280        assert!(!rr.contains(Point::new(100.0, 100.0)));
281    }
282}