Skip to main content

cranpose_ui_graphics/
stroke.rs

1//! Stroke styling and analytic arc geometry.
2//!
3//! # Angle convention
4//!
5//! Every angle in this module is expressed in **radians**, with `0` pointing
6//! along the **+X axis** and increasing angles sweeping **clockwise on
7//! screen**. Cranpose uses y-down device coordinates, so a point on the arc of
8//! radius `r` at angle `θ` is
9//!
10//! ```text
11//! (center.x + r * cos(θ), center.y + r * sin(θ))
12//! ```
13//!
14//! which — because `y` grows downwards — visually rotates clockwise as `θ`
15//! grows. This is exactly the convention already baked into the sweep-gradient
16//! branch of `shape.wgsl`, which derives its parameter from `atan2(dy, dx)`.
17
18use crate::{Point, Rect};
19
20/// Shape of the two ends of an open stroked path (an arc, today).
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum StrokeCap {
23    /// Flat end exactly at the geometric end of the path.
24    #[default]
25    Butt,
26    /// Semicircular end bulging half the stroke width past the path end.
27    Round,
28    /// Flat end projected half the stroke width past the path end.
29    Square,
30}
31
32/// Shape produced where two stroked segments meet at a corner.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
34pub enum StrokeJoin {
35    /// Extend the outer edges until they meet in a sharp point.
36    #[default]
37    Miter,
38    /// Fill the corner with a circular arc of half the stroke width.
39    Round,
40    /// Cut the corner off with a straight chamfer.
41    Bevel,
42}
43
44/// Describes how an outline is stroked.
45///
46/// The stroke is *centered* on the geometry: it extends `width / 2` to either
47/// side of the path, matching Skia / Jetpack Compose semantics.
48#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct Stroke {
50    /// Total stroke width in the caller's coordinate space (dp for
51    /// [`crate::DrawScope`] callers).
52    pub width: f32,
53    pub cap: StrokeCap,
54    pub join: StrokeJoin,
55}
56
57impl Stroke {
58    /// A `width`-wide stroke with butt caps and miter joins.
59    pub const fn new(width: f32) -> Self {
60        Self {
61            width,
62            cap: StrokeCap::Butt,
63            join: StrokeJoin::Miter,
64        }
65    }
66
67    pub const fn with_width(mut self, width: f32) -> Self {
68        self.width = width;
69        self
70    }
71
72    pub const fn with_cap(mut self, cap: StrokeCap) -> Self {
73        self.cap = cap;
74        self
75    }
76
77    pub const fn with_join(mut self, join: StrokeJoin) -> Self {
78        self.join = join;
79        self
80    }
81
82    /// Half the stroke width, clamped to a finite non-negative value.
83    ///
84    /// This is the amount the stroke bleeds outside (and inside) the geometry.
85    pub fn half_width(&self) -> f32 {
86        if self.width.is_finite() {
87            (self.width * 0.5).max(0.0)
88        } else {
89            0.0
90        }
91    }
92
93    /// A stroke is renderable only when it has a strictly positive, finite width.
94    pub fn is_visible(&self) -> bool {
95        self.width.is_finite() && self.width > 0.0
96    }
97
98    /// Scales the stroke width (used when a layer transform scales the shape).
99    pub fn scaled(&self, scale: f32) -> Self {
100        Self {
101            width: self.width * scale,
102            ..*self
103        }
104    }
105}
106
107impl Default for Stroke {
108    fn default() -> Self {
109        Self::new(1.0)
110    }
111}
112
113/// Full turn in radians.
114pub const TAU: f32 = std::f32::consts::PI * 2.0;
115
116/// A resolved circular *band* between two radii, limited to an angular sweep.
117///
118/// Both a stroked arc and a filled annular sector lower to this single form:
119///
120/// * stroked arc — `inner = radius - width/2`, `outer = radius + width/2`,
121///   ends shaped by the stroke's [`StrokeCap`];
122/// * filled annular sector — `inner`/`outer` as given, always butt (flat
123///   radial) ends.
124///
125/// Values are normalized on construction: `sweep_angle` is non-negative and at
126/// most [`TAU`], `outer_radius >= inner_radius >= 0`, and non-finite inputs
127/// collapse to a degenerate geometry (see [`ArcGeometry::is_degenerate`]).
128#[derive(Clone, Copy, Debug, PartialEq)]
129pub struct ArcGeometry {
130    pub center: Point,
131    pub inner_radius: f32,
132    pub outer_radius: f32,
133    /// Normalized to `[0, TAU)`.
134    pub start_angle: f32,
135    /// Normalized to `[0, TAU]`.
136    pub sweep_angle: f32,
137    pub cap: StrokeCap,
138}
139
140/// Exact `x.floor()` without the libm call `f32::floor` lowers to on armv7
141/// (no `vrintm` there): truncate via int cast, fix up negatives. Bit-equal
142/// to `floorf` for every input — casts only run below 2^23, where i32 cannot
143/// saturate, and at 2^23 and above every finite f32 is already an integer.
144/// NaN fails the range test and passes through unchanged, like `floorf`.
145#[inline]
146fn exact_floor(x: f32) -> f32 {
147    if x == 0.0 {
148        return x;
149    }
150    if x.abs() < 8_388_608.0 {
151        let truncated = x as i32 as f32;
152        truncated - ((x < truncated) as i32 as f32)
153    } else {
154        x
155    }
156}
157
158/// `x mod TAU` into `[0, TAU)` without `rem_euclid`, whose `fmodf` lowers to
159/// the software routine in compiler_builtins on aarch64 Android and shows up
160/// in profiles at two calls per arc per frame. Multiply-floor keeps it to a
161/// couple of instructions; the fixup folds the one-ulp overshoot cases back
162/// into range.
163#[inline]
164fn wrap_angle_tau(x: f32) -> f32 {
165    if (0.0..TAU).contains(&x) {
166        return x;
167    }
168    let wrapped = x - exact_floor(x * (1.0 / TAU)) * TAU;
169    if wrapped >= TAU {
170        wrapped - TAU
171    } else if wrapped < 0.0 {
172        0.0
173    } else {
174        wrapped
175    }
176}
177
178/// `(sin, cos)` by refined parabola, absolute error under [`FAST_TRIG_ERR`].
179/// Bounding boxes only need trig that is close — the box gets padded by the
180/// worst-case position error afterwards — and libm's `sincosf`, called twice
181/// per partial arc, was one of the larger single costs of recording a
182/// shape-heavy frame on a watch-class core.
183#[inline]
184fn fast_sin_cos(angle: f32) -> (f32, f32) {
185    use std::f32::consts::{FRAC_PI_2, PI};
186    #[inline]
187    fn fold_sin(x: f32) -> f32 {
188        const B: f32 = 4.0 / PI;
189        const C: f32 = -4.0 / (PI * PI);
190        let y = B * x + C * x * x.abs();
191        0.225 * (y * y.abs() - y) + y
192    }
193    let x = wrap_angle_tau(angle);
194    let x = if x > PI { x - TAU } else { x };
195    let mut c = x + FRAC_PI_2;
196    if c > PI {
197        c -= TAU;
198    }
199    (fold_sin(x), fold_sin(c))
200}
201
202/// Worst-case absolute error of [`fast_sin_cos`]; bounds derived from it are
203/// padded by radius x this so the approximate box always contains the exact
204/// shape.
205const FAST_TRIG_ERR: f32 = 1.3e-3;
206
207impl ArcGeometry {
208    /// Normalizing constructor. Never panics and never stores a NaN.
209    #[inline]
210    pub fn new(
211        center: Point,
212        inner_radius: f32,
213        outer_radius: f32,
214        start_angle: f32,
215        sweep_angle: f32,
216        cap: StrokeCap,
217    ) -> Self {
218        let finite = center.x.is_finite()
219            && center.y.is_finite()
220            && inner_radius.is_finite()
221            && outer_radius.is_finite()
222            && start_angle.is_finite()
223            && sweep_angle.is_finite();
224        if !finite {
225            return Self::DEGENERATE;
226        }
227
228        let outer = outer_radius.max(0.0);
229        let inner = inner_radius.clamp(0.0, outer);
230
231        let (mut start, mut sweep) = if sweep_angle < 0.0 {
232            (start_angle + sweep_angle, -sweep_angle)
233        } else {
234            (start_angle, sweep_angle)
235        };
236        if sweep >= TAU {
237            sweep = TAU;
238            start = 0.0;
239        }
240        start = wrap_angle_tau(start);
241        if !start.is_finite() {
242            start = 0.0;
243        }
244        let cap = if sweep >= TAU { StrokeCap::Round } else { cap };
245
246        Self {
247            center,
248            inner_radius: inner,
249            outer_radius: outer,
250            start_angle: start,
251            sweep_angle: sweep,
252            cap,
253        }
254    }
255
256    const DEGENERATE: Self = Self {
257        center: Point::ZERO,
258        inner_radius: 0.0,
259        outer_radius: 0.0,
260        start_angle: 0.0,
261        sweep_angle: 0.0,
262        cap: StrokeCap::Butt,
263    };
264
265    /// Radius of the band's centerline (`ra` in the analytic arc SDF).
266    pub fn mid_radius(&self) -> f32 {
267        (self.inner_radius + self.outer_radius) * 0.5
268    }
269
270    /// Half the band thickness (`rb` in the analytic arc SDF). Also the radius
271    /// of a round cap and the projection distance of a square cap.
272    pub fn half_thickness(&self) -> f32 {
273        (self.outer_radius - self.inner_radius) * 0.5
274    }
275
276    /// True when the band encloses no area and therefore must not be emitted.
277    pub fn is_degenerate(&self) -> bool {
278        !(self.outer_radius > 0.0
279            && self.outer_radius > self.inner_radius
280            && self.sweep_angle > 0.0)
281    }
282
283    /// True when `angle` lies inside `[start, start + sweep]` (mod `TAU`).
284    pub fn contains_angle(&self, angle: f32) -> bool {
285        if self.sweep_angle >= TAU {
286            return true;
287        }
288        let delta = wrap_angle_tau(angle - self.start_angle);
289        delta <= self.sweep_angle + 1e-6
290    }
291
292    /// Scales radii and translates the center. Angles are unchanged, so this is
293    /// only valid for a uniform (non-mirroring) scale.
294    pub fn scaled_about(&self, center: Point, scale: f32) -> Self {
295        Self {
296            center,
297            inner_radius: self.inner_radius * scale,
298            outer_radius: self.outer_radius * scale,
299            ..*self
300        }
301    }
302
303    /// Tight axis-aligned bounding box of the rendered band, caps included.
304    ///
305    /// The box is the union of
306    /// * the two radial ends (inner and outer radius, extended for
307    ///   round/square caps), and
308    /// * the outer-radius point at every axis direction (0, 90, 180, 270
309    ///   degrees) that the sweep actually crosses.
310    ///
311    /// Sampling only the endpoints would be wrong for any sweep that crosses an
312    /// axis: a 0..270 degree sweep reaches `center.x + outer` *and*
313    /// `center.x - outer` even though neither endpoint does.
314    pub fn bounds(&self) -> Rect {
315        if self.is_degenerate() {
316            return Rect {
317                x: self.center.x,
318                y: self.center.y,
319                width: 0.0,
320                height: 0.0,
321            };
322        }
323
324        if self.sweep_angle >= TAU && self.cap != StrokeCap::Square {
325            let r = self.outer_radius;
326            return Rect {
327                x: self.center.x - r,
328                y: self.center.y - r,
329                width: r + r,
330                height: r + r,
331            };
332        }
333
334        let mut min_x = f32::INFINITY;
335        let mut min_y = f32::INFINITY;
336        let mut max_x = f32::NEG_INFINITY;
337        let mut max_y = f32::NEG_INFINITY;
338        let mut include = |x: f32, y: f32| {
339            min_x = min_x.min(x);
340            min_y = min_y.min(y);
341            max_x = max_x.max(x);
342            max_y = max_y.max(y);
343        };
344
345        let rb = self.half_thickness();
346        let ra = self.mid_radius();
347        let end_angle = self.start_angle + self.sweep_angle;
348
349        for (angle, outward) in [(self.start_angle, -1.0f32), (end_angle, 1.0f32)] {
350            let (sin, cos) = fast_sin_cos(angle);
351            match self.cap {
352                StrokeCap::Butt => {
353                    include(
354                        self.center.x + cos * self.inner_radius,
355                        self.center.y + sin * self.inner_radius,
356                    );
357                    include(
358                        self.center.x + cos * self.outer_radius,
359                        self.center.y + sin * self.outer_radius,
360                    );
361                }
362                StrokeCap::Square => {
363                    let tx = -sin * rb * outward;
364                    let ty = cos * rb * outward;
365                    include(
366                        self.center.x + cos * self.inner_radius + tx,
367                        self.center.y + sin * self.inner_radius + ty,
368                    );
369                    include(
370                        self.center.x + cos * self.outer_radius + tx,
371                        self.center.y + sin * self.outer_radius + ty,
372                    );
373                }
374                StrokeCap::Round => {
375                    let cx = self.center.x + cos * ra;
376                    let cy = self.center.y + sin * ra;
377                    include(cx - rb, cy - rb);
378                    include(cx + rb, cy + rb);
379                }
380            }
381        }
382
383        const AXIS_DIRECTIONS: [(f32, f32); 4] = [(0.0, 1.0), (1.0, 0.0), (0.0, -1.0), (-1.0, 0.0)];
384        for (quadrant, (sin, cos)) in AXIS_DIRECTIONS.into_iter().enumerate() {
385            let angle = quadrant as f32 * std::f32::consts::FRAC_PI_2;
386            if self.contains_angle(angle) {
387                include(
388                    self.center.x + cos * self.outer_radius,
389                    self.center.y + sin * self.outer_radius,
390                );
391            }
392        }
393
394        let pad = (self.outer_radius + rb) * FAST_TRIG_ERR + 0.02;
395        Rect {
396            x: min_x - pad,
397            y: min_y - pad,
398            width: (max_x - min_x + pad + pad).max(0.0),
399            height: (max_y - min_y + pad + pad).max(0.0),
400        }
401    }
402}
403
404/// Resolves the `(inner, outer, cap)` band described by a
405/// [`crate::DrawPrimitive::Arc`].
406///
407/// * `stroke = Some(_)` — a stroked arc centered on `radius`.
408/// * `stroke = None` — a filled annular sector from `inner_radius` to `radius`
409///   with flat (butt) radial ends. `inner_radius <= 0` yields a filled pie
410///   wedge.
411///
412/// Non-finite input collapses to an empty band so the caller drops the draw
413/// instead of pushing NaN down the pipeline.
414#[inline]
415pub fn arc_band(radius: f32, inner_radius: f32, stroke: Option<Stroke>) -> (f32, f32, StrokeCap) {
416    match stroke {
417        Some(stroke) => {
418            if !radius.is_finite() || !stroke.is_visible() {
419                return (0.0, 0.0, stroke.cap);
420            }
421            let half = stroke.half_width();
422            let radius = radius.max(0.0);
423            ((radius - half).max(0.0), radius + half, stroke.cap)
424        }
425        None => {
426            if !radius.is_finite() || !inner_radius.is_finite() {
427                return (0.0, 0.0, StrokeCap::Butt);
428            }
429            let outer = radius.max(0.0);
430            let inner = inner_radius.clamp(0.0, outer);
431            (inner, outer, StrokeCap::Butt)
432        }
433    }
434}
435
436/// Grows `rect` by `amount` on every side, clamping to a non-negative size.
437pub fn inflate_rect(rect: Rect, amount: f32) -> Rect {
438    if !amount.is_finite() || amount <= 0.0 {
439        return rect;
440    }
441    Rect {
442        x: rect.x - amount,
443        y: rect.y - amount,
444        width: (rect.width + amount * 2.0).max(0.0),
445        height: (rect.height + amount * 2.0).max(0.0),
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use std::f32::consts::{FRAC_PI_2, PI};
452
453    use super::*;
454
455    fn approx(a: f32, b: f32) -> bool {
456        (a - b).abs() < 0.15
457    }
458
459    #[test]
460    fn scaling_an_arc_moves_its_centre_and_its_radii_and_nothing_else() {
461        let arc = ArcGeometry::new(
462            Point { x: 10.0, y: 20.0 },
463            4.0,
464            10.0,
465            FRAC_PI_2,
466            PI,
467            StrokeCap::Round,
468        );
469        let moved = arc.scaled_about(Point { x: 100.0, y: 200.0 }, 2.5);
470
471        assert_eq!(moved.center, Point { x: 100.0, y: 200.0 });
472        assert_eq!(moved.inner_radius, 10.0);
473        assert_eq!(moved.outer_radius, 25.0);
474        assert_eq!(moved.start_angle, arc.start_angle);
475        assert_eq!(moved.sweep_angle, arc.sweep_angle);
476        assert_eq!(moved.cap, arc.cap);
477
478        let same = arc.scaled_about(arc.center, 1.0);
479        assert_eq!(same, arc);
480    }
481
482    #[test]
483    fn exact_floor_is_bit_equal_to_floorf() {
484        let mut probes: Vec<f32> = vec![
485            0.0,
486            -0.0,
487            0.5,
488            -0.5,
489            1.0,
490            -1.0,
491            8_388_607.5,
492            -8_388_607.5,
493            8_388_608.0,
494            -8_388_608.0,
495            1.0e30,
496            -1.0e30,
497            f32::INFINITY,
498            f32::NEG_INFINITY,
499            f32::MIN_POSITIVE,
500            -f32::MIN_POSITIVE,
501        ];
502        for i in -4000..4000 {
503            probes.push(i as f32 * 0.01737);
504            probes.push(i as f32 * PI);
505        }
506        for x in probes {
507            assert_eq!(
508                exact_floor(x).to_bits(),
509                x.floor().to_bits(),
510                "exact_floor({x}) diverged from floorf"
511            );
512        }
513        assert!(exact_floor(f32::NAN).is_nan());
514    }
515
516    #[test]
517    fn stroke_builders_compose() {
518        let stroke = Stroke::new(4.0)
519            .with_cap(StrokeCap::Round)
520            .with_join(StrokeJoin::Bevel);
521        assert_eq!(stroke.width, 4.0);
522        assert_eq!(stroke.cap, StrokeCap::Round);
523        assert_eq!(stroke.join, StrokeJoin::Bevel);
524        assert_eq!(stroke.half_width(), 2.0);
525        assert!(stroke.is_visible());
526        assert_eq!(Stroke::default(), Stroke::new(1.0));
527        assert_eq!(Stroke::new(4.0).with_width(6.0).width, 6.0);
528    }
529
530    #[test]
531    fn stroke_rejects_non_positive_and_non_finite_widths() {
532        assert!(!Stroke::new(0.0).is_visible());
533        assert!(!Stroke::new(-3.0).is_visible());
534        assert!(!Stroke::new(f32::NAN).is_visible());
535        assert!(!Stroke::new(f32::INFINITY).is_visible());
536        assert_eq!(Stroke::new(f32::NAN).half_width(), 0.0);
537        assert_eq!(Stroke::new(-3.0).half_width(), 0.0);
538    }
539
540    #[test]
541    fn arc_geometry_normalizes_negative_sweeps() {
542        let arc = ArcGeometry::new(Point::ZERO, 1.0, 2.0, PI, -FRAC_PI_2, StrokeCap::Butt);
543        assert!(approx(arc.start_angle, PI - FRAC_PI_2));
544        assert!(approx(arc.sweep_angle, FRAC_PI_2));
545    }
546
547    #[test]
548    fn arc_geometry_clamps_full_turns_and_forces_round_caps() {
549        let arc = ArcGeometry::new(Point::ZERO, 1.0, 2.0, 0.3, TAU * 3.0, StrokeCap::Butt);
550        assert_eq!(arc.sweep_angle, TAU);
551        assert_eq!(
552            arc.cap,
553            StrokeCap::Round,
554            "a closed ring must not clip its (invisible) caps"
555        );
556        assert!(arc.contains_angle(0.0));
557        assert!(arc.contains_angle(PI));
558    }
559
560    #[test]
561    fn arc_geometry_sanitizes_non_finite_input() {
562        for arc in [
563            ArcGeometry::new(
564                Point::new(f32::NAN, 0.0),
565                1.0,
566                2.0,
567                0.0,
568                1.0,
569                StrokeCap::Butt,
570            ),
571            ArcGeometry::new(Point::ZERO, f32::NAN, 2.0, 0.0, 1.0, StrokeCap::Butt),
572            ArcGeometry::new(Point::ZERO, 1.0, f32::INFINITY, 0.0, 1.0, StrokeCap::Butt),
573            ArcGeometry::new(Point::ZERO, 1.0, 2.0, f32::NAN, 1.0, StrokeCap::Butt),
574            ArcGeometry::new(Point::ZERO, 1.0, 2.0, 0.0, f32::NAN, StrokeCap::Butt),
575        ] {
576            assert!(arc.is_degenerate());
577            let bounds = arc.bounds();
578            for value in [bounds.x, bounds.y, bounds.width, bounds.height] {
579                assert!(value.is_finite(), "degenerate arc bounds must stay finite");
580            }
581        }
582    }
583
584    #[test]
585    fn approximate_bounds_contain_the_exact_box_within_documented_slack() {
586        for radius in [2.0f32, 10.0, 57.0, 204.0] {
587            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
588                for step in 0..48 {
589                    let start = step as f32 * (TAU / 48.0) * 1.031;
590                    for sweep in [0.05f32, 0.9, FRAC_PI_2, 3.6] {
591                        let arc = ArcGeometry::new(
592                            Point::new(11.0, -7.0),
593                            radius * 0.55,
594                            radius,
595                            start,
596                            sweep,
597                            cap,
598                        );
599                        if arc.is_degenerate() {
600                            continue;
601                        }
602                        let bounds = arc.bounds();
603                        let exact = exact_bounds(&arc);
604                        let slack =
605                            (arc.outer_radius + arc.half_thickness()) * FAST_TRIG_ERR * 2.0 + 0.05;
606                        assert!(
607                            bounds.x <= exact.x + 1e-3
608                                && bounds.y <= exact.y + 1e-3
609                                && bounds.x + bounds.width >= exact.x + exact.width - 1e-3
610                                && bounds.y + bounds.height >= exact.y + exact.height - 1e-3,
611                            "approximate box lost containment: {bounds:?} vs exact {exact:?} \
612                             (radius {radius}, start {start}, sweep {sweep}, cap {cap:?})"
613                        );
614                        assert!(
615                            (bounds.x - exact.x).abs() <= slack
616                                && (bounds.y - exact.y).abs() <= slack
617                                && (bounds.width - exact.width).abs() <= 2.0 * slack
618                                && (bounds.height - exact.height).abs() <= 2.0 * slack,
619                            "approximate box drifted past its slack: {bounds:?} vs exact \
620                             {exact:?} slack {slack} (radius {radius}, start {start}, sweep \
621                             {sweep}, cap {cap:?})"
622                        );
623                    }
624                }
625            }
626        }
627    }
628
629    fn exact_bounds(arc: &ArcGeometry) -> Rect {
630        let mut min_x = f32::INFINITY;
631        let mut min_y = f32::INFINITY;
632        let mut max_x = f32::NEG_INFINITY;
633        let mut max_y = f32::NEG_INFINITY;
634        let mut include = |x: f32, y: f32| {
635            min_x = min_x.min(x);
636            min_y = min_y.min(y);
637            max_x = max_x.max(x);
638            max_y = max_y.max(y);
639        };
640        let rb = arc.half_thickness();
641        let ra = arc.mid_radius();
642        let end_angle = arc.start_angle + arc.sweep_angle;
643        for (angle, outward) in [(arc.start_angle, -1.0f32), (end_angle, 1.0f32)] {
644            let (sin, cos) = angle.sin_cos();
645            match arc.cap {
646                StrokeCap::Butt => {
647                    include(
648                        arc.center.x + cos * arc.inner_radius,
649                        arc.center.y + sin * arc.inner_radius,
650                    );
651                    include(
652                        arc.center.x + cos * arc.outer_radius,
653                        arc.center.y + sin * arc.outer_radius,
654                    );
655                }
656                StrokeCap::Square => {
657                    let tx = -sin * rb * outward;
658                    let ty = cos * rb * outward;
659                    include(
660                        arc.center.x + cos * arc.inner_radius + tx,
661                        arc.center.y + sin * arc.inner_radius + ty,
662                    );
663                    include(
664                        arc.center.x + cos * arc.outer_radius + tx,
665                        arc.center.y + sin * arc.outer_radius + ty,
666                    );
667                }
668                StrokeCap::Round => {
669                    let cx = arc.center.x + cos * ra;
670                    let cy = arc.center.y + sin * ra;
671                    include(cx - rb, cy - rb);
672                    include(cx + rb, cy + rb);
673                }
674            }
675        }
676        const AXIS_DIRECTIONS: [(f32, f32); 4] = [(0.0, 1.0), (1.0, 0.0), (0.0, -1.0), (-1.0, 0.0)];
677        for (quadrant, (sin, cos)) in AXIS_DIRECTIONS.into_iter().enumerate() {
678            let angle = quadrant as f32 * FRAC_PI_2;
679            if arc.contains_angle(angle) {
680                include(
681                    arc.center.x + cos * arc.outer_radius,
682                    arc.center.y + sin * arc.outer_radius,
683                );
684            }
685        }
686        Rect {
687            x: min_x,
688            y: min_y,
689            width: (max_x - min_x).max(0.0),
690            height: (max_y - min_y).max(0.0),
691        }
692    }
693
694    #[test]
695    fn arc_geometry_flags_degenerate_bands() {
696        assert!(ArcGeometry::new(Point::ZERO, 5.0, 5.0, 0.0, 1.0, StrokeCap::Butt).is_degenerate());
697        assert!(ArcGeometry::new(Point::ZERO, 9.0, 5.0, 0.0, 1.0, StrokeCap::Butt).is_degenerate());
698        assert!(ArcGeometry::new(Point::ZERO, 1.0, 5.0, 0.0, 0.0, StrokeCap::Butt).is_degenerate());
699        assert!(ArcGeometry::new(Point::ZERO, 0.0, 0.0, 0.0, 1.0, StrokeCap::Butt).is_degenerate());
700    }
701
702    #[test]
703    fn arc_bounds_quarter_sweep_hugs_the_quadrant() {
704        let arc = ArcGeometry::new(
705            Point::new(100.0, 100.0),
706            0.0,
707            10.0,
708            0.0,
709            FRAC_PI_2,
710            StrokeCap::Butt,
711        );
712        let bounds = arc.bounds();
713        assert!(approx(bounds.x, 100.0), "{bounds:?}");
714        assert!(approx(bounds.y, 100.0), "{bounds:?}");
715        assert!(approx(bounds.width, 10.0), "{bounds:?}");
716        assert!(approx(bounds.height, 10.0), "{bounds:?}");
717    }
718
719    #[test]
720    fn arc_bounds_three_quarter_sweep_spans_every_axis_it_crosses() {
721        let arc = ArcGeometry::new(
722            Point::new(0.0, 0.0),
723            0.0,
724            10.0,
725            0.0,
726            3.0 * FRAC_PI_2,
727            StrokeCap::Butt,
728        );
729        let bounds = arc.bounds();
730        assert!(approx(bounds.x, -10.0), "{bounds:?}");
731        assert!(approx(bounds.y, -10.0), "{bounds:?}");
732        assert!(approx(bounds.width, 20.0), "{bounds:?}");
733        assert!(approx(bounds.height, 20.0), "{bounds:?}");
734    }
735
736    #[test]
737    fn arc_bounds_include_inner_endpoints_when_no_axis_is_crossed() {
738        let arc = ArcGeometry::new(
739            Point::ZERO,
740            8.0,
741            10.0,
742            std::f32::consts::FRAC_PI_4,
743            FRAC_PI_2,
744            StrokeCap::Butt,
745        );
746        let bounds = arc.bounds();
747        let sqrt2_2 = std::f32::consts::FRAC_1_SQRT_2;
748        assert!(approx(bounds.y, 8.0 * sqrt2_2), "{bounds:?}");
749        assert!(approx(bounds.y + bounds.height, 10.0), "{bounds:?}");
750        assert!(approx(bounds.x, -10.0 * sqrt2_2), "{bounds:?}");
751        assert!(approx(bounds.width, 20.0 * sqrt2_2), "{bounds:?}");
752    }
753
754    #[test]
755    fn arc_bounds_negative_sweep_matches_equivalent_positive_sweep() {
756        let forward = ArcGeometry::new(Point::ZERO, 4.0, 6.0, 0.0, FRAC_PI_2, StrokeCap::Butt);
757        let backward = ArcGeometry::new(
758            Point::ZERO,
759            4.0,
760            6.0,
761            FRAC_PI_2,
762            -FRAC_PI_2,
763            StrokeCap::Butt,
764        );
765        assert_eq!(forward.bounds(), backward.bounds());
766    }
767
768    #[test]
769    fn arc_bounds_full_turn_is_the_outer_circle() {
770        let arc = ArcGeometry::new(Point::new(5.0, 7.0), 3.0, 9.0, 1.1, TAU, StrokeCap::Butt);
771        let bounds = arc.bounds();
772        assert!(approx(bounds.x, -4.0), "{bounds:?}");
773        assert!(approx(bounds.y, -2.0), "{bounds:?}");
774        assert!(approx(bounds.width, 18.0), "{bounds:?}");
775        assert!(approx(bounds.height, 18.0), "{bounds:?}");
776    }
777
778    #[test]
779    fn arc_bounds_round_caps_bulge_past_the_radial_ends() {
780        let butt = ArcGeometry::new(Point::ZERO, 8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Butt);
781        let round = ArcGeometry::new(Point::ZERO, 8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Round);
782        let butt_bounds = butt.bounds();
783        let round_bounds = round.bounds();
784        assert!(approx(butt_bounds.y, 0.0), "{butt_bounds:?}");
785        assert!(approx(round_bounds.y, -2.0), "{round_bounds:?}");
786        assert!(round_bounds.width >= butt_bounds.width);
787        assert!(round_bounds.height >= butt_bounds.height);
788    }
789
790    #[test]
791    fn arc_bounds_square_caps_project_along_the_tangent() {
792        let square = ArcGeometry::new(Point::ZERO, 8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Square);
793        let bounds = square.bounds();
794        assert!(approx(bounds.y, -2.0), "{bounds:?}");
795        assert!(approx(bounds.x + bounds.width, 12.0), "{bounds:?}");
796    }
797
798    #[test]
799    fn arc_band_resolves_stroked_and_filled_forms() {
800        let (inner, outer, cap) =
801            arc_band(10.0, 0.0, Some(Stroke::new(4.0).with_cap(StrokeCap::Round)));
802        assert_eq!((inner, outer), (8.0, 12.0));
803        assert_eq!(cap, StrokeCap::Round);
804
805        let (inner, outer, cap) = arc_band(10.0, 6.0, None);
806        assert_eq!((inner, outer), (6.0, 10.0));
807        assert_eq!(cap, StrokeCap::Butt);
808
809        let (inner, outer, _) = arc_band(10.0, 40.0, None);
810        assert_eq!((inner, outer), (10.0, 10.0));
811
812        let (inner, outer, _) = arc_band(1.0, 0.0, Some(Stroke::new(10.0)));
813        assert_eq!((inner, outer), (0.0, 6.0));
814    }
815
816    #[test]
817    fn full_ring_bounds_shortcut_matches_the_endpoint_walk() {
818        let ring = ArcGeometry::new(Point::new(10.0, -4.0), 6.0, 9.0, 1.3, TAU, StrokeCap::Butt);
819        assert_eq!(
820            ring.bounds(),
821            Rect {
822                x: 1.0,
823                y: -13.0,
824                width: 18.0,
825                height: 18.0
826            }
827        );
828
829        let square = ArcGeometry {
830            cap: StrokeCap::Square,
831            start_angle: TAU - (1.5f32 / 9.0).atan(),
832            ..ring
833        };
834        let bounds = square.bounds();
835        assert!(bounds.x + bounds.width > square.center.x + square.outer_radius);
836    }
837
838    #[test]
839    fn inflate_rect_ignores_non_positive_amounts() {
840        let rect = Rect {
841            x: 1.0,
842            y: 2.0,
843            width: 3.0,
844            height: 4.0,
845        };
846        assert_eq!(inflate_rect(rect, 0.0), rect);
847        assert_eq!(inflate_rect(rect, -1.0), rect);
848        assert_eq!(inflate_rect(rect, f32::NAN), rect);
849        assert_eq!(
850            inflate_rect(rect, 1.0),
851            Rect {
852                x: 0.0,
853                y: 1.0,
854                width: 5.0,
855                height: 6.0
856            }
857        );
858    }
859}