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::{
19    Point, Rect,
20    float::{all_finite, at_least, within},
21};
22
23/// Shape of the two ends of an open stroked path (an arc, today).
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum StrokeCap {
26    /// Flat end exactly at the geometric end of the path.
27    #[default]
28    Butt,
29    /// Semicircular end bulging half the stroke width past the path end.
30    Round,
31    /// Flat end projected half the stroke width past the path end.
32    Square,
33}
34
35/// Shape produced where two stroked segments meet at a corner.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
37pub enum StrokeJoin {
38    /// Extend the outer edges until they meet in a sharp point.
39    #[default]
40    Miter,
41    /// Fill the corner with a circular arc of half the stroke width.
42    Round,
43    /// Cut the corner off with a straight chamfer.
44    Bevel,
45}
46
47/// Describes how an outline is stroked.
48///
49/// The stroke is *centered* on the geometry: it extends `width / 2` to either
50/// side of the path, matching Skia / Jetpack Compose semantics.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct Stroke {
53    /// Total stroke width in the caller's coordinate space (dp for
54    /// [`crate::DrawScope`] callers).
55    pub width: f32,
56    /// How the ends of an open stroked path are shaped.
57    pub cap: StrokeCap,
58    /// How corners between stroked segments are shaped.
59    pub join: StrokeJoin,
60}
61
62impl Stroke {
63    /// A `width`-wide stroke with butt caps and miter joins.
64    pub const fn new(width: f32) -> Self {
65        Self {
66            width,
67            cap: StrokeCap::Butt,
68            join: StrokeJoin::Miter,
69        }
70    }
71
72    /// Returns the stroke with its width replaced by `width`.
73    pub const fn with_width(mut self, width: f32) -> Self {
74        self.width = width;
75        self
76    }
77
78    /// Returns the stroke with its cap replaced by `cap`.
79    pub const fn with_cap(mut self, cap: StrokeCap) -> Self {
80        self.cap = cap;
81        self
82    }
83
84    /// Returns the stroke with its join replaced by `join`.
85    pub const fn with_join(mut self, join: StrokeJoin) -> Self {
86        self.join = join;
87        self
88    }
89
90    /// Half the stroke width, clamped to a finite non-negative value.
91    ///
92    /// This is the amount the stroke bleeds outside (and inside) the geometry.
93    pub fn half_width(&self) -> f32 {
94        if self.width.is_finite() {
95            at_least(self.width * 0.5, 0.0)
96        } else {
97            0.0
98        }
99    }
100
101    /// A stroke is renderable only when it has a strictly positive, finite width.
102    pub fn is_visible(&self) -> bool {
103        // The positive finite floats are the bit patterns 1 through the
104        // largest finite one; zero, the negatives, infinity and NaN fall
105        // outside. One integer compare instead of two float compares, each
106        // an FPSCR transfer on armv7, for every stroked primitive recorded.
107        self.width.to_bits().wrapping_sub(1) < f32::MAX.to_bits()
108    }
109
110    /// Scales the stroke width (used when a layer transform scales the shape).
111    pub fn scaled(&self, scale: f32) -> Self {
112        Self {
113            width: self.width * scale,
114            ..*self
115        }
116    }
117}
118
119impl Default for Stroke {
120    fn default() -> Self {
121        Self::new(1.0)
122    }
123}
124
125/// Full turn in radians.
126pub const TAU: f32 = std::f32::consts::PI * 2.0;
127
128/// A resolved circular *band* between two radii, limited to an angular sweep.
129///
130/// Both a stroked arc and a filled annular sector lower to this single form:
131///
132/// * stroked arc — `inner = radius - width/2`, `outer = radius + width/2`,
133///   ends shaped by the stroke's [`StrokeCap`];
134/// * filled annular sector — `inner`/`outer` as given, always butt (flat
135///   radial) ends.
136///
137/// Values are normalized on construction: `sweep_angle` is non-negative and at
138/// most [`TAU`], `outer_radius >= inner_radius >= 0`, and non-finite inputs
139/// collapse to a degenerate geometry (see [`ArcGeometry::is_degenerate`]).
140#[derive(Clone, Copy, Debug, PartialEq)]
141pub struct ArcGeometry {
142    /// Center of the circle the band lies on.
143    pub center: Point,
144    /// Inner radius of the band. It is 0 for a pie wedge.
145    pub inner_radius: f32,
146    /// Outer radius of the band, not less than `inner_radius`.
147    pub outer_radius: f32,
148    /// Normalized to `[0, TAU)`.
149    pub start_angle: f32,
150    /// Normalized to `[0, TAU]`.
151    pub sweep_angle: f32,
152    /// How the two ends of the band are shaped.
153    pub cap: StrokeCap,
154}
155
156/// Exact `x.floor()` without the libm call `f32::floor` lowers to on armv7
157/// (no `vrintm` there): truncate via int cast, fix up negatives. Bit-equal
158/// to `floorf` for every input — casts only run below 2^23, where i32 cannot
159/// saturate, and at 2^23 and above every finite f32 is already an integer.
160/// NaN fails the range test and passes through unchanged, like `floorf`.
161#[inline]
162fn exact_floor(x: f32) -> f32 {
163    if x == 0.0 {
164        return x;
165    }
166    if x.abs() < 8_388_608.0 {
167        let truncated = x as i32 as f32;
168        truncated - ((x < truncated) as i32 as f32)
169    } else {
170        x
171    }
172}
173
174/// `x mod TAU` into `[0, TAU)` without `rem_euclid`, whose `fmodf` lowers to
175/// the software routine in compiler_builtins on aarch64 Android and shows up
176/// in profiles at two calls per arc per frame. Multiply-floor keeps it to a
177/// couple of instructions; the fixup folds the one-ulp overshoot cases back
178/// into range.
179#[inline]
180fn wrap_angle_tau(x: f32) -> f32 {
181    if (0.0..TAU).contains(&x) {
182        return x;
183    }
184    let wrapped = x - exact_floor(x * (1.0 / TAU)) * TAU;
185    if wrapped >= TAU {
186        wrapped - TAU
187    } else if wrapped < 0.0 {
188        0.0
189    } else {
190        wrapped
191    }
192}
193
194/// `(sin, cos)` by refined parabola, absolute error under [`FAST_TRIG_ERR`].
195/// Bounding boxes only need trig that is close — the box gets padded by the
196/// worst-case position error afterwards — and libm's `sincosf`, called twice
197/// per partial arc, was one of the larger single costs of recording a
198/// shape-heavy frame on a watch-class core.
199#[inline]
200fn fast_sin_cos(angle: f32) -> (f32, f32) {
201    use std::f32::consts::{FRAC_PI_2, PI};
202    #[inline]
203    fn fold_sin(x: f32) -> f32 {
204        const B: f32 = 4.0 / PI;
205        const C: f32 = -4.0 / (PI * PI);
206        let y = B * x + C * x * x.abs();
207        0.225 * (y * y.abs() - y) + y
208    }
209    let x = wrap_angle_tau(angle);
210    let x = if x > PI { x - TAU } else { x };
211    let mut c = x + FRAC_PI_2;
212    if c > PI {
213        c -= TAU;
214    }
215    (fold_sin(x), fold_sin(c))
216}
217
218/// Worst-case absolute error of [`fast_sin_cos`]; bounds derived from it are
219/// padded by radius x this so the approximate box always contains the exact
220/// shape.
221const FAST_TRIG_ERR: f32 = 1.3e-3;
222
223impl ArcGeometry {
224    /// Normalizing constructor. Never panics and never stores a NaN.
225    #[inline]
226    pub fn new(
227        center: Point,
228        inner_radius: f32,
229        outer_radius: f32,
230        start_angle: f32,
231        sweep_angle: f32,
232        cap: StrokeCap,
233    ) -> Self {
234        if !all_finite([
235            center.x,
236            center.y,
237            inner_radius,
238            outer_radius,
239            start_angle,
240            sweep_angle,
241        ]) {
242            return Self::DEGENERATE;
243        }
244        let outer = at_least(outer_radius, 0.0);
245        let inner = within(inner_radius, 0.0, outer);
246        Self::with_angles(center, inner, outer, start_angle, sweep_angle, cap)
247    }
248
249    /// [`Self::new`] for the radii [`arc_band`] returns, which already hold
250    /// `0 <= inner <= outer`: the same geometry without clamping them again.
251    #[inline]
252    pub(crate) fn of_band(
253        center: Point,
254        (inner, outer, cap): (f32, f32, StrokeCap),
255        start_angle: f32,
256        sweep_angle: f32,
257    ) -> Self {
258        if !all_finite([center.x, center.y, inner, outer, start_angle, sweep_angle]) {
259            return Self::DEGENERATE;
260        }
261        Self::with_angles(center, inner, outer, start_angle, sweep_angle, cap)
262    }
263
264    /// The geometry of normalized radii with its sweep made positive and at
265    /// most a full turn, and its start wrapped into `[0, TAU)`.
266    #[inline(always)]
267    fn with_angles(
268        center: Point,
269        inner: f32,
270        outer: f32,
271        start_angle: f32,
272        sweep_angle: f32,
273        cap: StrokeCap,
274    ) -> Self {
275        // A sweep in (0, TAU) from a start in [0, TAU) is already normal:
276        // told apart by the bits alone, with no float compare, each an
277        // FPSCR transfer on armv7 (a positive finite float's bits order as
278        // its value; zero, negatives, infinity and NaN fall outside).
279        if sweep_angle.to_bits().wrapping_sub(1) < TAU.to_bits() - 1
280            && start_angle.to_bits() < TAU.to_bits()
281        {
282            return Self {
283                center,
284                inner_radius: inner,
285                outer_radius: outer,
286                start_angle,
287                sweep_angle,
288                cap,
289            };
290        }
291        Self::normalizing_angles(center, inner, outer, start_angle, sweep_angle, cap)
292    }
293
294    /// [`Self::with_angles`] by comparing the angles as floats.
295    #[inline]
296    fn normalizing_angles(
297        center: Point,
298        inner: f32,
299        outer: f32,
300        start_angle: f32,
301        sweep_angle: f32,
302        cap: StrokeCap,
303    ) -> Self {
304        let (mut start, mut sweep) = if sweep_angle < 0.0 {
305            (start_angle + sweep_angle, -sweep_angle)
306        } else {
307            (start_angle, sweep_angle)
308        };
309        if sweep >= TAU {
310            sweep = TAU;
311            start = 0.0;
312        }
313        start = wrap_angle_tau(start);
314        if !start.is_finite() {
315            start = 0.0;
316        }
317        let cap = if sweep >= TAU { StrokeCap::Round } else { cap };
318
319        Self {
320            center,
321            inner_radius: inner,
322            outer_radius: outer,
323            start_angle: start,
324            sweep_angle: sweep,
325            cap,
326        }
327    }
328
329    const DEGENERATE: Self = Self {
330        center: Point::ZERO,
331        inner_radius: 0.0,
332        outer_radius: 0.0,
333        start_angle: 0.0,
334        sweep_angle: 0.0,
335        cap: StrokeCap::Butt,
336    };
337
338    /// Radius of the band's centerline (`ra` in the analytic arc SDF).
339    pub fn mid_radius(&self) -> f32 {
340        (self.inner_radius + self.outer_radius) * 0.5
341    }
342
343    /// Half the band thickness (`rb` in the analytic arc SDF). Also the radius
344    /// of a round cap and the projection distance of a square cap.
345    pub fn half_thickness(&self) -> f32 {
346        (self.outer_radius - self.inner_radius) * 0.5
347    }
348
349    /// True when the band encloses no area and therefore must not be emitted.
350    pub fn is_degenerate(&self) -> bool {
351        !(self.outer_radius > 0.0
352            && self.outer_radius > self.inner_radius
353            && self.sweep_angle > 0.0)
354    }
355
356    /// True when `angle` lies inside `[start, start + sweep]` (mod `TAU`).
357    pub fn contains_angle(&self, angle: f32) -> bool {
358        if self.sweep_angle >= TAU {
359            return true;
360        }
361        let delta = wrap_angle_tau(angle - self.start_angle);
362        delta <= self.sweep_angle + 1e-6
363    }
364
365    /// Scales radii and translates the center. Angles are unchanged, so this is
366    /// only valid for a uniform (non-mirroring) scale.
367    pub fn scaled_about(&self, center: Point, scale: f32) -> Self {
368        Self {
369            center,
370            inner_radius: self.inner_radius * scale,
371            outer_radius: self.outer_radius * scale,
372            ..*self
373        }
374    }
375
376    /// Tight axis-aligned bounding box of the rendered band, caps included.
377    ///
378    /// The box is the union of
379    /// * the two radial ends (inner and outer radius, extended for
380    ///   round/square caps), and
381    /// * the outer-radius point at every axis direction (0, 90, 180, 270
382    ///   degrees) that the sweep actually crosses.
383    ///
384    /// Sampling only the endpoints would be wrong for any sweep that crosses an
385    /// axis: a 0..270 degree sweep reaches `center.x + outer` *and*
386    /// `center.x - outer` even though neither endpoint does.
387    pub fn bounds(&self) -> Rect {
388        if self.is_degenerate() {
389            return Rect {
390                x: self.center.x,
391                y: self.center.y,
392                width: 0.0,
393                height: 0.0,
394            };
395        }
396
397        if self.sweep_angle >= TAU && self.cap != StrokeCap::Square {
398            let r = self.outer_radius;
399            return Rect {
400                x: self.center.x - r,
401                y: self.center.y - r,
402                width: r + r,
403                height: r + r,
404            };
405        }
406
407        let mut min_x = f32::INFINITY;
408        let mut min_y = f32::INFINITY;
409        let mut max_x = f32::NEG_INFINITY;
410        let mut max_y = f32::NEG_INFINITY;
411        let mut include = |x: f32, y: f32| {
412            min_x = min_x.min(x);
413            min_y = min_y.min(y);
414            max_x = max_x.max(x);
415            max_y = max_y.max(y);
416        };
417
418        let rb = self.half_thickness();
419        let ra = self.mid_radius();
420        let end_angle = self.start_angle + self.sweep_angle;
421
422        for (angle, outward) in [(self.start_angle, -1.0f32), (end_angle, 1.0f32)] {
423            let (sin, cos) = fast_sin_cos(angle);
424            match self.cap {
425                StrokeCap::Butt => {
426                    include(
427                        self.center.x + cos * self.inner_radius,
428                        self.center.y + sin * self.inner_radius,
429                    );
430                    include(
431                        self.center.x + cos * self.outer_radius,
432                        self.center.y + sin * self.outer_radius,
433                    );
434                }
435                StrokeCap::Square => {
436                    let tx = -sin * rb * outward;
437                    let ty = cos * rb * outward;
438                    include(
439                        self.center.x + cos * self.inner_radius + tx,
440                        self.center.y + sin * self.inner_radius + ty,
441                    );
442                    include(
443                        self.center.x + cos * self.outer_radius + tx,
444                        self.center.y + sin * self.outer_radius + ty,
445                    );
446                }
447                StrokeCap::Round => {
448                    let cx = self.center.x + cos * ra;
449                    let cy = self.center.y + sin * ra;
450                    include(cx - rb, cy - rb);
451                    include(cx + rb, cy + rb);
452                }
453            }
454        }
455
456        const AXIS_DIRECTIONS: [(f32, f32); 4] = [(0.0, 1.0), (1.0, 0.0), (0.0, -1.0), (-1.0, 0.0)];
457        for (quadrant, (sin, cos)) in AXIS_DIRECTIONS.into_iter().enumerate() {
458            let angle = quadrant as f32 * std::f32::consts::FRAC_PI_2;
459            if self.contains_angle(angle) {
460                include(
461                    self.center.x + cos * self.outer_radius,
462                    self.center.y + sin * self.outer_radius,
463                );
464            }
465        }
466
467        let pad = (self.outer_radius + rb) * FAST_TRIG_ERR + 0.02;
468        Rect {
469            x: min_x - pad,
470            y: min_y - pad,
471            width: (max_x - min_x + pad + pad).max(0.0),
472            height: (max_y - min_y + pad + pad).max(0.0),
473        }
474    }
475}
476
477/// Resolves the `(inner, outer, cap)` band described by a
478/// [`crate::DrawPrimitive::Arc`].
479///
480/// * `stroke = Some(_)` — a stroked arc centered on `radius`.
481/// * `stroke = None` — a filled annular sector from `inner_radius` to `radius`
482///   with flat (butt) radial ends. `inner_radius <= 0` yields a filled pie
483///   wedge.
484///
485/// Non-finite input collapses to an empty band so the caller drops the draw
486/// instead of pushing NaN down the pipeline.
487#[inline]
488pub fn arc_band(radius: f32, inner_radius: f32, stroke: Option<Stroke>) -> (f32, f32, StrokeCap) {
489    match stroke {
490        Some(stroke) => {
491            if !radius.is_finite() || !stroke.is_visible() {
492                return (0.0, 0.0, stroke.cap);
493            }
494            let half = stroke.half_width();
495            let radius = at_least(radius, 0.0);
496            (at_least(radius - half, 0.0), radius + half, stroke.cap)
497        }
498        None => {
499            if !radius.is_finite() || !inner_radius.is_finite() {
500                return (0.0, 0.0, StrokeCap::Butt);
501            }
502            let outer = at_least(radius, 0.0);
503            let inner = within(inner_radius, 0.0, outer);
504            (inner, outer, StrokeCap::Butt)
505        }
506    }
507}
508
509/// Grows `rect` by `amount` on every side, clamping to a non-negative size.
510pub fn inflate_rect(rect: Rect, amount: f32) -> Rect {
511    if !amount.is_finite() || amount <= 0.0 {
512        return rect;
513    }
514    Rect {
515        x: rect.x - amount,
516        y: rect.y - amount,
517        width: (rect.width + amount * 2.0).max(0.0),
518        height: (rect.height + amount * 2.0).max(0.0),
519    }
520}
521
522/// A stroked straight segment: its two ends, half its width and its cap,
523/// in the units it is drawn in. [`crate::DrawPrimitive::Line`] lowers to it,
524/// and the GPU and CPU renderers take their coverage from the same frame.
525#[derive(Clone, Copy, Debug, PartialEq)]
526pub struct LineGeometry {
527    /// Start of the segment.
528    pub start: Point,
529    /// End of the segment.
530    pub end: Point,
531    /// Half the stroke width, in the units the line is drawn in.
532    pub half_width: f32,
533    /// How the two ends of the segment are shaped.
534    pub cap: StrokeCap,
535}
536
537/// Where a pixel sits relative to a [`LineGeometry`]: its midpoint, the unit
538/// direction from start to end, and half its length.
539#[derive(Clone, Copy, Debug, PartialEq)]
540pub struct LineFrame {
541    /// Midpoint of the segment.
542    pub center: Point,
543    /// Unit vector pointing from the start to the end.
544    pub direction: Point,
545    /// Half the segment's length, in the units it is drawn in.
546    pub half_length: f32,
547}
548
549impl LineGeometry {
550    /// The segment from `start` to `end` stroked with `stroke`.
551    pub fn new(start: Point, end: Point, stroke: Stroke) -> Self {
552        Self {
553            start,
554            end,
555            half_width: stroke.half_width(),
556            cap: stroke.cap,
557        }
558    }
559
560    /// True when the segment covers nothing: a non-finite input, no width,
561    /// or no length between butt caps, as Compose's `drawLine` draws nothing
562    /// there.
563    pub fn is_degenerate(&self) -> bool {
564        !all_finite([self.start.x, self.start.y, self.end.x, self.end.y])
565            || !(self.half_width > 0.0 && self.half_width.is_finite())
566            || (self.cap == StrokeCap::Butt && self.start == self.end)
567    }
568
569    /// The midpoint, unit direction and half length coverage is measured
570    /// in. A segment of no length points along +X, so its round or square
571    /// cap still draws a dot.
572    pub fn frame(&self) -> LineFrame {
573        let (dx, dy) = (self.end.x - self.start.x, self.end.y - self.start.y);
574        let length = (dx * dx + dy * dy).sqrt();
575        let direction = if length > 0.0 {
576            Point::new(dx / length, dy / length)
577        } else {
578            Point::new(1.0, 0.0)
579        };
580        LineFrame {
581            center: Point::new(
582                (self.start.x + self.end.x) * 0.5,
583                (self.start.y + self.end.y) * 0.5,
584            ),
585            direction,
586            half_length: length * 0.5,
587        }
588    }
589
590    /// How far past each end the stroke reaches: half the width for round
591    /// and square caps, nothing for butt caps.
592    pub fn cap_reach(&self) -> f32 {
593        if self.cap == StrokeCap::Butt {
594            0.0
595        } else {
596            self.half_width
597        }
598    }
599
600    /// The axis-aligned box of the ends, the rect the primitive carries.
601    pub fn end_bounds(&self) -> Rect {
602        let min_x = self.start.x.min(self.end.x);
603        let min_y = self.start.y.min(self.end.y);
604        Rect {
605            x: min_x,
606            y: min_y,
607            width: self.start.x.max(self.end.x) - min_x,
608            height: self.start.y.max(self.end.y) - min_y,
609        }
610    }
611
612    /// How far past the ends' box the stroke reaches on each axis: a round
613    /// end's disc bounds it by half the width every way; a butt or square
614    /// end's corners reach along the segment by the cap and across it by
615    /// half the width, which on a slant is more than half the width.
616    pub fn reach(&self) -> Point {
617        if self.cap == StrokeCap::Round {
618            return Point::new(self.half_width, self.half_width);
619        }
620        let frame = self.frame();
621        let (along_x, along_y) = (frame.direction.x.abs(), frame.direction.y.abs());
622        let cap = self.cap_reach();
623        Point::new(
624            along_x * cap + along_y * self.half_width,
625            along_y * cap + along_x * self.half_width,
626        )
627    }
628
629    /// The pixels the stroke can reach: the ends' box grown by
630    /// [`Self::reach`].
631    pub fn bounds(&self) -> Rect {
632        let ends = self.end_bounds();
633        let reach = self.reach();
634        Rect {
635            x: ends.x - reach.x,
636            y: ends.y - reach.y,
637            width: ends.width + reach.x * 2.0,
638            height: ends.height + reach.y * 2.0,
639        }
640    }
641
642    /// The same segment with its ends at `start` and `end` and its width
643    /// scaled by `scale`, as a layer's transform places it.
644    pub fn placed(&self, start: Point, end: Point, scale: f32) -> Self {
645        Self {
646            start,
647            end,
648            half_width: self.half_width * scale,
649            ..*self
650        }
651    }
652
653    /// The share of the pixel centred at `point` the stroke covers: exact
654    /// box coverage across the segment and along it, as a rect's edges are
655    /// covered, so a thin line keeps its weight at any sub-pixel offset;
656    /// past the ends of a round cap, the distance to the cap's disc.
657    /// `shape.wgsl` mirrors it.
658    pub fn coverage(&self, point: Point) -> f32 {
659        let frame = self.frame();
660        let (dx, dy) = (point.x - frame.center.x, point.y - frame.center.y);
661        let along = (dx * frame.direction.x + dy * frame.direction.y).abs();
662        let across = (dx * frame.direction.y - dy * frame.direction.x).abs();
663        let across_coverage = (self.half_width + 0.5 - across).clamp(0.0, 1.0);
664        if self.cap == StrokeCap::Round {
665            if along <= frame.half_length {
666                return across_coverage;
667            }
668            let (past, side) = (along - frame.half_length, across);
669            let distance = (past * past + side * side).sqrt() - self.half_width;
670            return (0.5 - distance).clamp(0.0, 1.0);
671        }
672        let reach = frame.half_length + self.cap_reach();
673        across_coverage * (reach + 0.5 - along).clamp(0.0, 1.0)
674    }
675}
676
677#[cfg(test)]
678#[path = "tests/stroke_tests.rs"]
679mod tests;