pax-runtime-api 0.39.1

Userland constructs used at the runtime API boundary of Pax Engine
Documentation
use kurbo::{
    stroke as kurbo_stroke, BezPath, Cap as KurboCap, Circle, Join as KurboJoin, PathEl, Point,
    Rect, Shape, Stroke as KurboStroke, StrokeOpts as KurboStrokeOpts,
};

use super::{ResolvedStroke, StrokeCap, StrokeJoin};

/// Resolves a stroke width as pixels.
pub fn stroke_width_pixels(stroke: &ResolvedStroke) -> f64 {
    stroke.width_pixels()
}

/// Builds the filled outline path corresponding to a stroked centerline path.
///
/// This is useful for hit testing, masking, and occlusion, where stroke coverage
/// needs to be treated as fill geometry.
pub fn stroked_outline_path(centerline: &BezPath, stroke: &ResolvedStroke) -> Option<BezPath> {
    let width = stroke_width_pixels(stroke);
    if width <= f64::EPSILON {
        return None;
    }

    let outline = kurbo_stroke(
        centerline.elements().iter().copied(),
        &KurboStroke::new(width)
            .with_join(to_kurbo_join(stroke.join.clone()))
            .with_miter_limit(4.0)
            .with_caps(to_kurbo_cap(stroke.cap.clone())),
        &KurboStrokeOpts::default(),
        0.1,
    );

    if !outline.elements().is_empty() {
        Some(outline)
    } else {
        // kurbo intentionally produces no outline for a fully collapsed path.
        // Pax still needs cap-shaped coverage for zero-length lines so that
        // masking and hit testing remain consistent with rendering.
        degenerate_outline_path(centerline, stroke, width)
    }
}

fn to_kurbo_cap(cap: StrokeCap) -> KurboCap {
    match cap {
        StrokeCap::Butt => KurboCap::Butt,
        StrokeCap::Round => KurboCap::Round,
        StrokeCap::Square => KurboCap::Square,
    }
}

fn to_kurbo_join(join: StrokeJoin) -> KurboJoin {
    match join {
        StrokeJoin::Miter => KurboJoin::Miter,
        StrokeJoin::Round => KurboJoin::Round,
        StrokeJoin::Bevel => KurboJoin::Bevel,
    }
}

fn degenerate_outline_path(
    centerline: &BezPath,
    stroke: &ResolvedStroke,
    width: f64,
) -> Option<BezPath> {
    let center = collapsed_path_point(centerline)?;
    let half_width = width / 2.0;

    match stroke.cap {
        StrokeCap::Butt => None,
        StrokeCap::Round => Some(Circle::new(center, half_width).to_path(0.1)),
        StrokeCap::Square => Some(
            Rect::new(
                center.x - half_width,
                center.y - half_width,
                center.x + half_width,
                center.y + half_width,
            )
            .to_path(0.1),
        ),
    }
}

fn collapsed_path_point(centerline: &BezPath) -> Option<Point> {
    let mut anchor = None;

    // Only treat the path as degenerate when every point-bearing element
    // collapses to the same location.
    for element in centerline.elements() {
        let points = match *element {
            PathEl::MoveTo(point) | PathEl::LineTo(point) => [Some(point), None, None],
            PathEl::QuadTo(point1, point2) => [Some(point1), Some(point2), None],
            PathEl::CurveTo(point1, point2, point3) => [Some(point1), Some(point2), Some(point3)],
            PathEl::ClosePath => [None, None, None],
        };

        for point in points.into_iter().flatten() {
            match anchor {
                Some(existing) if !same_point(existing, point) => return None,
                Some(_) => {}
                None => anchor = Some(point),
            }
        }
    }

    anchor
}

fn same_point(a: Point, b: Point) -> bool {
    (a.x - b.x).abs() <= f64::EPSILON && (a.y - b.y).abs() <= f64::EPSILON
}