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