Skip to main content

alpha2_geometry/
stroke.rs

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