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        // The int round-trip would turn -0.0 into +0.0; floorf keeps the sign.
149        return x;
150    }
151    if x.abs() < 8_388_608.0 {
152        let truncated = x as i32 as f32;
153        truncated - ((x < truncated) as i32 as f32)
154    } else {
155        x
156    }
157}
158
159/// `x mod TAU` into `[0, TAU)` without `rem_euclid`, whose `fmodf` lowers to
160/// the software routine in compiler_builtins on aarch64 Android and shows up
161/// in profiles at two calls per arc per frame. Multiply-floor keeps it to a
162/// couple of instructions; the fixup folds the one-ulp overshoot cases back
163/// into range.
164#[inline]
165fn wrap_angle_tau(x: f32) -> f32 {
166    let wrapped = x - exact_floor(x * (1.0 / TAU)) * TAU;
167    if wrapped >= TAU {
168        wrapped - TAU
169    } else if wrapped < 0.0 {
170        0.0
171    } else {
172        wrapped
173    }
174}
175
176/// `(sin, cos)` by refined parabola, absolute error under [`FAST_TRIG_ERR`].
177/// Bounding boxes only need trig that is close — the box gets padded by the
178/// worst-case position error afterwards — and libm's `sincosf`, called twice
179/// per partial arc, was one of the larger single costs of recording a
180/// shape-heavy frame on a watch-class core.
181#[inline]
182fn fast_sin_cos(angle: f32) -> (f32, f32) {
183    use std::f32::consts::{FRAC_PI_2, PI};
184    #[inline]
185    fn fold_sin(x: f32) -> f32 {
186        const B: f32 = 4.0 / PI;
187        const C: f32 = -4.0 / (PI * PI);
188        let y = B * x + C * x * x.abs();
189        0.225 * (y * y.abs() - y) + y
190    }
191    let x = wrap_angle_tau(angle);
192    let x = if x > PI { x - TAU } else { x };
193    let mut c = x + FRAC_PI_2;
194    if c > PI {
195        c -= TAU;
196    }
197    (fold_sin(x), fold_sin(c))
198}
199
200/// Worst-case absolute error of [`fast_sin_cos`]; bounds derived from it are
201/// padded by radius x this so the approximate box always contains the exact
202/// shape.
203const FAST_TRIG_ERR: f32 = 1.3e-3;
204
205impl ArcGeometry {
206    /// Normalizing constructor. Never panics and never stores a NaN.
207    pub fn new(
208        center: Point,
209        inner_radius: f32,
210        outer_radius: f32,
211        start_angle: f32,
212        sweep_angle: f32,
213        cap: StrokeCap,
214    ) -> Self {
215        let finite = center.x.is_finite()
216            && center.y.is_finite()
217            && inner_radius.is_finite()
218            && outer_radius.is_finite()
219            && start_angle.is_finite()
220            && sweep_angle.is_finite();
221        if !finite {
222            return Self::DEGENERATE;
223        }
224
225        let outer = outer_radius.max(0.0);
226        let inner = inner_radius.clamp(0.0, outer);
227
228        // Fold negative sweeps into a positive sweep starting at the other end
229        // so downstream math (and the shader) only ever sees `0 ..= TAU`.
230        let (mut start, mut sweep) = if sweep_angle < 0.0 {
231            (start_angle + sweep_angle, -sweep_angle)
232        } else {
233            (start_angle, sweep_angle)
234        };
235        if sweep >= TAU {
236            // A closed ring: caps can never be seen, and forcing `Round` keeps
237            // the shader from clipping a hairline seam at the wrap point.
238            sweep = TAU;
239            start = 0.0;
240        }
241        start = wrap_angle_tau(start);
242        if !start.is_finite() {
243            start = 0.0;
244        }
245        let cap = if sweep >= TAU { StrokeCap::Round } else { cap };
246
247        Self {
248            center,
249            inner_radius: inner,
250            outer_radius: outer,
251            start_angle: start,
252            sweep_angle: sweep,
253            cap,
254        }
255    }
256
257    const DEGENERATE: Self = Self {
258        center: Point::ZERO,
259        inner_radius: 0.0,
260        outer_radius: 0.0,
261        start_angle: 0.0,
262        sweep_angle: 0.0,
263        cap: StrokeCap::Butt,
264    };
265
266    /// Radius of the band's centerline (`ra` in the analytic arc SDF).
267    pub fn mid_radius(&self) -> f32 {
268        (self.inner_radius + self.outer_radius) * 0.5
269    }
270
271    /// Half the band thickness (`rb` in the analytic arc SDF). Also the radius
272    /// of a round cap and the projection distance of a square cap.
273    pub fn half_thickness(&self) -> f32 {
274        (self.outer_radius - self.inner_radius) * 0.5
275    }
276
277    /// True when the band encloses no area and therefore must not be emitted.
278    pub fn is_degenerate(&self) -> bool {
279        !(self.outer_radius > 0.0
280            && self.outer_radius > self.inner_radius
281            && self.sweep_angle > 0.0)
282    }
283
284    /// True when `angle` lies inside `[start, start + sweep]` (mod `TAU`).
285    pub fn contains_angle(&self, angle: f32) -> bool {
286        if self.sweep_angle >= TAU {
287            return true;
288        }
289        let delta = wrap_angle_tau(angle - self.start_angle);
290        delta <= self.sweep_angle + 1e-6
291    }
292
293    /// Scales radii and translates the center. Angles are unchanged, so this is
294    /// only valid for a uniform (non-mirroring) scale.
295    pub fn scaled_about(&self, center: Point, scale: f32) -> Self {
296        Self {
297            center,
298            inner_radius: self.inner_radius * scale,
299            outer_radius: self.outer_radius * scale,
300            ..*self
301        }
302    }
303
304    /// Tight axis-aligned bounding box of the rendered band, caps included.
305    ///
306    /// The box is the union of
307    /// * the two radial ends (inner and outer radius, extended for
308    ///   round/square caps), and
309    /// * the outer-radius point at every axis direction (0, 90, 180, 270
310    ///   degrees) that the sweep actually crosses.
311    ///
312    /// Sampling only the endpoints would be wrong for any sweep that crosses an
313    /// axis: a 0..270 degree sweep reaches `center.x + outer` *and*
314    /// `center.x - outer` even though neither endpoint does.
315    pub fn bounds(&self) -> Rect {
316        if self.is_degenerate() {
317            return Rect {
318                x: self.center.x,
319                y: self.center.y,
320                width: 0.0,
321                height: 0.0,
322            };
323        }
324
325        // A closed ring reaches `center ± outer` on all four axes and nothing
326        // in it — caps included — reaches further, so its box needs no
327        // endpoint trig at all. Most primitives in a particle-heavy scene are
328        // full circles (dots, rings, glow discs), and the two `sin_cos` calls
329        // below were the single largest trig cost of recording such a frame.
330        // (`new` forces `Round` at a full sweep; a hand-built square cap can
331        // project past `outer` along the tangent, so it keeps the long path.)
332        if self.sweep_angle >= TAU && self.cap != StrokeCap::Square {
333            let r = self.outer_radius;
334            return Rect {
335                x: self.center.x - r,
336                y: self.center.y - r,
337                width: r + r,
338                height: r + r,
339            };
340        }
341
342        let mut min_x = f32::INFINITY;
343        let mut min_y = f32::INFINITY;
344        let mut max_x = f32::NEG_INFINITY;
345        let mut max_y = f32::NEG_INFINITY;
346        let mut include = |x: f32, y: f32| {
347            min_x = min_x.min(x);
348            min_y = min_y.min(y);
349            max_x = max_x.max(x);
350            max_y = max_y.max(y);
351        };
352
353        let rb = self.half_thickness();
354        let ra = self.mid_radius();
355        let end_angle = self.start_angle + self.sweep_angle;
356
357        for (angle, outward) in [(self.start_angle, -1.0f32), (end_angle, 1.0f32)] {
358            let (sin, cos) = fast_sin_cos(angle);
359            match self.cap {
360                StrokeCap::Butt => {
361                    include(
362                        self.center.x + cos * self.inner_radius,
363                        self.center.y + sin * self.inner_radius,
364                    );
365                    include(
366                        self.center.x + cos * self.outer_radius,
367                        self.center.y + sin * self.outer_radius,
368                    );
369                }
370                StrokeCap::Square => {
371                    // Projected along the tangent, away from the sweep.
372                    let tx = -sin * rb * outward;
373                    let ty = cos * rb * outward;
374                    include(
375                        self.center.x + cos * self.inner_radius + tx,
376                        self.center.y + sin * self.inner_radius + ty,
377                    );
378                    include(
379                        self.center.x + cos * self.outer_radius + tx,
380                        self.center.y + sin * self.outer_radius + ty,
381                    );
382                }
383                StrokeCap::Round => {
384                    // Semicircle of radius `rb` centered on the band centerline.
385                    let cx = self.center.x + cos * ra;
386                    let cy = self.center.y + sin * ra;
387                    include(cx - rb, cy - rb);
388                    include(cx + rb, cy + rb);
389                }
390            }
391        }
392
393        // The axis directions have constant sines and cosines; going through
394        // `sin_cos` here doubled the trig cost of every arc in a shape-heavy
395        // scene.
396        const AXIS_DIRECTIONS: [(f32, f32); 4] = [(0.0, 1.0), (1.0, 0.0), (0.0, -1.0), (-1.0, 0.0)];
397        for (quadrant, (sin, cos)) in AXIS_DIRECTIONS.into_iter().enumerate() {
398            let angle = quadrant as f32 * std::f32::consts::FRAC_PI_2;
399            if self.contains_angle(angle) {
400                include(
401                    self.center.x + cos * self.outer_radius,
402                    self.center.y + sin * self.outer_radius,
403                );
404            }
405        }
406
407        // The endpoint positions above came from approximate trig; grow the
408        // box by their worst-case error (sub-pixel at any plausible radius)
409        // so it still contains the exact shape. Square caps project rb along
410        // an approximate tangent on top of the radial term, hence the sum;
411        // the absolute floor keeps the containment margin real for tiny
412        // radii where f32 rounding competes with the scaled term.
413        let pad = (self.outer_radius + rb) * FAST_TRIG_ERR + 0.02;
414        Rect {
415            x: min_x - pad,
416            y: min_y - pad,
417            width: (max_x - min_x + pad + pad).max(0.0),
418            height: (max_y - min_y + pad + pad).max(0.0),
419        }
420    }
421}
422
423/// Resolves the `(inner, outer, cap)` band described by a
424/// [`crate::DrawPrimitive::Arc`].
425///
426/// * `stroke = Some(_)` — a stroked arc centered on `radius`.
427/// * `stroke = None` — a filled annular sector from `inner_radius` to `radius`
428///   with flat (butt) radial ends. `inner_radius <= 0` yields a filled pie
429///   wedge.
430///
431/// Non-finite input collapses to an empty band so the caller drops the draw
432/// instead of pushing NaN down the pipeline.
433pub fn arc_band(radius: f32, inner_radius: f32, stroke: Option<Stroke>) -> (f32, f32, StrokeCap) {
434    match stroke {
435        Some(stroke) => {
436            if !radius.is_finite() || !stroke.is_visible() {
437                return (0.0, 0.0, stroke.cap);
438            }
439            let half = stroke.half_width();
440            let radius = radius.max(0.0);
441            ((radius - half).max(0.0), radius + half, stroke.cap)
442        }
443        None => {
444            if !radius.is_finite() || !inner_radius.is_finite() {
445                return (0.0, 0.0, StrokeCap::Butt);
446            }
447            let outer = radius.max(0.0);
448            let inner = inner_radius.clamp(0.0, outer);
449            (inner, outer, StrokeCap::Butt)
450        }
451    }
452}
453
454/// Grows `rect` by `amount` on every side, clamping to a non-negative size.
455pub fn inflate_rect(rect: Rect, amount: f32) -> Rect {
456    if !amount.is_finite() || amount <= 0.0 {
457        return rect;
458    }
459    Rect {
460        x: rect.x - amount,
461        y: rect.y - amount,
462        width: (rect.width + amount * 2.0).max(0.0),
463        height: (rect.height + amount * 2.0).max(0.0),
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use std::f32::consts::{FRAC_PI_2, PI};
471
472    /// Bounds are deliberately conservative now: endpoint trig is
473    /// approximate and the box is padded by its worst-case error, so
474    /// "hugs"/"tight" means within that documented slack, not within float
475    /// noise. The containment property test below is the strict guard.
476    fn approx(a: f32, b: f32) -> bool {
477        (a - b).abs() < 0.15
478    }
479
480    #[test]
481    fn exact_floor_is_bit_equal_to_floorf() {
482        let mut probes: Vec<f32> = vec![
483            0.0,
484            -0.0,
485            0.5,
486            -0.5,
487            1.0,
488            -1.0,
489            8_388_607.5,
490            -8_388_607.5,
491            8_388_608.0,
492            -8_388_608.0,
493            1.0e30,
494            -1.0e30,
495            f32::INFINITY,
496            f32::NEG_INFINITY,
497            f32::MIN_POSITIVE,
498            -f32::MIN_POSITIVE,
499        ];
500        for i in -4000..4000 {
501            probes.push(i as f32 * 0.01737);
502            probes.push(i as f32 * PI);
503        }
504        for x in probes {
505            assert_eq!(
506                exact_floor(x).to_bits(),
507                x.floor().to_bits(),
508                "exact_floor({x}) diverged from floorf"
509            );
510        }
511        assert!(exact_floor(f32::NAN).is_nan());
512    }
513
514    #[test]
515    fn stroke_builders_compose() {
516        let stroke = Stroke::new(4.0)
517            .with_cap(StrokeCap::Round)
518            .with_join(StrokeJoin::Bevel);
519        assert_eq!(stroke.width, 4.0);
520        assert_eq!(stroke.cap, StrokeCap::Round);
521        assert_eq!(stroke.join, StrokeJoin::Bevel);
522        assert_eq!(stroke.half_width(), 2.0);
523        assert!(stroke.is_visible());
524        assert_eq!(Stroke::default(), Stroke::new(1.0));
525        assert_eq!(Stroke::new(4.0).with_width(6.0).width, 6.0);
526    }
527
528    #[test]
529    fn stroke_rejects_non_positive_and_non_finite_widths() {
530        assert!(!Stroke::new(0.0).is_visible());
531        assert!(!Stroke::new(-3.0).is_visible());
532        assert!(!Stroke::new(f32::NAN).is_visible());
533        assert!(!Stroke::new(f32::INFINITY).is_visible());
534        assert_eq!(Stroke::new(f32::NAN).half_width(), 0.0);
535        assert_eq!(Stroke::new(-3.0).half_width(), 0.0);
536    }
537
538    #[test]
539    fn arc_geometry_normalizes_negative_sweeps() {
540        let arc = ArcGeometry::new(Point::ZERO, 1.0, 2.0, PI, -FRAC_PI_2, StrokeCap::Butt);
541        assert!(approx(arc.start_angle, PI - FRAC_PI_2));
542        assert!(approx(arc.sweep_angle, FRAC_PI_2));
543    }
544
545    #[test]
546    fn arc_geometry_clamps_full_turns_and_forces_round_caps() {
547        let arc = ArcGeometry::new(Point::ZERO, 1.0, 2.0, 0.3, TAU * 3.0, StrokeCap::Butt);
548        assert_eq!(arc.sweep_angle, TAU);
549        assert_eq!(
550            arc.cap,
551            StrokeCap::Round,
552            "a closed ring must not clip its (invisible) caps"
553        );
554        assert!(arc.contains_angle(0.0));
555        assert!(arc.contains_angle(PI));
556    }
557
558    #[test]
559    fn arc_geometry_sanitizes_non_finite_input() {
560        for arc in [
561            ArcGeometry::new(
562                Point::new(f32::NAN, 0.0),
563                1.0,
564                2.0,
565                0.0,
566                1.0,
567                StrokeCap::Butt,
568            ),
569            ArcGeometry::new(Point::ZERO, f32::NAN, 2.0, 0.0, 1.0, StrokeCap::Butt),
570            ArcGeometry::new(Point::ZERO, 1.0, f32::INFINITY, 0.0, 1.0, StrokeCap::Butt),
571            ArcGeometry::new(Point::ZERO, 1.0, 2.0, f32::NAN, 1.0, StrokeCap::Butt),
572            ArcGeometry::new(Point::ZERO, 1.0, 2.0, 0.0, f32::NAN, StrokeCap::Butt),
573        ] {
574            assert!(arc.is_degenerate());
575            let bounds = arc.bounds();
576            for value in [bounds.x, bounds.y, bounds.width, bounds.height] {
577                assert!(value.is_finite(), "degenerate arc bounds must stay finite");
578            }
579        }
580    }
581
582    /// The strict contract of approximate bounds: the box must CONTAIN the
583    /// box the exact-trig algorithm produces, and must not exceed it by more
584    /// than the documented pad. Sweeps every cap, many radii and angles.
585    #[test]
586    fn approximate_bounds_contain_the_exact_box_within_documented_slack() {
587        for radius in [2.0f32, 10.0, 57.0, 204.0] {
588            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
589                for step in 0..48 {
590                    let start = step as f32 * (TAU / 48.0) * 1.031;
591                    for sweep in [0.05f32, 0.9, FRAC_PI_2, 3.6] {
592                        let arc = ArcGeometry::new(
593                            Point::new(11.0, -7.0),
594                            radius * 0.55,
595                            radius,
596                            start,
597                            sweep,
598                            cap,
599                        );
600                        if arc.is_degenerate() {
601                            continue;
602                        }
603                        let bounds = arc.bounds();
604                        let exact = exact_bounds(&arc);
605                        let slack =
606                            (arc.outer_radius + arc.half_thickness()) * FAST_TRIG_ERR * 2.0 + 0.05;
607                        assert!(
608                            bounds.x <= exact.x + 1e-3
609                                && bounds.y <= exact.y + 1e-3
610                                && bounds.x + bounds.width >= exact.x + exact.width - 1e-3
611                                && bounds.y + bounds.height >= exact.y + exact.height - 1e-3,
612                            "approximate box lost containment: {bounds:?} vs exact {exact:?} \
613                             (radius {radius}, start {start}, sweep {sweep}, cap {cap:?})"
614                        );
615                        assert!(
616                            (bounds.x - exact.x).abs() <= slack
617                                && (bounds.y - exact.y).abs() <= slack
618                                && (bounds.width - exact.width).abs() <= 2.0 * slack
619                                && (bounds.height - exact.height).abs() <= 2.0 * slack,
620                            "approximate box drifted past its slack: {bounds:?} vs exact \
621                             {exact:?} slack {slack} (radius {radius}, start {start}, sweep \
622                             {sweep}, cap {cap:?})"
623                        );
624                    }
625                }
626            }
627        }
628    }
629
630    /// The pre-approximation bounds algorithm, verbatim, with libm trig.
631    fn exact_bounds(arc: &ArcGeometry) -> Rect {
632        let mut min_x = f32::INFINITY;
633        let mut min_y = f32::INFINITY;
634        let mut max_x = f32::NEG_INFINITY;
635        let mut max_y = f32::NEG_INFINITY;
636        let mut include = |x: f32, y: f32| {
637            min_x = min_x.min(x);
638            min_y = min_y.min(y);
639            max_x = max_x.max(x);
640            max_y = max_y.max(y);
641        };
642        let rb = arc.half_thickness();
643        let ra = arc.mid_radius();
644        let end_angle = arc.start_angle + arc.sweep_angle;
645        for (angle, outward) in [(arc.start_angle, -1.0f32), (end_angle, 1.0f32)] {
646            let (sin, cos) = angle.sin_cos();
647            match arc.cap {
648                StrokeCap::Butt => {
649                    include(
650                        arc.center.x + cos * arc.inner_radius,
651                        arc.center.y + sin * arc.inner_radius,
652                    );
653                    include(
654                        arc.center.x + cos * arc.outer_radius,
655                        arc.center.y + sin * arc.outer_radius,
656                    );
657                }
658                StrokeCap::Square => {
659                    let tx = -sin * rb * outward;
660                    let ty = cos * rb * outward;
661                    include(
662                        arc.center.x + cos * arc.inner_radius + tx,
663                        arc.center.y + sin * arc.inner_radius + ty,
664                    );
665                    include(
666                        arc.center.x + cos * arc.outer_radius + tx,
667                        arc.center.y + sin * arc.outer_radius + ty,
668                    );
669                }
670                StrokeCap::Round => {
671                    let cx = arc.center.x + cos * ra;
672                    let cy = arc.center.y + sin * ra;
673                    include(cx - rb, cy - rb);
674                    include(cx + rb, cy + rb);
675                }
676            }
677        }
678        const AXIS_DIRECTIONS: [(f32, f32); 4] = [(0.0, 1.0), (1.0, 0.0), (0.0, -1.0), (-1.0, 0.0)];
679        for (quadrant, (sin, cos)) in AXIS_DIRECTIONS.into_iter().enumerate() {
680            let angle = quadrant as f32 * FRAC_PI_2;
681            if arc.contains_angle(angle) {
682                include(
683                    arc.center.x + cos * arc.outer_radius,
684                    arc.center.y + sin * arc.outer_radius,
685                );
686            }
687        }
688        Rect {
689            x: min_x,
690            y: min_y,
691            width: (max_x - min_x).max(0.0),
692            height: (max_y - min_y).max(0.0),
693        }
694    }
695
696    #[test]
697    fn arc_geometry_flags_degenerate_bands() {
698        // inner >= outer
699        assert!(ArcGeometry::new(Point::ZERO, 5.0, 5.0, 0.0, 1.0, StrokeCap::Butt).is_degenerate());
700        assert!(ArcGeometry::new(Point::ZERO, 9.0, 5.0, 0.0, 1.0, StrokeCap::Butt).is_degenerate());
701        // zero sweep
702        assert!(ArcGeometry::new(Point::ZERO, 1.0, 5.0, 0.0, 0.0, StrokeCap::Butt).is_degenerate());
703        // zero radius
704        assert!(ArcGeometry::new(Point::ZERO, 0.0, 0.0, 0.0, 1.0, StrokeCap::Butt).is_degenerate());
705    }
706
707    #[test]
708    fn arc_bounds_quarter_sweep_hugs_the_quadrant() {
709        let arc = ArcGeometry::new(
710            Point::new(100.0, 100.0),
711            0.0,
712            10.0,
713            0.0,
714            FRAC_PI_2,
715            StrokeCap::Butt,
716        );
717        let bounds = arc.bounds();
718        assert!(approx(bounds.x, 100.0), "{bounds:?}");
719        assert!(approx(bounds.y, 100.0), "{bounds:?}");
720        assert!(approx(bounds.width, 10.0), "{bounds:?}");
721        assert!(approx(bounds.height, 10.0), "{bounds:?}");
722    }
723
724    #[test]
725    fn arc_bounds_three_quarter_sweep_spans_every_axis_it_crosses() {
726        // 0 -> 270 degrees crosses +X, +Y, -X and ends on -Y.
727        let arc = ArcGeometry::new(
728            Point::new(0.0, 0.0),
729            0.0,
730            10.0,
731            0.0,
732            3.0 * FRAC_PI_2,
733            StrokeCap::Butt,
734        );
735        let bounds = arc.bounds();
736        assert!(approx(bounds.x, -10.0), "{bounds:?}");
737        assert!(approx(bounds.y, -10.0), "{bounds:?}");
738        assert!(approx(bounds.width, 20.0), "{bounds:?}");
739        assert!(approx(bounds.height, 20.0), "{bounds:?}");
740    }
741
742    #[test]
743    fn arc_bounds_include_inner_endpoints_when_no_axis_is_crossed() {
744        // 45 -> 135 degrees only crosses +Y; the minimum y comes from the two
745        // *inner* radial endpoints, not from the outer arc.
746        let arc = ArcGeometry::new(
747            Point::ZERO,
748            8.0,
749            10.0,
750            std::f32::consts::FRAC_PI_4,
751            FRAC_PI_2,
752            StrokeCap::Butt,
753        );
754        let bounds = arc.bounds();
755        let sqrt2_2 = std::f32::consts::FRAC_1_SQRT_2;
756        assert!(approx(bounds.y, 8.0 * sqrt2_2), "{bounds:?}");
757        assert!(approx(bounds.y + bounds.height, 10.0), "{bounds:?}");
758        assert!(approx(bounds.x, -10.0 * sqrt2_2), "{bounds:?}");
759        assert!(approx(bounds.width, 20.0 * sqrt2_2), "{bounds:?}");
760    }
761
762    #[test]
763    fn arc_bounds_negative_sweep_matches_equivalent_positive_sweep() {
764        let forward = ArcGeometry::new(Point::ZERO, 4.0, 6.0, 0.0, FRAC_PI_2, StrokeCap::Butt);
765        let backward = ArcGeometry::new(
766            Point::ZERO,
767            4.0,
768            6.0,
769            FRAC_PI_2,
770            -FRAC_PI_2,
771            StrokeCap::Butt,
772        );
773        assert_eq!(forward.bounds(), backward.bounds());
774    }
775
776    #[test]
777    fn arc_bounds_full_turn_is_the_outer_circle() {
778        let arc = ArcGeometry::new(Point::new(5.0, 7.0), 3.0, 9.0, 1.1, TAU, StrokeCap::Butt);
779        let bounds = arc.bounds();
780        assert!(approx(bounds.x, -4.0), "{bounds:?}");
781        assert!(approx(bounds.y, -2.0), "{bounds:?}");
782        assert!(approx(bounds.width, 18.0), "{bounds:?}");
783        assert!(approx(bounds.height, 18.0), "{bounds:?}");
784    }
785
786    #[test]
787    fn arc_bounds_round_caps_bulge_past_the_radial_ends() {
788        let butt = ArcGeometry::new(Point::ZERO, 8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Butt);
789        let round = ArcGeometry::new(Point::ZERO, 8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Round);
790        let butt_bounds = butt.bounds();
791        let round_bounds = round.bounds();
792        // The start cap at angle 0 bulges to -rb in y; butt stops at y = 0.
793        assert!(approx(butt_bounds.y, 0.0), "{butt_bounds:?}");
794        assert!(approx(round_bounds.y, -2.0), "{round_bounds:?}");
795        assert!(round_bounds.width >= butt_bounds.width);
796        assert!(round_bounds.height >= butt_bounds.height);
797    }
798
799    #[test]
800    fn arc_bounds_square_caps_project_along_the_tangent() {
801        let square = ArcGeometry::new(Point::ZERO, 8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Square);
802        let bounds = square.bounds();
803        // Start cap at angle 0: tangent is +Y, projected backwards by rb = 2.
804        assert!(approx(bounds.y, -2.0), "{bounds:?}");
805        assert!(approx(bounds.x + bounds.width, 12.0), "{bounds:?}");
806    }
807
808    #[test]
809    fn arc_band_resolves_stroked_and_filled_forms() {
810        let (inner, outer, cap) =
811            arc_band(10.0, 0.0, Some(Stroke::new(4.0).with_cap(StrokeCap::Round)));
812        assert_eq!((inner, outer), (8.0, 12.0));
813        assert_eq!(cap, StrokeCap::Round);
814
815        let (inner, outer, cap) = arc_band(10.0, 6.0, None);
816        assert_eq!((inner, outer), (6.0, 10.0));
817        assert_eq!(cap, StrokeCap::Butt);
818
819        // inner >= outer clamps rather than producing a negative band.
820        let (inner, outer, _) = arc_band(10.0, 40.0, None);
821        assert_eq!((inner, outer), (10.0, 10.0));
822
823        // A stroke wider than the radius clamps the inner radius at 0.
824        let (inner, outer, _) = arc_band(1.0, 0.0, Some(Stroke::new(10.0)));
825        assert_eq!((inner, outer), (0.0, 6.0));
826    }
827
828    #[test]
829    fn full_ring_bounds_shortcut_matches_the_endpoint_walk() {
830        // The trig-free full-sweep path must return exactly what the endpoint
831        // walk would: `center ± outer` on both axes. The walk's answer for a
832        // full ring is forced by the four axis crossings plus a round cap
833        // whose farthest point sits at `mid + half_thickness == outer`.
834        let ring = ArcGeometry::new(Point::new(10.0, -4.0), 6.0, 9.0, 1.3, TAU, StrokeCap::Butt);
835        assert_eq!(
836            ring.bounds(),
837            Rect {
838                x: 1.0,
839                y: -13.0,
840                width: 18.0,
841                height: 18.0
842            }
843        );
844
845        // A hand-built square cap can project past `outer` along the tangent
846        // (its corner sits at distance `sqrt(outer² + rb²)` from the center),
847        // so a full-sweep square ring must keep the endpoint walk. The
848        // endpoint angle is chosen so the corner lands on the +x axis, where
849        // the excess is largest.
850        let square = ArcGeometry {
851            cap: StrokeCap::Square,
852            start_angle: TAU - (1.5f32 / 9.0).atan(),
853            ..ring
854        };
855        let bounds = square.bounds();
856        assert!(bounds.x + bounds.width > square.center.x + square.outer_radius);
857    }
858
859    #[test]
860    fn inflate_rect_ignores_non_positive_amounts() {
861        let rect = Rect {
862            x: 1.0,
863            y: 2.0,
864            width: 3.0,
865            height: 4.0,
866        };
867        assert_eq!(inflate_rect(rect, 0.0), rect);
868        assert_eq!(inflate_rect(rect, -1.0), rect);
869        assert_eq!(inflate_rect(rect, f32::NAN), rect);
870        assert_eq!(
871            inflate_rect(rect, 1.0),
872            Rect {
873                x: 0.0,
874                y: 1.0,
875                width: 5.0,
876                height: 6.0
877            }
878        );
879    }
880}