Skip to main content

emblema_geometry/
stroke.rs

1//! Stroke style.
2//!
3//! Stroking is offsetting a path by half the line width on both sides and
4//! resolving what happens at the ends and corners. The corner cases are where
5//! the visual bugs live: a miter join at a near-degenerate angle produces a
6//! spike that can extend arbitrarily far from the path, which is what the
7//! miter limit exists to bound.
8
9/// How a stroke terminates at an open end.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum LineCap {
12    /// Stop exactly at the endpoint.
13    #[default]
14    Butt,
15    /// Extend by a half-disc of radius `width / 2`.
16    Round,
17    /// Extend by a half-square of side `width / 2`.
18    Square,
19}
20
21/// How a stroke turns a corner.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum LineJoin {
24    /// Extend both outer edges until they meet, subject to the miter limit.
25    #[default]
26    Miter,
27    /// Fill the corner with a circular arc.
28    Round,
29    /// Cut the corner off with a straight edge.
30    Bevel,
31}
32
33/// A stroke's geometric parameters.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct StrokeStyle {
36    /// Full width of the stroke, not the half-width offset.
37    pub width: f32,
38    pub cap: LineCap,
39    pub join: LineJoin,
40    /// Maximum ratio of miter length to line width before a miter join
41    /// degrades to a bevel.
42    ///
43    /// As the angle between two segments approaches zero the miter length
44    /// grows without bound, so an unbounded miter would let a hairline path
45    /// paint an arbitrarily long spike. The default of 4 is the SVG and
46    /// PostScript convention; measured against this implementation, it
47    /// degrades to a bevel just below 29 degrees.
48    ///
49    /// Values below 2 all behave as 2, which bevels below 60 degrees. That
50    /// floor comes from the tessellation backend rather than from the
51    /// geometry, and such limits are rare enough in practice that matching
52    /// them exactly is not worth carrying a second join implementation.
53    pub miter_limit: f32,
54}
55
56impl Default for StrokeStyle {
57    fn default() -> Self {
58        Self {
59            width: 1.0,
60            cap: LineCap::default(),
61            join: LineJoin::default(),
62            miter_limit: 4.0,
63        }
64    }
65}
66
67impl StrokeStyle {
68    pub fn new(width: f32) -> Self {
69        Self {
70            width,
71            ..Default::default()
72        }
73    }
74
75    /// The same style at another width.
76    pub fn with_width(mut self, width: f32) -> Self {
77        self.width = width;
78        self
79    }
80
81    pub fn with_cap(mut self, cap: LineCap) -> Self {
82        self.cap = cap;
83        self
84    }
85
86    pub fn with_join(mut self, join: LineJoin) -> Self {
87        self.join = join;
88        self
89    }
90
91    pub fn with_miter_limit(mut self, limit: f32) -> Self {
92        self.miter_limit = limit;
93        self
94    }
95
96    /// Whether this style would produce any geometry at all.
97    ///
98    /// A non-positive or non-finite width strokes nothing. Treating that as
99    /// "draw nothing" rather than as an error matches how a caller animating a
100    /// width down to zero expects it to behave.
101    ///
102    /// Zero is excluded because the tessellator cannot build a stroke of no
103    /// width: it offsets the path by half of it and normalizes the result, which
104    /// for zero is a direction that does not exist. Asking it anyway put NaN in
105    /// a vertex buffer, which `a_stroked_path_of_any_shape_leaves_addressable_buffers`
106    /// caught. A caller's zero is a hairline and is widened before it arrives --
107    /// see [`Self::can_draw`], which is the question asked before the widening.
108    pub fn is_visible(&self) -> bool {
109        self.width > 0.0 && self.width.is_finite()
110    }
111
112    /// Whether a stroke of this width can put ink on a frame at all.
113    ///
114    /// Wider than [`Self::is_visible`] by exactly one value, and that value is
115    /// the whole reason both exist: `dart:ui` documents `Paint.strokeWidth` as
116    /// defaulting to zero and zero as "a hairline width", so a zero-width stroke
117    /// draws the thinnest line the device can. It cannot be tessellated at that
118    /// width, so a canvas widens it to a device pixel first and dims nothing to
119    /// pay for it, which is Impeller's rule.
120    ///
121    /// So this is the question to ask *before* the widening -- may this draw at
122    /// all -- and `is_visible` is the question to ask after, with a width the
123    /// tessellator can use.
124    pub fn can_draw(&self) -> bool {
125        self.width >= 0.0 && self.width.is_finite()
126    }
127
128    /// How far the stroke can extend beyond the path itself.
129    ///
130    /// Used to expand bounds for culling. A miter join reaches
131    /// `miter_limit * width / 2` at the limit, which is further than the
132    /// half-width the stroke reaches along a straight run.
133    pub fn max_extent(&self) -> f32 {
134        if !self.is_visible() {
135            return 0.0;
136        }
137        let half = self.width * 0.5;
138        match self.join {
139            LineJoin::Miter => half * self.miter_limit.max(1.0),
140            LineJoin::Round | LineJoin::Bevel => half,
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn defaults_match_the_svg_conventions() {
151        let s = StrokeStyle::default();
152        assert_eq!(s.cap, LineCap::Butt);
153        assert_eq!(s.join, LineJoin::Miter);
154        assert_eq!(s.miter_limit, 4.0);
155    }
156
157    #[test]
158    fn non_positive_and_non_finite_widths_are_invisible() {
159        // An animation driving width to zero should stop drawing, not error.
160        assert!(!StrokeStyle::new(0.0).is_visible());
161        assert!(!StrokeStyle::new(-1.0).is_visible());
162        // Zero may draw, though, and is widened before it reaches geometry:
163        // it is `dart:ui`'s default and means a hairline.
164        assert!(StrokeStyle::new(0.0).can_draw());
165        assert!(!StrokeStyle::new(-1.0).can_draw());
166        assert!(!StrokeStyle::new(f32::NAN).can_draw());
167        assert!(!StrokeStyle::new(f32::NAN).is_visible());
168        assert!(!StrokeStyle::new(f32::INFINITY).is_visible());
169        assert!(StrokeStyle::new(0.5).is_visible());
170    }
171
172    #[test]
173    fn miter_joins_reach_further_than_the_half_width() {
174        let w = 4.0;
175        let miter = StrokeStyle::new(w).with_join(LineJoin::Miter);
176        // A miter spike reaches miter_limit * half_width, so culling bounds
177        // computed from the half-width alone would clip it.
178        assert_eq!(miter.max_extent(), 2.0 * 4.0);
179
180        for join in [LineJoin::Round, LineJoin::Bevel] {
181            assert_eq!(StrokeStyle::new(w).with_join(join).max_extent(), 2.0);
182        }
183    }
184
185    #[test]
186    fn a_miter_limit_below_one_cannot_shrink_the_extent() {
187        // The join can never pull inside the stroke's own half-width, so a
188        // nonsensical limit must not underestimate the bounds.
189        let s = StrokeStyle::new(4.0).with_miter_limit(0.1);
190        assert_eq!(s.max_extent(), 2.0);
191    }
192
193    #[test]
194    fn invisible_strokes_have_no_extent() {
195        assert_eq!(StrokeStyle::new(0.0).max_extent(), 0.0);
196        assert_eq!(StrokeStyle::new(f32::NAN).max_extent(), 0.0);
197    }
198}