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::{Stroke, StrokeCap, StrokeJoin};
7
8/// Resolves a stroke width as pixels.
9pub fn stroke_width_pixels(stroke: &Stroke) -> f64 {
10    stroke.width.get().expect_pixels().to_float()
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: &Stroke) -> 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.get()))
27            .with_miter_limit(4.0)
28            .with_caps(to_kurbo_cap(stroke.cap.get())),
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(centerline: &BezPath, stroke: &Stroke, width: f64) -> Option<BezPath> {
60    let center = collapsed_path_point(centerline)?;
61    let half_width = width / 2.0;
62
63    match stroke.cap.get() {
64        StrokeCap::Butt => None,
65        StrokeCap::Round => Some(Circle::new(center, half_width).to_path(0.1)),
66        StrokeCap::Square => Some(
67            Rect::new(
68                center.x - half_width,
69                center.y - half_width,
70                center.x + half_width,
71                center.y + half_width,
72            )
73            .to_path(0.1),
74        ),
75    }
76}
77
78fn collapsed_path_point(centerline: &BezPath) -> Option<Point> {
79    let mut anchor = None;
80
81    // Only treat the path as degenerate when every point-bearing element
82    // collapses to the same location.
83    for element in centerline.elements() {
84        let points = match *element {
85            PathEl::MoveTo(point) | PathEl::LineTo(point) => [Some(point), None, None],
86            PathEl::QuadTo(point1, point2) => [Some(point1), Some(point2), None],
87            PathEl::CurveTo(point1, point2, point3) => [Some(point1), Some(point2), Some(point3)],
88            PathEl::ClosePath => [None, None, None],
89        };
90
91        for point in points.into_iter().flatten() {
92            match anchor {
93                Some(existing) if !same_point(existing, point) => return None,
94                Some(_) => {}
95                None => anchor = Some(point),
96            }
97        }
98    }
99
100    anchor
101}
102
103fn same_point(a: Point, b: Point) -> bool {
104    (a.x - b.x).abs() <= f64::EPSILON && (a.y - b.y).abs() <= f64::EPSILON
105}