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}