Skip to main content

denise_render/
polygon.rs

1//! Stars, over an allocation-free scanline polygon filler.
2//!
3//! # Why there is a polygon filler behind one shape
4//!
5//! The crate documentation promises **no path builder**, and that promise is
6//! kept: what is public here is [`Canvas::fill_star`], a shape in the same
7//! sense [`Canvas::fill_circle`] is a shape. It computes its own vertices from
8//! the same Q16 sine table the arcs use — no floating point, no `libm`.
9//!
10//! The general filler underneath it stays `pub(crate)`. A star is a ten-vertex
11//! polygon and nothing else in the rasteriser draws one, so the machinery had
12//! to exist; keeping it internal means a heart, an arrow or a hexagon can be
13//! added the day one is genuinely wanted, without today committing to a public
14//! path API that would then have to be supported forever.
15//!
16//! # No allocation
17//!
18//! This crate has neither `std` nor `alloc`, so a scanline's edge crossings
19//! live in a fixed-size array on the stack — [`MAX_VERTICES`] of them, which is
20//! an eight-pointed star and more than any UI shape needs. Coverage is
21//! accumulated per pixel by summing sub-row overlaps rather than into a
22//! buffer, exactly as the rounded rectangles do it.
23
24use denise::{Point, Rect};
25
26use crate::arc::{TURN, direction};
27use crate::blend::{Paint, blend_span};
28use crate::canvas::Canvas;
29use crate::rounded::{COORD_LIMIT, ONE, SUB_STEP, SUBSAMPLES, ceil_px, floor_px, to_fx};
30
31/// The most vertices a polygon may have, and so the most crossings one
32/// scanline can produce. Sixteen points of a star is far past anything legible
33/// at UI sizes.
34pub(crate) const MAX_VERTICES: usize = 32;
35
36/// Crossings of one sub-row, sorted ascending, in fixed point.
37struct Crossings {
38    xs: [i32; MAX_VERTICES],
39    len: usize,
40}
41
42impl Crossings {
43    /// Where the polygon's edges cross the horizontal line at `sy`.
44    ///
45    /// The rule is half-open in y — an edge counts when exactly one of its
46    /// endpoints is at or above the line — which is what makes a vertex on the
47    /// line count once rather than twice or not at all.
48    fn at(points: &[(i32, i32)], sy: i32) -> Self {
49        let mut xs = [0i32; MAX_VERTICES];
50        let mut len = 0;
51        for i in 0..points.len() {
52            let (x0, y0) = points[i];
53            let (x1, y1) = points[(i + 1) % points.len()];
54            if (y0 <= sy) == (y1 <= sy) {
55                continue;
56            }
57            // y1 != y0 here: the halves disagree, so the edge is not horizontal.
58            let t = (sy - y0) as i64 * (x1 - x0) as i64 / (y1 - y0) as i64;
59            let x = x0 as i64 + t;
60            if len < MAX_VERTICES {
61                xs[len] = x as i32;
62                len += 1;
63            }
64        }
65        // Insertion sort: at most MAX_VERTICES entries, and in practice two or
66        // four. Nothing here is worth a better algorithm.
67        for i in 1..len {
68            let v = xs[i];
69            let mut j = i;
70            while j > 0 && xs[j - 1] > v {
71                xs[j] = xs[j - 1];
72                j -= 1;
73            }
74            xs[j] = v;
75        }
76        Self { xs, len }
77    }
78
79    /// How much of the pixel column `[px0, px0 + ONE)` this sub-row covers,
80    /// in fixed-point units. Even-odd: the spans are consecutive pairs.
81    fn overlap(&self, px0: i32) -> i32 {
82        let px1 = px0 + ONE;
83        let mut covered = 0;
84        let mut k = 0;
85        while k + 1 < self.len {
86            let l = self.xs[k].max(px0);
87            let r = self.xs[k + 1].min(px1);
88            covered += (r - l).max(0);
89            k += 2;
90        }
91        covered
92    }
93}
94
95impl Canvas<'_> {
96    /// Fills a polygon given in fixed point, by the even-odd rule.
97    ///
98    /// Vertices past [`MAX_VERTICES`] are ignored rather than drawn wrongly.
99    pub(crate) fn fill_polygon_fx(&mut self, points: &[(i32, i32)], paint: Paint) {
100        if points.len() < 3 || points.len() > MAX_VERTICES || paint.is_invisible() {
101            return;
102        }
103        let (mut top, mut bottom) = (i32::MAX, i32::MIN);
104        let (mut left, mut right) = (i32::MAX, i32::MIN);
105        for &(x, y) in points {
106            top = top.min(y);
107            bottom = bottom.max(y);
108            left = left.min(x);
109            right = right.max(x);
110        }
111        let bbox = Rect::from_edges(
112            floor_px(left),
113            floor_px(top),
114            ceil_px(right) + 1,
115            ceil_px(bottom) + 1,
116        );
117        let Some(vis) = self.visible(bbox) else {
118            return;
119        };
120
121        for y in vis.y..vis.bottom() {
122            let mut rows = [const {
123                Crossings {
124                    xs: [0; MAX_VERTICES],
125                    len: 0,
126                }
127            }; SUBSAMPLES];
128            let mut simple = true;
129            for (k, row) in rows.iter_mut().enumerate() {
130                let sy = to_fx(y) + k as i32 * SUB_STEP + SUB_STEP / 2;
131                *row = Crossings::at(points, sy);
132                simple &= row.len == 2;
133            }
134
135            // When every sub-row crosses exactly twice — the body of the shape,
136            // away from any notch — the fully covered run is between the
137            // rightmost left edge and the leftmost right edge, and only the two
138            // fringes have to be evaluated per pixel.
139            let (solid0, solid1) = if simple {
140                let l = rows.iter().map(|r| r.xs[0]).max().unwrap_or(0);
141                let r = rows.iter().map(|r| r.xs[1]).min().unwrap_or(0);
142                (ceil_px(l), floor_px(r))
143            } else {
144                (vis.right(), vis.right())
145            };
146
147            for x in vis.x..solid0.min(vis.right()) {
148                self.blend_at(x, y, paint, coverage(&rows, x));
149            }
150            let (s0, s1) = (solid0.max(vis.x), solid1.min(vis.right()));
151            if s0 < s1
152                && let Some(span) = self.row_span(y, s0, s1)
153            {
154                blend_span(span, paint);
155            }
156            for x in s1.max(vis.x)..vis.right() {
157                self.blend_at(x, y, paint, coverage(&rows, x));
158            }
159        }
160    }
161
162    /// Fills a star, anti-aliased.
163    ///
164    /// `points` is the number of spikes — five for the familiar one. Vertices
165    /// alternate between `outer_radius` at the tips and `inner_radius` at the
166    /// valleys, so the ratio between them is how pointed the star looks: about
167    /// `0.38` of the outer radius is the classic pentagram, and an inner radius
168    /// approaching the outer one is a polygon with `2 × points` sides.
169    ///
170    /// `rotation` is in the same binary turns as the arcs — see [`TURN`] — and
171    /// zero puts a tip at twelve o'clock.
172    ///
173    /// Vertices are computed to sub-pixel precision even though the centre is
174    /// whole pixels, which is what keeps a small star from looking chewed.
175    ///
176    /// # A five-pointed star is not exactly five-fold symmetric
177    ///
178    /// [`TURN`] is a power of two, so it divides exactly by two, four and eight
179    /// and not by five. A five-pointed star's vertex angles are therefore each
180    /// rounded to the nearest unit — at most half a unit in 65536, which is
181    /// under a hundredth of a pixel at any radius a screen can show, and
182    /// invisible. But it does mean a star rotated by `TURN / 5` is not the
183    /// bit-identical picture, only the same one. Anything that needs exactness
184    /// should compare against a tolerance rather than against pixels.
185    pub fn fill_star(
186        &mut self,
187        centre: Point,
188        outer_radius: i32,
189        inner_radius: i32,
190        points: u32,
191        rotation: i32,
192        color: impl Into<Paint>,
193    ) {
194        let paint = color.into();
195        if points < 2 || outer_radius <= 0 || paint.is_invisible() {
196            return;
197        }
198        let count = (points as usize) * 2;
199        if count > MAX_VERTICES {
200            return;
201        }
202        let outer = outer_radius.clamp(0, COORD_LIMIT) as i64;
203        let inner = inner_radius.clamp(0, outer_radius) as i64;
204
205        let mut vertices = [(0i32, 0i32); MAX_VERTICES];
206        let (cx, cy) = (to_fx(centre.x), to_fx(centre.y));
207        for (i, vertex) in vertices.iter_mut().enumerate().take(count) {
208            // Rounded, not truncated: the spacing error is then at most half a
209            // unit per vertex rather than a whole one.
210            let step = (i as i64 * TURN as i64 + count as i64 / 2) / count as i64;
211            let angle = rotation.wrapping_add(step as i32);
212            let (dx, dy) = direction(angle);
213            let r = if i % 2 == 0 { outer } else { inner };
214            // The direction is Q16 and the target is 8.8, so a radius in whole
215            // pixels lands sub-pixel after shifting off eight bits.
216            *vertex = (
217                cx + ((dx as i64 * r) >> 8) as i32,
218                cy + ((dy as i64 * r) >> 8) as i32,
219            );
220        }
221        self.fill_polygon_fx(&vertices[..count], paint);
222    }
223}
224
225/// Coverage of pixel column `x` across every sub-row, `0..=255`.
226fn coverage(rows: &[Crossings; SUBSAMPLES], x: i32) -> u32 {
227    let px0 = to_fx(x);
228    let mut covered: i32 = 0;
229    for row in rows {
230        covered += row.overlap(px0);
231    }
232    // Rounded rather than truncated, for the reason `Scan::coverage` gives: a
233    // pixel that is 99.9% covered reading as 254 is a visible hairline seam.
234    let total = ONE as u32 * SUBSAMPLES as u32;
235    ((covered.max(0) as u32 * 255 + total / 2) / total).min(255)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::testing::TestCanvas;
242    use denise::Color;
243
244    fn alpha_of(px: u32) -> u32 {
245        px & 0xFF
246    }
247
248    /// The star's vertices in f64 pixel coordinates, computed independently of
249    /// the fixed-point path — the oracle's own geometry.
250    fn star_vertices(cx: f64, cy: f64, outer: f64, inner: f64, points: usize) -> Vec<(f64, f64)> {
251        let count = points * 2;
252        (0..count)
253            .map(|i| {
254                let a = i as f64 / count as f64 * core::f64::consts::TAU;
255                let r = if i % 2 == 0 { outer } else { inner };
256                (cx + a.sin() * r, cy - a.cos() * r)
257            })
258            .collect()
259    }
260
261    /// Even-odd point-in-polygon by ray casting, in floating point.
262    fn inside(poly: &[(f64, f64)], x: f64, y: f64) -> bool {
263        let mut hit = false;
264        for i in 0..poly.len() {
265            let (x0, y0) = poly[i];
266            let (x1, y1) = poly[(i + 1) % poly.len()];
267            if (y0 > y) != (y1 > y) && x < (x1 - x0) * (y - y0) / (y1 - y0) + x0 {
268                hit = !hit;
269            }
270        }
271        hit
272    }
273
274    /// The one test that would catch a wrong filler: coverage compared against
275    /// 16x16 supersampling of an independently written point-in-polygon test.
276    /// Hand-picked cases cannot see a systematic half-pixel shift; this can.
277    #[test]
278    fn star_coverage_matches_a_supersampled_oracle() {
279        const N: i32 = 16;
280        let (cx, cy, outer, inner) = (24, 24, 20, 8);
281        let mut t = TestCanvas::new(48, 48);
282        t.canvas()
283            .fill_star(Point::new(cx, cy), outer, inner, 5, 0, Color::WHITE);
284
285        let poly = star_vertices(cx as f64, cy as f64, outer as f64, inner as f64, 5);
286        let mut worst = 0u32;
287        for y in 0..48 {
288            for x in 0..48 {
289                let mut hits = 0;
290                for sy in 0..N {
291                    for sx in 0..N {
292                        let px = x as f64 + (sx as f64 + 0.5) / N as f64;
293                        let py = y as f64 + (sy as f64 + 0.5) / N as f64;
294                        if inside(&poly, px, py) {
295                            hits += 1;
296                        }
297                    }
298                }
299                let want = (hits * 255 / (N * N)) as u32;
300                let got = alpha_of(t.at(x, y));
301                worst = worst.max(got.abs_diff(want));
302            }
303        }
304        // Four sub-rows against sixteen will differ on the fringes; a
305        // systematic error would be far larger than this.
306        assert!(worst <= 48, "worst pixel differs by {worst}");
307    }
308
309    /// Even-odd pairing walks crossings two at a time, so an odd count means a
310    /// span that never closes — ink running to the edge of the clip. The rule
311    /// that prevents it is the half-open comparison in `Crossings::at`, and the
312    /// cases that test it are the ones nothing random will generate: a vertex
313    /// exactly on a sub-row line, and a horizontal edge lying along one.
314    ///
315    /// Note that `[y0, y1)` and `(y0, y1]` are both correct here and a mutation
316    /// swapping them is not detectable — either counts a vertex once. What must
317    /// not happen is a rule that counts it twice or not at all.
318    #[test]
319    fn every_scanline_crosses_a_polygon_an_even_number_of_times() {
320        let sub_row = |y: i32, k: i32| to_fx(y) + k * SUB_STEP + SUB_STEP / 2;
321
322        // A triangle with its apex exactly on a sub-row, and a diamond with two
323        // vertices there — then a rectangle whose top and bottom edges are
324        // horizontal and lie exactly on sub-rows.
325        let apex = sub_row(10, 0);
326        let shapes: [&[(i32, i32)]; 3] = [
327            &[
328                (to_fx(10), apex),
329                (to_fx(30), to_fx(30)),
330                (to_fx(2), to_fx(28)),
331            ],
332            &[
333                (to_fx(16), apex),
334                (to_fx(28), sub_row(20, 2)),
335                (to_fx(16), to_fx(34)),
336                (to_fx(4), sub_row(20, 2)),
337            ],
338            &[
339                (to_fx(4), apex),
340                (to_fx(28), apex),
341                (to_fx(28), sub_row(30, 1)),
342                (to_fx(4), sub_row(30, 1)),
343            ],
344        ];
345
346        for (n, shape) in shapes.iter().enumerate() {
347            for y in 0..48 {
348                for k in 0..SUBSAMPLES as i32 {
349                    let c = Crossings::at(shape, sub_row(y, k));
350                    assert!(
351                        c.len.is_multiple_of(2),
352                        "shape {n} at y={y} sub-row {k} crossed {} times",
353                        c.len
354                    );
355                }
356            }
357        }
358    }
359
360    /// A horizontal edge has no crossing to compute, and computing one would
361    /// divide by zero. The skip is load-bearing, not tidiness.
362    #[test]
363    fn a_horizontal_edge_never_divides_by_zero() {
364        let mut t = TestCanvas::new(32, 32);
365        let flat: &[(i32, i32)] = &[
366            (to_fx(4), to_fx(8)),
367            (to_fx(28), to_fx(8)),
368            (to_fx(28), to_fx(20)),
369            (to_fx(4), to_fx(20)),
370        ];
371        t.canvas().fill_polygon_fx(flat, Color::WHITE.into());
372        assert_eq!(alpha_of(t.at(16, 14)), 255, "the interior must be filled");
373        assert_eq!(alpha_of(t.at(16, 2)), 0, "and nothing above it");
374    }
375
376    #[test]
377    fn a_star_has_its_tips_and_its_valleys() {
378        let mut t = TestCanvas::new(64, 64);
379        t.canvas()
380            .fill_star(Point::new(32, 32), 28, 11, 5, 0, Color::WHITE);
381        assert_eq!(alpha_of(t.at(32, 32)), 255, "the middle must be solid");
382        // A tip at twelve o'clock, and empty just outside it.
383        assert!(alpha_of(t.at(32, 8)) > 0, "no tip at twelve o'clock");
384        assert_eq!(alpha_of(t.at(32, 2)), 0, "something past the tip");
385        // The corners of the bounding box are outside every star.
386        for (x, y) in [(4, 4), (59, 4), (4, 59), (59, 59)] {
387            assert_eq!(alpha_of(t.at(x, y)), 0, "spilled at {x},{y}");
388        }
389    }
390
391    #[test]
392    fn a_star_stays_inside_its_radius_at_every_size() {
393        for radius in [3, 8, 20, 60] {
394            let mut t = TestCanvas::new(160, 160);
395            t.canvas().fill_star(
396                Point::new(80, 80),
397                radius,
398                radius * 2 / 5,
399                5,
400                0,
401                Color::WHITE,
402            );
403            for y in 0..160i32 {
404                for x in 0..160i32 {
405                    if alpha_of(t.at(x, y)) == 0 {
406                        continue;
407                    }
408                    let (dx, dy) = ((x - 80) as f64 + 0.5, (y - 80) as f64 + 0.5);
409                    let d = (dx * dx + dy * dy).sqrt();
410                    assert!(
411                        d <= radius as f64 + 1.5,
412                        "radius {radius}: ink at {x},{y} is {d} out"
413                    );
414                }
415            }
416        }
417    }
418
419    #[test]
420    fn rotation_turns_the_star_and_a_full_turn_returns_it() {
421        let draw = |rotation| {
422            let mut t = TestCanvas::new(64, 64);
423            t.canvas()
424                .fill_star(Point::new(32, 32), 24, 10, 5, rotation, Color::WHITE);
425            t
426        };
427        /// Pixels whose coverage differs by more than anti-aliasing noise.
428        fn far_apart(a: &TestCanvas, b: &TestCanvas) -> usize {
429            a.pixels()
430                .iter()
431                .zip(b.pixels())
432                .filter(|&(&p, &q)| alpha_of(p).abs_diff(alpha_of(q)) > 24)
433                .count()
434        }
435
436        let zero = draw(0);
437        assert_eq!(
438            zero.pixels(),
439            draw(TURN).pixels(),
440            "a full turn must be exactly identity"
441        );
442
443        // A fifth of a turn is the star's own symmetry — but TURN is a power of
444        // two and does not divide by five, so this is the same star and not the
445        // same pixels. See the note on `fill_star`.
446        let fifth = far_apart(&zero, &draw(TURN / 5));
447        assert!(fifth < 40, "five-fold symmetry is off by {fifth} pixels");
448
449        // Half a step is a genuinely different orientation, and must look it —
450        // otherwise the assertion above would be measuring nothing.
451        let tenth = far_apart(&zero, &draw(TURN / 10));
452        assert!(
453            tenth > 10 * fifth.max(1),
454            "half a step differs by only {tenth} against {fifth}"
455        );
456    }
457
458    #[test]
459    fn clipping_a_star_matches_the_unclipped_result() {
460        let region = Rect::new(20, 20, 24, 24);
461        let mut full = TestCanvas::new(64, 64);
462        full.canvas()
463            .fill_star(Point::new(32, 32), 26, 10, 5, 0, Color::WHITE);
464
465        let mut clipped = TestCanvas::new(64, 64);
466        {
467            let mut c = clipped.canvas();
468            c.clip_to(region);
469            c.fill_star(Point::new(32, 32), 26, 10, 5, 0, Color::WHITE);
470        }
471        for y in 0..64 {
472            for x in 0..64 {
473                let expected = if region.contains(Point::new(x, y)) {
474                    full.at(x, y)
475                } else {
476                    0
477                };
478                assert_eq!(clipped.at(x, y), expected, "at {x},{y}");
479            }
480        }
481    }
482
483    #[test]
484    fn an_inner_radius_at_the_outer_one_is_a_convex_polygon() {
485        // No spikes left: every vertex at the same radius is a 10-gon, which
486        // must be solid all the way out rather than developing notches.
487        let mut t = TestCanvas::new(64, 64);
488        t.canvas()
489            .fill_star(Point::new(32, 32), 20, 20, 5, 0, Color::WHITE);
490        assert_eq!(alpha_of(t.at(32, 32)), 255);
491        assert_eq!(alpha_of(t.at(32, 14)), 255, "a valley became a notch");
492    }
493
494    #[test]
495    fn degenerate_stars_draw_nothing_and_nobody_panics() {
496        let mut t = TestCanvas::new(32, 32);
497        let mut c = t.canvas();
498        c.fill_star(Point::new(16, 16), 0, 0, 5, 0, Color::WHITE);
499        c.fill_star(Point::new(16, 16), -10, 4, 5, 0, Color::WHITE);
500        c.fill_star(Point::new(16, 16), 10, 20, 5, 0, Color::WHITE);
501        c.fill_star(Point::new(16, 16), 10, 4, 1, 0, Color::WHITE);
502        c.fill_star(Point::new(16, 16), 10, 4, 99, 0, Color::WHITE);
503        c.fill_star(Point::new(16, 16), 10, 4, 5, i32::MIN, Color::WHITE);
504        c.fill_star(Point::new(1_000_000, 0), 10, 4, 5, 0, Color::WHITE);
505        c.fill_star(Point::new(16, 16), i32::MAX, 4, 5, 0, Color::WHITE);
506        c.fill_star(Point::new(16, 16), 10, 4, 5, 0, Color::rgba(255, 0, 0, 0));
507    }
508
509    #[test]
510    fn an_inner_radius_larger_than_the_outer_is_clamped_not_inverted() {
511        // Asking for a bigger valley than tip is a caller's arithmetic error;
512        // clamping gives a polygon rather than a self-intersecting mess.
513        let mut asked = TestCanvas::new(48, 48);
514        asked
515            .canvas()
516            .fill_star(Point::new(24, 24), 16, 999, 5, 0, Color::WHITE);
517        let mut clamped = TestCanvas::new(48, 48);
518        clamped
519            .canvas()
520            .fill_star(Point::new(24, 24), 16, 16, 5, 0, Color::WHITE);
521        assert_eq!(asked.pixels(), clamped.pixels());
522    }
523
524    #[test]
525    fn alpha_never_doubles_up_anywhere() {
526        // One pass over each pixel: a translucent star must nowhere composite
527        // itself twice, which is what a fringe overlapping a solid run does.
528        let mut t = TestCanvas::new(64, 64);
529        t.canvas().fill_star(
530            Point::new(32, 32),
531            26,
532            10,
533            5,
534            0,
535            Color::rgba(255, 255, 255, 128),
536        );
537        for y in 0..64 {
538            for x in 0..64 {
539                assert!(alpha_of(t.at(x, y)) <= 128, "double-composited at {x},{y}");
540            }
541        }
542    }
543}