Skip to main content

pax_runtime_api/drawing/
stroke_utils.rs

1use kurbo::{
2    stroke as kurbo_stroke, BezPath, Cap as KurboCap, Circle, Join as KurboJoin, PathEl, Point,
3    Rect, Shape, Stroke as KurboStroke, StrokeOpts as KurboStrokeOpts,
4};
5
6use super::{ResolvedStroke, StrokeCap, StrokeJoin};
7
8/// Resolves a stroke width as pixels.
9pub fn stroke_width_pixels(stroke: &ResolvedStroke) -> f64 {
10    stroke.width_pixels()
11}
12
13/// Builds the filled outline path corresponding to a stroked centerline path.
14///
15/// This is useful for hit testing, masking, and occlusion, where stroke coverage
16/// needs to be treated as fill geometry.
17pub fn stroked_outline_path(centerline: &BezPath, stroke: &ResolvedStroke) -> Option<BezPath> {
18    let width = stroke_width_pixels(stroke);
19    if width <= f64::EPSILON {
20        return None;
21    }
22
23    let outline = kurbo_stroke(
24        centerline.elements().iter().copied(),
25        &KurboStroke::new(width)
26            .with_join(to_kurbo_join(stroke.join.clone()))
27            .with_miter_limit(4.0)
28            .with_caps(to_kurbo_cap(stroke.cap.clone())),
29        &KurboStrokeOpts::default(),
30        0.1,
31    );
32
33    if !outline.elements().is_empty() {
34        Some(outline)
35    } else {
36        // kurbo intentionally produces no outline for a fully collapsed path.
37        // Pax still needs cap-shaped coverage for zero-length lines so that
38        // masking and hit testing remain consistent with rendering.
39        degenerate_outline_path(centerline, stroke, width)
40    }
41}
42
43fn to_kurbo_cap(cap: StrokeCap) -> KurboCap {
44    match cap {
45        StrokeCap::Butt => KurboCap::Butt,
46        StrokeCap::Round => KurboCap::Round,
47        StrokeCap::Square => KurboCap::Square,
48    }
49}
50
51fn to_kurbo_join(join: StrokeJoin) -> KurboJoin {
52    match join {
53        StrokeJoin::Miter => KurboJoin::Miter,
54        StrokeJoin::Round => KurboJoin::Round,
55        StrokeJoin::Bevel => KurboJoin::Bevel,
56    }
57}
58
59fn degenerate_outline_path(
60    centerline: &BezPath,
61    stroke: &ResolvedStroke,
62    width: f64,
63) -> Option<BezPath> {
64    let center = collapsed_path_point(centerline)?;
65    let half_width = width / 2.0;
66
67    match stroke.cap {
68        StrokeCap::Butt => None,
69        StrokeCap::Round => Some(Circle::new(center, half_width).to_path(0.1)),
70        StrokeCap::Square => Some(
71            Rect::new(
72                center.x - half_width,
73                center.y - half_width,
74                center.x + half_width,
75                center.y + half_width,
76            )
77            .to_path(0.1),
78        ),
79    }
80}
81
82fn collapsed_path_point(centerline: &BezPath) -> Option<Point> {
83    let mut anchor = None;
84
85    // Only treat the path as degenerate when every point-bearing element
86    // collapses to the same location.
87    for element in centerline.elements() {
88        let points = match *element {
89            PathEl::MoveTo(point) | PathEl::LineTo(point) => [Some(point), None, None],
90            PathEl::QuadTo(point1, point2) => [Some(point1), Some(point2), None],
91            PathEl::CurveTo(point1, point2, point3) => [Some(point1), Some(point2), Some(point3)],
92            PathEl::ClosePath => [None, None, None],
93        };
94
95        for point in points.into_iter().flatten() {
96            match anchor {
97                Some(existing) if !same_point(existing, point) => return None,
98                Some(_) => {}
99                None => anchor = Some(point),
100            }
101        }
102    }
103
104    anchor
105}
106
107fn same_point(a: Point, b: Point) -> bool {
108    (a.x - b.x).abs() <= f64::EPSILON && (a.y - b.y).abs() <= f64::EPSILON
109}