Skip to main content

denise_render/
rounded.rs

1//! Anti-aliased rounded rectangles.
2//!
3//! The shape is evaluated analytically per scanline rather than by rendering a
4//! path. For each row the corner arcs give an exact horizontal inset, and the only
5//! approximation is that the arc is sampled at [`SUBSAMPLES`] sub-rows instead of
6//! integrated exactly. That is enough at UI radii and costs a handful of integer
7//! square roots per row — no path building, no allocation, no floating point.
8
9use denise::Rect;
10
11use crate::blend::{Paint, blend_span};
12use crate::canvas::Canvas;
13
14/// Sub-rows sampled per scanline. Four is the point where the near-horizontal top
15/// of a corner stops looking stepped; eight is not visibly better.
16pub(crate) const SUBSAMPLES: usize = 4;
17
18/// Fractional bits in the fixed-point coordinates used here.
19pub(crate) const FRAC_BITS: u32 = 8;
20/// One whole pixel in fixed point.
21pub(crate) const ONE: i32 = 1 << FRAC_BITS;
22/// Vertical distance between sub-rows.
23pub(crate) const SUB_STEP: i32 = ONE / SUBSAMPLES as i32;
24
25/// Coordinates are clamped to this before entering fixed point, so a rectangle
26/// placed absurdly far off-screen cannot overflow the shift. Any real surface is
27/// orders of magnitude inside it.
28pub(crate) const COORD_LIMIT: i32 = 1 << 22;
29
30#[inline]
31pub(crate) fn to_fx(v: i32) -> i32 {
32    v.clamp(-COORD_LIMIT, COORD_LIMIT) << FRAC_BITS
33}
34
35#[inline]
36pub(crate) fn floor_px(v: i32) -> i32 {
37    v.div_euclid(ONE)
38}
39
40#[inline]
41pub(crate) fn ceil_px(v: i32) -> i32 {
42    (v + ONE - 1).div_euclid(ONE)
43}
44
45/// `sqrt(radius² - dy²)`, all in fixed point.
46#[inline]
47fn arc_half_width(radius: i32, dy: i32) -> i32 {
48    let r2 = (radius as i64 * radius as i64) as u64;
49    let d2 = (dy as i64 * dy as i64) as u64;
50    r2.saturating_sub(d2).isqrt() as i32
51}
52
53/// Where a rounded rectangle starts and ends on one scanline, sampled at several
54/// sub-rows and kept sub-pixel.
55#[derive(Clone, Copy, Debug)]
56pub(crate) struct Scan {
57    pub(crate) left: [i32; SUBSAMPLES],
58    pub(crate) right: [i32; SUBSAMPLES],
59}
60
61impl Scan {
62    /// Computes the extent of `rect` with corner `radius` on row `y`.
63    pub(crate) fn new(rect: Rect, radius: i32, y: i32) -> Self {
64        let rad = to_fx(radius);
65        let top = to_fx(rect.y);
66        let bottom = to_fx(rect.bottom());
67        let left_edge = to_fx(rect.x);
68        let right_edge = to_fx(rect.right());
69
70        let mut left = [0; SUBSAMPLES];
71        let mut right = [0; SUBSAMPLES];
72
73        for k in 0..SUBSAMPLES {
74            let sy = to_fx(y) + k as i32 * SUB_STEP + SUB_STEP / 2;
75
76            // Distance into whichever corner band this sub-row falls in. Outside
77            // both bands the inset is zero and the row is a plain rectangle, which
78            // is also what a zero radius gives everywhere.
79            let from_top = sy - top;
80            let from_bottom = bottom - sy;
81            let dy = if from_top < rad {
82                rad - from_top
83            } else if from_bottom < rad {
84                rad - from_bottom
85            } else {
86                0
87            };
88
89            let inset = if dy > 0 {
90                rad - arc_half_width(rad, dy)
91            } else {
92                0
93            };
94
95            left[k] = left_edge + inset;
96            right[k] = right_edge - inset;
97        }
98
99        Scan { left, right }
100    }
101
102    /// Coverage of pixel column `x`, `0..=255`.
103    pub(crate) fn coverage(&self, x: i32) -> u32 {
104        let px0 = to_fx(x);
105        let px1 = px0 + ONE;
106        let mut covered: i32 = 0;
107        for k in 0..SUBSAMPLES {
108            let l = self.left[k].max(px0);
109            let r = self.right[k].min(px1);
110            covered += (r - l).max(0);
111        }
112        // Rounded, not truncated. Truncation leaves a pixel that is 99.9% covered
113        // reading as 254, which shows up as a hairline seam wherever a fill meets
114        // the solid span next to it.
115        let total = ONE as u32 * SUBSAMPLES as u32;
116        ((covered as u32 * 255 + total / 2) / total).min(255)
117    }
118
119    #[inline]
120    pub(crate) fn min_left(&self) -> i32 {
121        *self.left.iter().min().expect("SUBSAMPLES > 0")
122    }
123
124    #[inline]
125    pub(crate) fn max_left(&self) -> i32 {
126        *self.left.iter().max().expect("SUBSAMPLES > 0")
127    }
128
129    #[inline]
130    pub(crate) fn min_right(&self) -> i32 {
131        *self.right.iter().min().expect("SUBSAMPLES > 0")
132    }
133
134    #[inline]
135    pub(crate) fn max_right(&self) -> i32 {
136        *self.right.iter().max().expect("SUBSAMPLES > 0")
137    }
138}
139
140/// Coverage of a filled shape, or of the gap between two — a stroke.
141struct Coverage<'a> {
142    outer: &'a Scan,
143    inner: Option<&'a Scan>,
144}
145
146impl Coverage<'_> {
147    #[inline]
148    fn at(&self, x: i32) -> u32 {
149        let outer = self.outer.coverage(x);
150        match self.inner {
151            None => outer,
152            Some(inner) => {
153                let hole = inner.coverage(x);
154                if hole == 0 {
155                    outer
156                } else {
157                    // Correct rounding matters here: an off-by-one on a 1px stroke
158                    // is a visible seam between the band and the solid interior.
159                    (outer * (255 - hole) + 127) / 255
160                }
161            }
162        }
163    }
164}
165
166impl Canvas<'_> {
167    /// Fills a rectangle with rounded corners, anti-aliased.
168    ///
169    /// `radius` is clamped to half the shorter side; a radius of zero is exactly
170    /// [`Canvas::fill_rect`].
171    pub fn fill_rounded_rect(&mut self, rect: Rect, radius: i32, color: impl Into<Paint>) {
172        let paint = color.into();
173        if paint.is_invisible() || rect.is_empty() {
174            return;
175        }
176        let radius = radius.clamp(0, rect.width.min(rect.height) / 2);
177        if radius == 0 {
178            self.fill_rect(rect, paint);
179            return;
180        }
181        let Some(vis) = self.visible(rect) else {
182            return;
183        };
184
185        for y in vis.y..vis.bottom() {
186            let outer = Scan::new(rect, radius, y);
187            let cov = Coverage {
188                outer: &outer,
189                inner: None,
190            };
191            self.emit_run(
192                y,
193                floor_px(outer.min_left()),
194                ceil_px(outer.max_right()),
195                Some((ceil_px(outer.max_left()), floor_px(outer.min_right()))),
196                &cov,
197                paint,
198            );
199        }
200    }
201
202    /// Draws a rounded border of `thickness` pixels inside `rect`, anti-aliased.
203    ///
204    /// The inner radius follows the outer one so the band keeps a constant width
205    /// around the corner.
206    pub fn stroke_rounded_rect(
207        &mut self,
208        rect: Rect,
209        radius: i32,
210        thickness: i32,
211        color: impl Into<Paint>,
212    ) {
213        let paint = color.into();
214        let t = thickness.max(0);
215        if t == 0 || rect.is_empty() || paint.is_invisible() {
216            return;
217        }
218        let radius = radius.clamp(0, rect.width.min(rect.height) / 2);
219        if t * 2 >= rect.width.min(rect.height) {
220            self.fill_rounded_rect(rect, radius, paint);
221            return;
222        }
223
224        let inner = Rect::new(
225            rect.x + t,
226            rect.y + t,
227            rect.width - 2 * t,
228            rect.height - 2 * t,
229        );
230        let inner_radius = (radius - t).max(0);
231
232        let Some(vis) = self.visible(rect) else {
233            return;
234        };
235
236        for y in vis.y..vis.bottom() {
237            let outer = Scan::new(rect, radius, y);
238
239            // Above and below the inner rectangle the whole row is stroke.
240            if y < inner.y || y >= inner.bottom() {
241                let cov = Coverage {
242                    outer: &outer,
243                    inner: None,
244                };
245                self.emit_run(
246                    y,
247                    floor_px(outer.min_left()),
248                    ceil_px(outer.max_right()),
249                    Some((ceil_px(outer.max_left()), floor_px(outer.min_right()))),
250                    &cov,
251                    paint,
252                );
253                continue;
254            }
255
256            let inner_scan = Scan::new(inner, inner_radius, y);
257            let cov = Coverage {
258                outer: &outer,
259                inner: Some(&inner_scan),
260            };
261
262            // Two bands, skipping the interior entirely rather than blending it at
263            // zero coverage. On a 1080p dialog that is the difference between
264            // touching the border and touching the whole rectangle.
265            self.emit_run(
266                y,
267                floor_px(outer.min_left()),
268                ceil_px(inner_scan.max_left()),
269                Some((ceil_px(outer.max_left()), floor_px(inner_scan.min_left()))),
270                &cov,
271                paint,
272            );
273            self.emit_run(
274                y,
275                floor_px(inner_scan.min_right()),
276                ceil_px(outer.max_right()),
277                Some((ceil_px(inner_scan.max_right()), floor_px(outer.min_right()))),
278                &cov,
279                paint,
280            );
281        }
282    }
283
284    /// Emits one horizontal run: anti-aliased fringe, solid span, anti-aliased
285    /// fringe. `solid` is the half-open range that is known to be fully covered.
286    fn emit_run(
287        &mut self,
288        y: i32,
289        from: i32,
290        to: i32,
291        solid: Option<(i32, i32)>,
292        cov: &Coverage<'_>,
293        paint: Paint,
294    ) {
295        let clip = self.clip();
296        let from = from.max(clip.x);
297        let to = to.min(clip.right());
298        if from >= to {
299            return;
300        }
301
302        let (s0, s1) = match solid {
303            Some((s0, s1)) if s0 < s1 => (s0.clamp(from, to), s1.clamp(from, to)),
304            _ => (to, to),
305        };
306
307        for x in from..s0 {
308            self.blend_at(x, y, paint, cov.at(x));
309        }
310        if s0 < s1
311            && let Some(span) = self.row_span(y, s0, s1)
312        {
313            blend_span(span, paint);
314        }
315        for x in s1..to {
316            self.blend_at(x, y, paint, cov.at(x));
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::testing::TestCanvas;
325    use denise::Color;
326
327    fn alpha_of(px: u32) -> u32 {
328        // Opaque white on a black canvas, so any channel reads back as coverage.
329        px & 0xFF
330    }
331
332    #[test]
333    fn zero_radius_is_exactly_a_fill() {
334        let mut rounded = TestCanvas::new(16, 16);
335        rounded
336            .canvas()
337            .fill_rounded_rect(Rect::new(2, 2, 12, 12), 0, Color::WHITE);
338
339        let mut square = TestCanvas::new(16, 16);
340        square
341            .canvas()
342            .fill_rect(Rect::new(2, 2, 12, 12), Color::WHITE);
343
344        assert_eq!(rounded.pixels(), square.pixels());
345    }
346
347    #[test]
348    fn corners_are_cut_and_the_middle_is_not() {
349        let mut t = TestCanvas::new(32, 32);
350        t.canvas()
351            .fill_rounded_rect(Rect::new(0, 0, 32, 32), 8, Color::WHITE);
352
353        assert_eq!(alpha_of(t.at(0, 0)), 0, "corner must be empty");
354        assert_eq!(alpha_of(t.at(16, 0)), 255, "top edge must be solid");
355        assert_eq!(alpha_of(t.at(0, 16)), 255, "left edge must be solid");
356        assert_eq!(alpha_of(t.at(16, 16)), 255, "centre must be solid");
357        assert_eq!(alpha_of(t.at(31, 31)), 0, "corner must be empty");
358    }
359
360    #[test]
361    fn corners_are_antialiased_not_stepped() {
362        let radius = 10;
363        let mut t = TestCanvas::new(32, 32);
364        t.canvas()
365            .fill_rounded_rect(Rect::new(0, 0, 32, 32), radius, Color::WHITE);
366
367        // Not along the 45° diagonal: there the arc runs perpendicular to the walk
368        // and genuinely does step from 0 to 255 in one pixel. The anti-aliasing
369        // lives on the near-horizontal and near-vertical stretches of the arc, so
370        // count partial pixels over the whole corner block instead.
371        let partial = (0..radius)
372            .flat_map(|y| (0..radius).map(move |x| (x, y)))
373            .filter(|&(x, y)| (1..255).contains(&alpha_of(t.at(x, y))))
374            .count();
375
376        // An arc of radius 10 crosses roughly 2r pixel boundaries; well under half
377        // that means the edge is being quantised, not anti-aliased.
378        assert!(
379            partial >= radius as usize,
380            "only {partial} partially covered pixels in a radius-{radius} corner"
381        );
382    }
383
384    #[test]
385    fn coverage_reaches_both_extremes() {
386        // Rounding must still let a fully covered pixel read as opaque and a fully
387        // empty one as clear, rather than parking everything in between.
388        let mut t = TestCanvas::new(32, 32);
389        t.canvas()
390            .fill_rounded_rect(Rect::new(0, 0, 32, 32), 10, Color::WHITE);
391        assert_eq!(alpha_of(t.at(0, 0)), 0);
392        assert_eq!(alpha_of(t.at(16, 16)), 255);
393    }
394
395    #[test]
396    fn shape_is_symmetric() {
397        let mut t = TestCanvas::new(32, 24);
398        t.canvas()
399            .fill_rounded_rect(Rect::new(0, 0, 32, 24), 7, Color::WHITE);
400
401        for y in 0..24 {
402            for x in 0..16 {
403                assert_eq!(t.at(x, y), t.at(31 - x, y), "mirror at {x},{y}");
404            }
405        }
406        for y in 0..12 {
407            for x in 0..32 {
408                assert_eq!(t.at(x, y), t.at(x, 23 - y), "flip at {x},{y}");
409            }
410        }
411    }
412
413    #[test]
414    fn radius_is_clamped_to_half_the_shorter_side() {
415        // A radius past the clamp is a stadium, not an error, and must stay inside.
416        let mut t = TestCanvas::new(40, 20);
417        t.canvas()
418            .fill_rounded_rect(Rect::new(0, 0, 40, 20), 999, Color::WHITE);
419        assert_eq!(alpha_of(t.at(20, 10)), 255);
420        assert_eq!(alpha_of(t.at(0, 0)), 0);
421        assert_eq!(alpha_of(t.at(20, 0)), 255);
422    }
423
424    #[test]
425    fn fill_stays_inside_its_bounds() {
426        let mut t = TestCanvas::new(32, 32);
427        t.canvas()
428            .fill_rounded_rect(Rect::new(8, 8, 16, 16), 4, Color::WHITE);
429        for y in 0..32 {
430            for x in 0..32 {
431                let inside = (8..24).contains(&x) && (8..24).contains(&y);
432                if !inside {
433                    assert_eq!(t.at(x, y), 0, "spilled at {x},{y}");
434                }
435            }
436        }
437    }
438
439    #[test]
440    fn clipping_a_rounded_fill_matches_the_unclipped_result() {
441        let region = Rect::new(4, 4, 10, 10);
442
443        let mut full = TestCanvas::new(32, 32);
444        full.canvas()
445            .fill_rounded_rect(Rect::new(2, 2, 24, 24), 6, Color::WHITE);
446
447        let mut clipped = TestCanvas::new(32, 32);
448        {
449            let mut c = clipped.canvas();
450            c.clip_to(region);
451            c.fill_rounded_rect(Rect::new(2, 2, 24, 24), 6, Color::WHITE);
452        }
453
454        for y in 0..32 {
455            for x in 0..32 {
456                let expected = if region.contains(denise::Point::new(x, y)) {
457                    full.at(x, y)
458                } else {
459                    0
460                };
461                assert_eq!(clipped.at(x, y), expected, "at {x},{y}");
462            }
463        }
464    }
465
466    #[test]
467    fn stroke_leaves_the_interior_alone() {
468        let mut t = TestCanvas::new(32, 32);
469        t.canvas()
470            .stroke_rounded_rect(Rect::new(2, 2, 28, 28), 8, 3, Color::WHITE);
471        assert_eq!(alpha_of(t.at(16, 16)), 0, "interior must be untouched");
472        assert_eq!(alpha_of(t.at(16, 2)), 255, "top band must be solid");
473        assert_eq!(alpha_of(t.at(2, 16)), 255, "left band must be solid");
474        assert_eq!(alpha_of(t.at(16, 6)), 0, "just inside the band");
475    }
476
477    #[test]
478    fn stroke_covers_the_band_without_seams() {
479        // Every pixel across the band, at the mid-height where the stroke is
480        // vertical, must be fully covered. A gap here is the classic
481        // outer-minus-inner rounding seam.
482        let mut t = TestCanvas::new(40, 40);
483        t.canvas()
484            .stroke_rounded_rect(Rect::new(4, 4, 32, 32), 10, 4, Color::WHITE);
485        for x in 4..8 {
486            assert_eq!(alpha_of(t.at(x, 20)), 255, "seam at x={x}");
487        }
488    }
489
490    #[test]
491    fn stroke_thicker_than_the_shape_is_a_fill() {
492        let mut t = TestCanvas::new(32, 32);
493        t.canvas()
494            .stroke_rounded_rect(Rect::new(4, 4, 16, 16), 4, 99, Color::WHITE);
495        assert_eq!(alpha_of(t.at(12, 12)), 255);
496        assert_eq!(alpha_of(t.at(0, 0)), 0);
497    }
498
499    #[test]
500    fn stroke_alpha_does_not_double_up_anywhere() {
501        // Two bands meeting must never composite the same pixel twice.
502        let mut t = TestCanvas::new(40, 40);
503        t.canvas().stroke_rounded_rect(
504            Rect::new(4, 4, 32, 32),
505            10,
506            3,
507            Color::rgba(255, 255, 255, 128),
508        );
509        let single = alpha_of(t.at(20, 4));
510        for y in 0..40 {
511            for x in 0..40 {
512                assert!(
513                    alpha_of(t.at(x, y)) <= single,
514                    "double-composited at {x},{y}"
515                );
516            }
517        }
518    }
519
520    #[test]
521    fn degenerate_rects_do_not_panic() {
522        let mut t = TestCanvas::new(16, 16);
523        let mut c = t.canvas();
524        c.fill_rounded_rect(Rect::new(0, 0, 1, 1), 4, Color::WHITE);
525        c.fill_rounded_rect(Rect::new(0, 0, 0, 10), 4, Color::WHITE);
526        c.fill_rounded_rect(Rect::new(-100, -100, 8, 8), 3, Color::WHITE);
527        c.stroke_rounded_rect(Rect::new(0, 0, 2, 2), 1, 1, Color::WHITE);
528        c.stroke_rounded_rect(Rect::new(1_000_000, 0, 8, 8), 3, 1, Color::WHITE);
529    }
530}