Skip to main content

denise_render/
arc.rs

1//! Anti-aliased circles and arcs.
2//!
3//! The same machinery as the rounded rectangles — per-scanline extents in fixed
4//! point, sampled at [`SUBSAMPLES`] sub-rows, integer square roots — with one
5//! addition: an angular cut. An arc is the ring between two radii, intersected
6//! with the sector between two rays, and on any one sub-row both of those are
7//! just intervals of x. No trigonometry runs per pixel; the two ray directions
8//! are looked up once per call.
9//!
10//! # Angles are binary turns
11//!
12//! A full revolution is [`TURN`] = 65536, angle 0 points at twelve o'clock, and
13//! positive sweeps go clockwise. This is the unit a panel actually computes in:
14//! a progress ring is `done * TURN / total` with no floating point and no π, and
15//! wrap-around is `& (TURN - 1)` rather than a comparison nobody remembers to
16//! write. Radians would put `f32` and a libm dependency in the one crate that
17//! has neither; degrees would make the common quarters 90/180/270 but leave the
18//! progress ring with a rounding remainder. Negative sweeps go anticlockwise.
19//!
20//! # The sine table
21//!
22//! `sin` and `cos` are not in `core`. The two ray directions come from a
23//! 257-entry quarter-wave table in Q16, linearly interpolated — the worst error
24//! against the real function is under 2 parts in 65536, which at a radius of a
25//! thousand pixels misplaces a ray endpoint by a fortieth of a pixel. The table
26//! is checked exhaustively by tests: every one of the 65536 angles must satisfy
27//! the Pythagorean identity to within a part in a thousand, and the quarters
28//! must be exact.
29
30use denise::{Point, Rect};
31
32use crate::blend::Paint;
33use crate::canvas::Canvas;
34use crate::rounded::{COORD_LIMIT, ONE, SUB_STEP, SUBSAMPLES, Scan, ceil_px, floor_px, to_fx};
35
36/// One full revolution, in the angle unit every arc call takes.
37///
38/// Angle 0 is twelve o'clock; positive angles go clockwise. `TURN / 4` is three
39/// o'clock, `TURN / 2` six, and a progress ring at 30% is a sweep of
40/// `30 * TURN / 100`.
41pub const TURN: i32 = 1 << 16;
42
43/// sin(i / 256 · τ/4) · 2^16, for the first quarter turn inclusive.
44///
45/// Generated from the real function and pinned by exhaustive tests rather than
46/// trusted; see the module documentation.
47#[rustfmt::skip]
48const SIN_QUARTER: [i32; 257] = [
49    0, 402, 804, 1206, 1608, 2010, 2412, 2814,
50    3216, 3617, 4019, 4420, 4821, 5222, 5623, 6023,
51    6424, 6824, 7224, 7623, 8022, 8421, 8820, 9218,
52    9616, 10014, 10411, 10808, 11204, 11600, 11996, 12391,
53    12785, 13180, 13573, 13966, 14359, 14751, 15143, 15534,
54    15924, 16314, 16703, 17091, 17479, 17867, 18253, 18639,
55    19024, 19409, 19792, 20175, 20557, 20939, 21320, 21699,
56    22078, 22457, 22834, 23210, 23586, 23961, 24335, 24708,
57    25080, 25451, 25821, 26190, 26558, 26925, 27291, 27656,
58    28020, 28383, 28745, 29106, 29466, 29824, 30182, 30538,
59    30893, 31248, 31600, 31952, 32303, 32652, 33000, 33347,
60    33692, 34037, 34380, 34721, 35062, 35401, 35738, 36075,
61    36410, 36744, 37076, 37407, 37736, 38064, 38391, 38716,
62    39040, 39362, 39683, 40002, 40320, 40636, 40951, 41264,
63    41576, 41886, 42194, 42501, 42806, 43110, 43412, 43713,
64    44011, 44308, 44604, 44898, 45190, 45480, 45769, 46056,
65    46341, 46624, 46906, 47186, 47464, 47741, 48015, 48288,
66    48559, 48828, 49095, 49361, 49624, 49886, 50146, 50404,
67    50660, 50914, 51166, 51417, 51665, 51911, 52156, 52398,
68    52639, 52878, 53114, 53349, 53581, 53812, 54040, 54267,
69    54491, 54714, 54934, 55152, 55368, 55582, 55794, 56004,
70    56212, 56418, 56621, 56823, 57022, 57219, 57414, 57607,
71    57798, 57986, 58172, 58356, 58538, 58718, 58896, 59071,
72    59244, 59415, 59583, 59750, 59914, 60075, 60235, 60392,
73    60547, 60700, 60851, 60999, 61145, 61288, 61429, 61568,
74    61705, 61839, 61971, 62101, 62228, 62353, 62476, 62596,
75    62714, 62830, 62943, 63054, 63162, 63268, 63372, 63473,
76    63572, 63668, 63763, 63854, 63944, 64031, 64115, 64197,
77    64277, 64354, 64429, 64501, 64571, 64639, 64704, 64766,
78    64827, 64884, 64940, 64993, 65043, 65091, 65137, 65180,
79    65220, 65259, 65294, 65328, 65358, 65387, 65413, 65436,
80    65457, 65476, 65492, 65505, 65516, 65525, 65531, 65535,
81    65536,
82];
83
84/// sin of a binary-turn angle, in Q16.
85fn sin_bam(angle: i32) -> i32 {
86    let a = angle.rem_euclid(TURN);
87    let quarter = TURN / 4;
88    let (quadrant, q) = (a / quarter, a % quarter);
89    // Fold into the first quarter. The fold reaches q = quarter inclusive, which
90    // is the last table entry with nothing to interpolate towards.
91    let lookup = |q: i32| -> i32 {
92        let idx = (q >> 6) as usize;
93        let frac = q & 63;
94        if frac == 0 {
95            SIN_QUARTER[idx]
96        } else {
97            SIN_QUARTER[idx] + (SIN_QUARTER[idx + 1] - SIN_QUARTER[idx]) * frac / 64
98        }
99    };
100    match quadrant {
101        0 => lookup(q),
102        1 => lookup(quarter - q),
103        2 => -lookup(q),
104        _ => -lookup(quarter - q),
105    }
106}
107
108/// The unit vector of a clock angle, in Q16 screen coordinates (y down).
109///
110/// Twelve o'clock is (0, -1), three o'clock (1, 0).
111pub(crate) fn direction(angle: i32) -> (i32, i32) {
112    (sin_bam(angle), -sin_bam(angle + TURN / 4))
113}
114
115/// Sentinel for an unbounded side of a row interval. Far beyond any coordinate
116/// fixed point can carry, and far from overflowing anything it is added to.
117const UNBOUNDED: i64 = i64::MAX / 4;
118
119/// floor(b / a) for any sign of `a`.
120fn floor_div(b: i64, a: i64) -> i64 {
121    if a < 0 {
122        (-b).div_euclid(-a)
123    } else {
124        b.div_euclid(a)
125    }
126}
127
128/// ceil(b / a) for any sign of `a`.
129fn ceil_div(b: i64, a: i64) -> i64 {
130    -floor_div(-b, a)
131}
132
133/// The x-interval of one row that a half-plane through the centre keeps.
134///
135/// The half-plane is `cross(d, p - c) >= 0` (or `<= 0` for `keep_ge = false`)
136/// with `cross(d, r) = d.x·ry - d.y·rx`. On the row at height `ry` (fixed point,
137/// relative to the centre) that is linear in `rx`, so it keeps a half-line —
138/// or the whole row, or none of it, when the boundary ray is horizontal.
139fn half_plane(d: (i32, i32), ry: i64, keep_ge: bool) -> Option<(i64, i64)> {
140    let b = d.0 as i64 * ry;
141    let a = d.1 as i64;
142    if a == 0 {
143        let keeps = if keep_ge { b >= 0 } else { b <= 0 };
144        return keeps.then_some((-UNBOUNDED, UNBOUNDED));
145    }
146    // keep_ge:  a·rx <= b.  Otherwise:  a·rx >= b.  Dividing flips with a's sign.
147    let bounded_above = (a > 0) == keep_ge;
148    if bounded_above {
149        Some((-UNBOUNDED, floor_div(b, a)))
150    } else {
151        Some((ceil_div(b, a), UNBOUNDED))
152    }
153}
154
155/// The x-interval of one row inside the sector from `s` clockwise to `e`.
156///
157/// Only valid for sweeps of at most half a turn, where a sector is the
158/// intersection of two half-planes; a wider sweep is handled by its caller as
159/// the complement of the narrower one.
160fn sector_row(s: (i32, i32), e: (i32, i32), ry: i64) -> Option<(i64, i64)> {
161    let (lo_a, hi_a) = half_plane(s, ry, true)?;
162    let (lo_b, hi_b) = half_plane(e, ry, false)?;
163    let lo = lo_a.max(lo_b);
164    let hi = hi_a.min(hi_b);
165    (lo <= hi).then_some((lo, hi))
166}
167
168/// How the angular cut applies to a row interval.
169enum Cut {
170    /// Keep what falls inside the sector: a sweep of at most half a turn.
171    Keep,
172    /// Remove what falls inside the sector: the complement, for wider sweeps.
173    Remove,
174}
175
176/// The spans of one scanline, per sub-row, after every cut. At most four per
177/// sub-row: the ring contributes two, and removing a wedge can split one.
178struct RowSpans {
179    spans: [[(i32, i32); 4]; SUBSAMPLES],
180    counts: [usize; SUBSAMPLES],
181}
182
183impl RowSpans {
184    /// Total coverage of pixel column `x`, `0..=255` — the same rounding as the
185    /// rounded rectangles, so the two primitives meet without seams.
186    fn coverage(&self, x: i32) -> u32 {
187        let px0 = to_fx(x);
188        let px1 = px0 + ONE;
189        let mut covered: i32 = 0;
190        for k in 0..SUBSAMPLES {
191            for &(l, r) in &self.spans[k][..self.counts[k]] {
192                covered += (r.min(px1) - l.max(px0)).max(0);
193            }
194        }
195        let total = ONE as u32 * SUBSAMPLES as u32;
196        ((covered as u32 * 255 + total / 2) / total).min(255)
197    }
198}
199
200/// The pixel-column ranges a row's spans touch, merged so no column is visited
201/// twice — visiting one twice would composite translucent paint twice.
202struct Clusters {
203    runs: [(i32, i32); 16],
204    count: usize,
205}
206
207impl Clusters {
208    fn new() -> Self {
209        Self {
210            runs: [(0, 0); 16],
211            count: 0,
212        }
213    }
214
215    fn push(&mut self, from: i32, to: i32) {
216        if from >= to {
217            return;
218        }
219        // Merge with anything overlapping or adjacent, repeatedly: absorbing one
220        // run can bring the result into contact with another.
221        let mut from = from;
222        let mut to = to;
223        let mut i = 0;
224        while i < self.count {
225            let (a, b) = self.runs[i];
226            if from <= b && to >= a {
227                from = from.min(a);
228                to = to.max(b);
229                self.count -= 1;
230                self.runs[i] = self.runs[self.count];
231            } else {
232                i += 1;
233            }
234        }
235        if self.count < self.runs.len() {
236            self.runs[self.count] = (from, to);
237            self.count += 1;
238        }
239    }
240}
241
242impl Canvas<'_> {
243    /// Fills a circle of `radius` around `centre`, anti-aliased.
244    ///
245    /// Exactly [`Canvas::fill_rounded_rect`] on the bounding square — this name
246    /// exists so the intent is readable and the radius impossible to get wrong.
247    /// The painted diameter is `2 * radius` pixels.
248    pub fn fill_circle(&mut self, centre: Point, radius: i32, color: impl Into<Paint>) {
249        let r = radius.clamp(0, COORD_LIMIT);
250        let square = bounding_square(centre, r);
251        self.fill_rounded_rect(square, r, color);
252    }
253
254    /// Draws a ring of `thickness` pixels just inside the circle of `radius`
255    /// around `centre`, anti-aliased.
256    pub fn stroke_circle(
257        &mut self,
258        centre: Point,
259        radius: i32,
260        thickness: i32,
261        color: impl Into<Paint>,
262    ) {
263        let r = radius.clamp(0, COORD_LIMIT);
264        let square = bounding_square(centre, r);
265        self.stroke_rounded_rect(square, r, thickness.min(COORD_LIMIT), color);
266    }
267
268    /// Draws part of a ring: from `start`, sweeping `sweep`, both in units of
269    /// [`TURN`]. Angle 0 is twelve o'clock and positive sweeps go clockwise;
270    /// a negative sweep goes the other way. The ends are cut flat along the
271    /// radius — butt caps.
272    ///
273    /// A sweep of at least a full [`TURN`] is exactly [`Canvas::stroke_circle`],
274    /// which is what lets a progress ring pass `done * TURN / total` without
275    /// special-casing 100%. A `thickness` of at least `radius` fills to the
276    /// centre, which makes a pie slice.
277    ///
278    /// The cost is proportional to the pixels the arc actually covers, not to
279    /// its bounding square — a thin spinner touches a thin ring of pixels. What
280    /// to *damage* for an animated arc is the caller's business, but the same
281    /// property means a conservative bounding rectangle only costs rasterising
282    /// the ring inside it, not the square.
283    pub fn stroke_arc(
284        &mut self,
285        centre: Point,
286        radius: i32,
287        thickness: i32,
288        start: i32,
289        sweep: i32,
290        color: impl Into<Paint>,
291    ) {
292        let paint = color.into();
293        let r = radius.clamp(0, COORD_LIMIT);
294        let t = thickness.clamp(0, COORD_LIMIT).min(r);
295        if r == 0 || t == 0 || sweep == 0 || paint.is_invisible() {
296            return;
297        }
298
299        // Widened before normalising: negating `i32::MIN` overflows, and a
300        // sweep is allowed to be anything.
301        let mut start = start as i64;
302        let mut sweep = sweep as i64;
303        if sweep < 0 {
304            start += sweep;
305            sweep = -sweep;
306        }
307        if sweep >= TURN as i64 {
308            self.stroke_circle(centre, r, t, paint);
309            return;
310        }
311        let start = start.rem_euclid(TURN as i64) as i32;
312        let sweep = sweep as i32;
313
314        // A sector of at most half a turn is the intersection of two
315        // half-planes. A wider one is not — but its complement is, so the wider
316        // arc keeps what the complementary wedge does not claim.
317        let (cut, from, to) = if sweep <= TURN / 2 {
318            (Cut::Keep, start, start + sweep)
319        } else {
320            (Cut::Remove, start + sweep, start + TURN)
321        };
322        let s_dir = direction(from);
323        let e_dir = direction(to);
324
325        let square = bounding_square(centre, r);
326        let Some(vis) = self.visible(square) else {
327            return;
328        };
329        let inner_r = r - t;
330        let inner_square = bounding_square(centre, inner_r);
331        let centre_y = to_fx(centre.y);
332        // The sector arithmetic is relative to the centre; the chords are
333        // absolute. This is the shift that reconciles them.
334        let centre_x = to_fx(centre.x) as i64;
335
336        for y in vis.y..vis.bottom() {
337            let outer = Scan::new(square, r, y);
338            let hole = inner_r > 0 && y >= inner_square.y && y < inner_square.bottom();
339            let inner = if hole {
340                Some(Scan::new(inner_square, inner_r, y))
341            } else {
342                None
343            };
344
345            let mut row = RowSpans {
346                spans: [[(0, 0); 4]; SUBSAMPLES],
347                counts: [0; SUBSAMPLES],
348            };
349            let mut clusters = Clusters::new();
350
351            for k in 0..SUBSAMPLES {
352                let sy = to_fx(y) + k as i32 * SUB_STEP + SUB_STEP / 2;
353                let ry = (sy - centre_y) as i64;
354
355                // The ring on this sub-row: the outer chord, minus the inner
356                // one when it exists.
357                let (ol, or_) = (outer.left[k], outer.right[k]);
358                if ol >= or_ {
359                    continue;
360                }
361                let ring: [(i32, i32); 2] = match &inner {
362                    Some(inner) if inner.left[k] < inner.right[k] => {
363                        [(ol, inner.left[k]), (inner.right[k], or_)]
364                    }
365                    _ => [(ol, or_), (0, 0)],
366                };
367
368                let sector = sector_row(s_dir, e_dir, ry)
369                    .map(|(lo, hi)| (lo.saturating_add(centre_x), hi.saturating_add(centre_x)));
370                let mut push = |l: i64, r: i64| {
371                    // Clamped into the chord *before* narrowing: these values
372                    // can carry the UNBOUNDED sentinel, and `as i32` on that
373                    // truncates — which once turned "no piece at all" into
374                    // "the whole chord".
375                    let l = l.clamp(ol as i64, or_ as i64) as i32;
376                    let r = r.clamp(ol as i64, or_ as i64) as i32;
377                    if l < r {
378                        let n = &mut row.counts[k];
379                        row.spans[k][*n] = (l, r);
380                        *n += 1;
381                        clusters.push(floor_px(l), ceil_px(r));
382                    }
383                };
384
385                for &(l, r) in ring.iter().filter(|(l, r)| l < r) {
386                    match (&cut, sector) {
387                        (Cut::Keep, None) => {}
388                        (Cut::Keep, Some((cl, ch))) => {
389                            push((l as i64).max(cl), (r as i64).min(ch));
390                        }
391                        (Cut::Remove, None) => push(l as i64, r as i64),
392                        (Cut::Remove, Some((wl, wh))) => {
393                            push(l as i64, (r as i64).min(wl));
394                            push((l as i64).max(wh), r as i64);
395                        }
396                    }
397                }
398            }
399
400            let clip = self.clip();
401            for &(from, to) in &clusters.runs[..clusters.count] {
402                let from = from.max(clip.x);
403                let to = to.min(clip.right());
404                for x in from..to {
405                    self.blend_at(x, y, paint, row.coverage(x));
406                }
407            }
408        }
409    }
410}
411
412/// The square a circle of `radius` around `centre` fits in, saturating so an
413/// absurd centre cannot overflow — the coordinates are clamped again on their
414/// way into fixed point.
415fn bounding_square(centre: Point, radius: i32) -> Rect {
416    Rect::new(
417        centre.x.saturating_sub(radius),
418        centre.y.saturating_sub(radius),
419        radius.saturating_mul(2),
420        radius.saturating_mul(2),
421    )
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::testing::TestCanvas;
428    use denise::Color;
429
430    fn alpha_of(px: u32) -> u32 {
431        // Opaque white on a black canvas, so any channel reads back as coverage.
432        px & 0xFF
433    }
434
435    // ------------------------------------------------------------- the table
436
437    /// The table is data and data can rot, so it is held to the mathematics it
438    /// claims to encode: every one of the 65536 angles must satisfy the
439    /// Pythagorean identity to about a part in a thousand.
440    #[test]
441    fn every_angle_satisfies_the_pythagorean_identity() {
442        for a in 0..TURN {
443            let s = sin_bam(a) as i64;
444            let c = sin_bam(a + TURN / 4) as i64;
445            let one = (s * s + c * c) >> 16;
446            assert!(
447                (one - 65536).abs() < 64,
448                "angle {a}: sin²+cos² is {one}, not 65536"
449            );
450        }
451    }
452
453    /// The quarters are exact, not approximately right: a progress ring at
454    /// exactly 25% must point at exactly three o'clock.
455    #[test]
456    fn the_cardinal_directions_are_exact() {
457        assert_eq!(direction(0), (0, -65536), "twelve o'clock");
458        assert_eq!(direction(TURN / 4), (65536, 0), "three o'clock");
459        assert_eq!(direction(TURN / 2), (0, 65536), "six o'clock");
460        assert_eq!(direction(3 * TURN / 4), (-65536, 0), "nine o'clock");
461        assert_eq!(direction(TURN), (0, -65536), "and round again");
462        assert_eq!(direction(-TURN / 4), (-65536, 0), "negative wraps too");
463    }
464
465    /// Monotone over the first quarter — a table with a transposed pair of
466    /// entries would still pass the identity test within tolerance.
467    #[test]
468    fn sine_rises_monotonically_over_the_first_quarter() {
469        let mut previous = -1;
470        for a in 0..=TURN / 4 {
471            let s = sin_bam(a);
472            assert!(s >= previous, "sin fell at angle {a}");
473            previous = s;
474        }
475    }
476
477    // ------------------------------------------------------------ the shapes
478
479    /// The named wrappers are exactly the rounded-rect primitives on the
480    /// bounding square — same pixels, no second implementation to drift.
481    #[test]
482    fn a_circle_is_exactly_a_fully_rounded_square() {
483        let mut circle = TestCanvas::new(48, 48);
484        circle
485            .canvas()
486            .fill_circle(Point::new(24, 24), 20, Color::WHITE);
487        let mut square = TestCanvas::new(48, 48);
488        square
489            .canvas()
490            .fill_rounded_rect(Rect::new(4, 4, 40, 40), 20, Color::WHITE);
491        assert_eq!(circle.pixels(), square.pixels());
492
493        let mut ring = TestCanvas::new(48, 48);
494        ring.canvas()
495            .stroke_circle(Point::new(24, 24), 20, 4, Color::WHITE);
496        let mut band = TestCanvas::new(48, 48);
497        band.canvas()
498            .stroke_rounded_rect(Rect::new(4, 4, 40, 40), 20, 4, Color::WHITE);
499        assert_eq!(ring.pixels(), band.pixels());
500    }
501
502    /// A full sweep is the circle, bit for bit — the equality the issue asked
503    /// for, and what lets a progress ring pass `TURN` at 100% unspecial-cased.
504    #[test]
505    fn a_full_sweep_matches_the_circle_exactly() {
506        for start in [0, TURN / 8, -TURN / 3] {
507            let mut arc = TestCanvas::new(48, 48);
508            arc.canvas()
509                .stroke_arc(Point::new(24, 24), 20, 4, start, TURN, Color::WHITE);
510            let mut circle = TestCanvas::new(48, 48);
511            circle
512                .canvas()
513                .stroke_circle(Point::new(24, 24), 20, 4, Color::WHITE);
514            assert_eq!(arc.pixels(), circle.pixels(), "start {start}");
515        }
516        // And more than a full turn is still just the circle.
517        let mut over = TestCanvas::new(48, 48);
518        over.canvas()
519            .stroke_arc(Point::new(24, 24), 20, 4, 0, TURN * 3, Color::WHITE);
520        let mut circle = TestCanvas::new(48, 48);
521        circle
522            .canvas()
523            .stroke_circle(Point::new(24, 24), 20, 4, Color::WHITE);
524        assert_eq!(over.pixels(), circle.pixels());
525    }
526
527    /// A zero sweep draws nothing at all.
528    #[test]
529    fn a_zero_sweep_draws_nothing() {
530        let mut t = TestCanvas::new(48, 48);
531        t.canvas()
532            .stroke_arc(Point::new(24, 24), 20, 4, TURN / 8, 0, Color::WHITE);
533        assert!(t.pixels().iter().all(|&px| px == 0));
534    }
535
536    /// A quarter sweep from twelve o'clock lives in the top-right quadrant and
537    /// nowhere else — one pixel of slack along the cut edges, where the
538    /// anti-aliasing genuinely straddles the ray.
539    #[test]
540    fn a_quarter_arc_stays_in_its_quadrant() {
541        let (cx, cy) = (24, 24);
542        let mut t = TestCanvas::new(48, 48);
543        t.canvas()
544            .stroke_arc(Point::new(cx, cy), 20, 4, 0, TURN / 4, Color::WHITE);
545
546        let mut painted = 0;
547        for y in 0..48 {
548            for x in 0..48 {
549                if alpha_of(t.at(x, y)) > 0 {
550                    painted += 1;
551                    assert!(
552                        x >= cx - 1 && y <= cy,
553                        "quarter arc escaped its quadrant at {x},{y}"
554                    );
555                }
556            }
557        }
558        assert!(painted > 50, "only {painted} pixels for a quarter arc");
559        // The twelve o'clock cap is at the top, the three o'clock cap on the
560        // right: both ends of the sweep actually drew. Sampled one pixel up
561        // from the axis, because the centre of an even-diameter circle is a
562        // pixel *boundary* — the cap at three o'clock runs between rows.
563        assert!(alpha_of(t.at(cx, cy - 20 + 2)) > 0, "no paint at the start");
564        assert!(
565            alpha_of(t.at(cx + 20 - 2, cy - 1)) > 0,
566            "no paint at the end"
567        );
568    }
569
570    /// A sweep crossing the wrap point paints both sides of twelve o'clock.
571    #[test]
572    fn a_sweep_across_the_wrap_point_paints_both_sides_of_the_top() {
573        let (cx, cy) = (24, 24);
574        let mut t = TestCanvas::new(48, 48);
575        // From 315° round to 45°, crossing zero.
576        t.canvas().stroke_arc(
577            Point::new(cx, cy),
578            20,
579            4,
580            7 * TURN / 8,
581            TURN / 4,
582            Color::WHITE,
583        );
584        assert!(alpha_of(t.at(cx - 8, cy - 17)) > 0, "left of the top");
585        assert!(alpha_of(t.at(cx + 8, cy - 17)) > 0, "right of the top");
586        assert_eq!(alpha_of(t.at(cx, cy + 18)), 0, "nothing at the bottom");
587        assert_eq!(alpha_of(t.at(cx - 18, cy)), 0, "nothing at nine o'clock");
588        assert_eq!(alpha_of(t.at(cx + 18, cy)), 0, "nothing at three o'clock");
589    }
590
591    /// A negative sweep is the same arc as the positive one that ends where it
592    /// starts.
593    #[test]
594    fn a_negative_sweep_goes_the_other_way() {
595        let mut negative = TestCanvas::new(48, 48);
596        negative
597            .canvas()
598            .stroke_arc(Point::new(24, 24), 20, 4, 0, -TURN / 4, Color::WHITE);
599        let mut positive = TestCanvas::new(48, 48);
600        positive.canvas().stroke_arc(
601            Point::new(24, 24),
602            20,
603            4,
604            3 * TURN / 4,
605            TURN / 4,
606            Color::WHITE,
607        );
608        assert_eq!(negative.pixels(), positive.pixels());
609    }
610
611    /// Sweeps wider than half a turn go through the complement path; the two
612    /// paths have to agree about where an edge is. A three-quarter arc and the
613    /// quarter arc that completes it must tile the ring: everywhere the circle
614    /// is solid, the two together must account for it, and where the circle is
615    /// empty both must be empty.
616    #[test]
617    fn a_wide_arc_and_its_complement_tile_the_ring() {
618        let mut wide = TestCanvas::new(48, 48);
619        wide.canvas().stroke_arc(
620            Point::new(24, 24),
621            20,
622            4,
623            TURN / 4,
624            3 * TURN / 4,
625            Color::WHITE,
626        );
627        let mut narrow = TestCanvas::new(48, 48);
628        narrow
629            .canvas()
630            .stroke_arc(Point::new(24, 24), 20, 4, 0, TURN / 4, Color::WHITE);
631        let mut circle = TestCanvas::new(48, 48);
632        circle
633            .canvas()
634            .stroke_circle(Point::new(24, 24), 20, 4, Color::WHITE);
635
636        for y in 0..48 {
637            for x in 0..48 {
638                let whole = alpha_of(circle.at(x, y));
639                let sum = alpha_of(wide.at(x, y)) + alpha_of(narrow.at(x, y));
640                if whole == 0 {
641                    assert_eq!(sum, 0, "painted outside the ring at {x},{y}");
642                } else if whole == 255 {
643                    // Butt caps overlap by at most the anti-aliased edge, so the
644                    // sum can exceed a full pixel but never fall short of one.
645                    assert!(
646                        (255..=510).contains(&sum),
647                        "the two arcs left a hole at {x},{y}: {sum}"
648                    );
649                }
650            }
651        }
652    }
653
654    /// The interior of a ring is untouched, and a thickness of at least the
655    /// radius fills to the centre — the pie-slice case.
656    #[test]
657    fn thickness_decides_between_a_ring_and_a_pie() {
658        let mut ring = TestCanvas::new(48, 48);
659        ring.canvas()
660            .stroke_arc(Point::new(24, 24), 20, 4, 0, TURN / 2, Color::WHITE);
661        assert_eq!(alpha_of(ring.at(24, 24)), 0, "ring centre must be empty");
662        assert_eq!(alpha_of(ring.at(30, 24)), 0, "ring interior must be empty");
663
664        let mut pie = TestCanvas::new(48, 48);
665        pie.canvas()
666            .stroke_arc(Point::new(24, 24), 20, 99, 0, TURN / 2, Color::WHITE);
667        assert_eq!(alpha_of(pie.at(30, 24)), 255, "pie interior must be solid");
668        assert_eq!(alpha_of(pie.at(17, 24)), 0, "outside the pie's half");
669    }
670
671    /// Translucent paint composites once per pixel, however the spans and
672    /// clusters carve the row up.
673    #[test]
674    fn translucent_arcs_never_composite_a_pixel_twice() {
675        for sweep in [TURN / 4, TURN / 2, 3 * TURN / 4, TURN - TURN / 16] {
676            let mut t = TestCanvas::new(48, 48);
677            t.canvas().stroke_arc(
678                Point::new(24, 24),
679                20,
680                4,
681                TURN / 16,
682                sweep,
683                Color::rgba(255, 255, 255, 128),
684            );
685            let ceiling = 128;
686            for y in 0..48 {
687                for x in 0..48 {
688                    assert!(
689                        alpha_of(t.at(x, y)) <= ceiling,
690                        "double-composited at {x},{y} with sweep {sweep}"
691                    );
692                }
693            }
694        }
695    }
696
697    /// Clipping changes which pixels are written, never what is written.
698    #[test]
699    fn clipping_an_arc_matches_the_unclipped_result() {
700        let region = Rect::new(10, 6, 20, 22);
701        let mut full = TestCanvas::new(48, 48);
702        full.canvas()
703            .stroke_arc(Point::new(24, 24), 18, 5, 0, 3 * TURN / 4, Color::WHITE);
704        let mut clipped = TestCanvas::new(48, 48);
705        {
706            let mut c = clipped.canvas();
707            c.clip_to(region);
708            c.stroke_arc(Point::new(24, 24), 18, 5, 0, 3 * TURN / 4, Color::WHITE);
709        }
710        for y in 0..48 {
711            for x in 0..48 {
712                let expected = if region.contains(Point::new(x, y)) {
713                    full.at(x, y)
714                } else {
715                    0
716                };
717                assert_eq!(clipped.at(x, y), expected, "at {x},{y}");
718            }
719        }
720    }
721
722    /// Degenerate and absurd inputs draw nothing or something, but never panic —
723    /// a panic inside a paint loop on a kiosk is a black screen.
724    #[test]
725    fn degenerate_arcs_do_not_panic() {
726        let mut t = TestCanvas::new(16, 16);
727        let mut c = t.canvas();
728        c.stroke_arc(Point::new(8, 8), 0, 4, 0, TURN, Color::WHITE);
729        c.stroke_arc(Point::new(8, 8), 6, 0, 0, TURN, Color::WHITE);
730        c.stroke_arc(Point::new(8, 8), -5, 3, 0, TURN, Color::WHITE);
731        c.stroke_arc(Point::new(8, 8), 6, -2, 0, TURN, Color::WHITE);
732        c.stroke_arc(Point::new(8, 8), 6, 3, i32::MIN, i32::MIN, Color::WHITE);
733        c.stroke_arc(Point::new(8, 8), 6, 3, i32::MAX, i32::MAX, Color::WHITE);
734        c.stroke_arc(
735            Point::new(i32::MIN, i32::MAX),
736            i32::MAX,
737            i32::MAX,
738            1,
739            1,
740            Color::WHITE,
741        );
742        c.fill_circle(Point::new(8, 8), 0, Color::WHITE);
743        c.fill_circle(Point::new(-1000, 8), i32::MAX, Color::WHITE);
744        c.stroke_circle(Point::new(8, 8), 6, i32::MAX, Color::WHITE);
745    }
746
747    /// The rasteriser against an independent oracle: 16×16 supersampled
748    /// point-in-annulus ∧ point-in-sector membership, in the same integer
749    /// arithmetic but sharing none of the scanline code. Interior and exterior
750    /// pixels must agree exactly; edge pixels within the difference between
751    /// 4-sub-row coverage and true area.
752    #[test]
753    fn coverage_agrees_with_a_supersampled_oracle() {
754        let (cx, cy) = (24, 24);
755        let (r, t) = (18, 5);
756        for (start, sweep) in [
757            (0, TURN / 4),
758            (TURN / 8, TURN / 2),
759            (7 * TURN / 8, TURN / 4),
760            (TURN / 4, 3 * TURN / 4),
761            (0, TURN / 2),
762        ] {
763            let mut canvas = TestCanvas::new(48, 48);
764            canvas
765                .canvas()
766                .stroke_arc(Point::new(cx, cy), r, t, start, sweep, Color::WHITE);
767
768            let s_dir = direction(start);
769            let e_dir = direction(start + sweep);
770            let wide = sweep > TURN / 2;
771
772            for py in 0..48 {
773                for px in 0..48 {
774                    let mut hits = 0u32;
775                    for sy in 0..16 {
776                        for sx in 0..16 {
777                            // Sample point in 1/32ths of a pixel, relative to
778                            // the centre.
779                            let dx = (px - cx) * 32 + sx * 2 + 1;
780                            let dy = (py - cy) * 32 + sy * 2 + 1;
781                            let d2 = (dx as i64) * (dx as i64) + (dy as i64) * (dy as i64);
782                            let outer = (r as i64 * 32).pow(2);
783                            let inner = ((r - t) as i64 * 32).pow(2);
784                            if d2 > outer || d2 <= inner {
785                                continue;
786                            }
787                            let cross_s = s_dir.0 as i64 * dy as i64 - s_dir.1 as i64 * dx as i64;
788                            let cross_e = e_dir.0 as i64 * dy as i64 - e_dir.1 as i64 * dx as i64;
789                            let in_sector = if wide {
790                                // Complement of the narrow sector from end to
791                                // start.
792                                !(cross_e >= 0 && cross_s <= 0)
793                            } else {
794                                cross_s >= 0 && cross_e <= 0
795                            };
796                            if in_sector {
797                                hits += 1;
798                            }
799                        }
800                    }
801                    let expected = (hits * 255 + 128) / 256;
802                    let actual = alpha_of(canvas.at(px, py));
803                    let error = expected.abs_diff(actual);
804                    assert!(
805                        error <= 72,
806                        "start {start} sweep {sweep} at {px},{py}: \
807                         oracle {expected}, rasteriser {actual}"
808                    );
809                    if expected == 0 {
810                        assert!(
811                            actual <= 16,
812                            "start {start} sweep {sweep}: painted well outside \
813                             the arc at {px},{py}: {actual}"
814                        );
815                    }
816                    if expected == 255 {
817                        assert!(
818                            actual >= 240,
819                            "start {start} sweep {sweep}: hole inside the arc \
820                             at {px},{py}: {actual}"
821                        );
822                    }
823                }
824            }
825        }
826    }
827}