Skip to main content

alpha2_geometry/
stroke.rs

1//! Stroke expansion: a stroke becomes the outline it covers, filled with
2//! the nonzero rule like any fill.
3
4use alpha2_model::{LineCap, LineJoin};
5use kurbo::{Affine, BezPath, Cap, Join, Stroke, StrokeOpts};
6
7/// How far kurbo's outline may stray from the exact stroke, in device
8/// pixels.
9const STROKE_TOLERANCE: f64 = 0.05;
10
11/// Most times a dash pattern repeats along a path before the stroke is drawn
12/// solid instead.
13const MAX_DASH_REPEATS: f64 = 100_000.0;
14
15/// The length of a path's control polygon: at least its arc length.
16fn control_length(path: &BezPath) -> f64 {
17    let mut total = 0.0;
18    let mut last = kurbo::Point::ZERO;
19    let mut start = kurbo::Point::ZERO;
20    for element in path.elements() {
21        let points: &[kurbo::Point] = match element {
22            kurbo::PathEl::MoveTo(p) => {
23                last = *p;
24                start = *p;
25                continue;
26            }
27            kurbo::PathEl::LineTo(p) => std::slice::from_ref(p),
28            kurbo::PathEl::QuadTo(a, b) => &[*a, *b],
29            kurbo::PathEl::CurveTo(a, b, c) => &[*a, *b, *c],
30            kurbo::PathEl::ClosePath => std::slice::from_ref(&start),
31        };
32        for point in points {
33            total += crate::affine::distance(last, *point);
34            last = *point;
35        }
36    }
37    total
38}
39
40/// A stroke's style, in the pixels of the space it is drawn in.
41#[derive(Clone, Debug, PartialEq)]
42pub struct StrokeStyle {
43    /// Full width.
44    pub width: f64,
45    /// How segments meet.
46    pub join: LineJoin,
47    /// Longest a miter reaches, as a multiple of half the width.
48    pub miter_limit: f64,
49    /// How open ends look.
50    pub cap: LineCap,
51    /// Alternating dash and gap lengths; solid when empty or all zero.
52    pub dash: Vec<f64>,
53    /// How far into the dash pattern the stroke starts.
54    pub dash_offset: f64,
55}
56
57/// The outline `style` covers along `path`, both in the space `to_device`
58/// takes to device pixels, there. The pen is round in `path`'s space, so a
59/// non-uniform transform makes it an ellipse, as After Effects draws it.
60/// Empty for a width of zero or less.
61#[must_use]
62pub fn stroke_outline(path: &BezPath, style: &StrokeStyle, to_device: Affine) -> BezPath {
63    if style.width.is_nan() || style.width <= 0.0 || path.elements().is_empty() {
64        return BezPath::new();
65    }
66    let join = match style.join {
67        LineJoin::Miter => Join::Miter,
68        LineJoin::Round => Join::Round,
69        LineJoin::Bevel => Join::Bevel,
70    };
71    let cap = match style.cap {
72        LineCap::Butt => Cap::Butt,
73        LineCap::Round => Cap::Round,
74        LineCap::Square => Cap::Square,
75    };
76    let mut stroke = Stroke::new(style.width)
77        .with_join(join)
78        .with_miter_limit(style.miter_limit.max(1.0))
79        .with_caps(cap);
80    let pattern_length: f64 = style.dash.iter().sum();
81    // A pattern far shorter than the path would cut it into more dashes than any frame can
82    // hold; past this many repeats it is drawn solid.
83    let repeats = control_length(path) / pattern_length;
84    if pattern_length > 0.0 && repeats <= MAX_DASH_REPEATS {
85        // An odd pattern repeats once more to pair dashes with gaps, as SVG reads it.
86        let mut pattern = style.dash.clone();
87        if pattern.len() % 2 == 1 {
88            pattern.extend_from_slice(&style.dash);
89        }
90        stroke = stroke.with_dashes(style.dash_offset, pattern);
91    }
92    // The tolerance is in device pixels: the path's space is scaled up by at most this.
93    let scale = crate::affine::max_scale(to_device).max(1e-9);
94    let outline = kurbo::stroke(
95        path.iter(),
96        &stroke,
97        &StrokeOpts::default(),
98        STROKE_TOLERANCE / scale,
99    );
100    to_device * outline
101}