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