diff --git a/render-wasm/src/shapes/stroke_paths.rs b/render-wasm/src/shapes/stroke_paths.rs index 358be86141..64626efe00 100644 --- a/render-wasm/src/shapes/stroke_paths.rs +++ b/render-wasm/src/shapes/stroke_paths.rs @@ -1,19 +1,40 @@ +use bezier_rs::{Bezier, BezierHandles, Cap, Identifier, Join, Subpath}; use skia_safe::{self as skia}; use super::paths::Path; -use super::strokes::{Stroke, StrokeKind}; +use super::strokes::{Stroke, StrokeKind, StrokeStyle}; use super::svg_attrs::SvgAttrs; +use super::{StrokeLineCap, StrokeLineJoin}; +use crate::math::bools::path_to_beziers; use crate::math::Rect; +/// Lightweight manipulator-group id for transient bezier-rs subpaths. We never +/// track manipulator identity here, so a zero-sized id is enough. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct StrokeId; + +impl Identifier for StrokeId { + fn new() -> Self { + Self + } +} + /// Converts a stroke into a filled path outline. /// -/// Uses Skia's `fill_path_with_paint` to expand the stroke into a filled region, -/// then clips it via boolean ops for inner/outer alignment. The optional -/// `path_transform` maps from local shape coords to the drawing space (and back). +/// For solid strokes (and whenever `solid_outline` is requested) the outline is +/// computed analytically with bezier-rs (`Subpath::outline`), which produces a +/// path with the minimum number of curve nodes — see issue #10002. Dashed/dotted +/// strokes fall back to Skia's `fill_path_with_paint`, which is required to apply +/// the dash `PathEffect`. /// -/// When `solid_outline` is true, any dash/dot PathEffect is stripped so the result -/// is a continuous stroke region — useful for clipping (e.g. drag crop cache) where -/// dash gaps should not punch holes in the clip mask. +/// For inner/outer alignment the (2×-width) outline is clipped against the +/// original shape via boolean ops. This is orientation-independent and avoids the +/// winding-sign pitfalls of directly offsetting the centerline. +/// +/// The optional `path_transform` maps from local shape coords to the drawing +/// space (and back). When `solid_outline` is true, any dash/dot effect is +/// dropped so the result is a continuous region — useful for clipping (e.g. drag +/// crop cache) where dash gaps should not punch holes in the clip mask. pub fn stroke_to_path( stroke: &Stroke, shape_path: &Path, @@ -22,44 +43,37 @@ pub fn stroke_to_path( svg_attrs: Option<&SvgAttrs>, solid_outline: bool, ) -> Option { - let skia_shape_path = shape_path.to_skia_path(svg_attrs); + let is_open = shape_path.is_open(); + let render_kind = stroke.render_kind(is_open); + let skia_shape_path = shape_path.to_skia_path(svg_attrs); let transformed_shape_path = if let Some(pt) = path_transform { skia_shape_path.make_transform(pt) } else { skia_shape_path.clone() }; - let is_open = shape_path.is_open(); - let mut paint = stroke.to_paint(selrect, svg_attrs, true); + // Solid strokes use the analytic bezier-rs outline (clean, minimal nodes). + // Dashed/dotted strokes need Skia's stroker to apply the dash effect. + let use_bezier_outline = solid_outline || stroke.style == StrokeStyle::Solid; - if solid_outline { - paint.set_path_effect(None); - } + let mut stroke_outline = if use_bezier_outline { + bezier_outline(stroke, shape_path, path_transform, render_kind, svg_attrs)? + } else { + skia_outline( + stroke, + &transformed_shape_path, + selrect, + svg_attrs, + render_kind, + solid_outline, + )? + }; - let render_kind = stroke.render_kind(is_open); - if render_kind != StrokeKind::Center { - paint.set_stroke_width(stroke.width * 2.0); - } - - let mut stroke_outline = skia::Path::default(); - let success = skia::path_utils::fill_path_with_paint( - &transformed_shape_path, - &paint, - &mut stroke_outline, - None, - None, - ); - - if !success { - return None; - } - - // For inner/outer strokes, use boolean ops to clip - // the 2×-width stroke outline to the correct region. - // Set EvenOdd to preserve the annular ring's inner hole, - // then as_winding() on the result fixes contour winding - // for Penpot's NonZero fill rule. + // For inner/outer strokes, use boolean ops to clip the 2×-width stroke + // outline to the correct region. Set EvenOdd to preserve the annular ring's + // inner hole, then as_winding() on the result fixes contour winding for + // Penpot's NonZero fill rule. let final_path = match render_kind { StrokeKind::Inner => { stroke_outline.set_fill_type(skia::PathFillType::EvenOdd); @@ -81,7 +95,7 @@ pub fn stroke_to_path( } }; - // If there was a path_transform, invert it back to local coords + // If there was a path_transform, invert it back to local coords. let final_path = if let Some(pt) = path_transform { if let Some(inv) = pt.invert() { final_path.make_transform(&inv) @@ -94,3 +108,134 @@ pub fn stroke_to_path( Some(Path::from_skia_path_accurate(final_path)) } + +/// Analytic stroke outline via bezier-rs. Returns the (2×-width for inner/outer, +/// 1×-width for center) stroke region in drawing space, EvenOdd-filled. +fn bezier_outline( + stroke: &Stroke, + shape_path: &Path, + path_transform: Option<&skia::Matrix>, + render_kind: StrokeKind, + svg_attrs: Option<&SvgAttrs>, +) -> Option { + // Build beziers in drawing space. + let mut local = shape_path.clone(); + if let Some(pt) = path_transform { + local.transform(pt); + } + let beziers = path_to_beziers(&local); + if beziers.is_empty() { + return None; + } + + let closed = !shape_path.is_open() && beziers.len() > 1; + let subpath = Subpath::::from_beziers(&beziers, closed); + + // Center alignment paints ±width/2 around the centerline. Inner/outer paint + // the full 2×-width band (±width), then get clipped to the correct half. + let distance = match render_kind { + StrokeKind::Center => (stroke.width / 2.0) as f64, + StrokeKind::Inner | StrokeKind::Outer => stroke.width as f64, + }; + + let join = map_join(svg_attrs); + let cap = map_cap(svg_attrs); + let (pos, neg) = subpath.outline(distance, join, cap); + + let mut pb = skia::PathBuilder::new(); + append_subpath(&mut pb, &pos); + if let Some(neg) = neg { + // Closed shapes yield an inner contour that punches the hole. + append_subpath(&mut pb, &neg); + } + let mut out = pb.detach(); + out.set_fill_type(skia::PathFillType::EvenOdd); + Some(out) +} + +/// Skia stroker fallback (preserves dash/dot effects). +fn skia_outline( + stroke: &Stroke, + transformed_shape_path: &skia::Path, + selrect: &Rect, + svg_attrs: Option<&SvgAttrs>, + render_kind: StrokeKind, + solid_outline: bool, +) -> Option { + let mut paint = stroke.to_paint(selrect, svg_attrs, true); + + if solid_outline { + paint.set_path_effect(None); + } + + if render_kind != StrokeKind::Center { + paint.set_stroke_width(stroke.width * 2.0); + } + + let mut stroke_outline = skia::Path::default(); + let success = skia::path_utils::fill_path_with_paint( + transformed_shape_path, + &paint, + &mut stroke_outline, + None, + None, + ); + + if !success { + return None; + } + Some(stroke_outline) +} + +/// Append a (closed) bezier-rs subpath to a Skia path builder, preserving curves. +fn append_subpath(pb: &mut skia::PathBuilder, subpath: &Subpath) { + let beziers: Vec = subpath.iter().collect(); + let Some(first) = beziers.first() else { + return; + }; + pb.move_to(to_point(first.start)); + for b in &beziers { + match b.handles { + BezierHandles::Linear => { + pb.line_to(to_point(b.end)); + } + BezierHandles::Quadratic { handle } => { + // Elevate the quadratic to a cubic (PathBuilder has no quad_to). + let s = b.start; + let e = b.end; + let c1 = s + (2.0 / 3.0) * (handle - s); + let c2 = e + (2.0 / 3.0) * (handle - e); + pb.cubic_to(to_point(c1), to_point(c2), to_point(e)); + } + BezierHandles::Cubic { + handle_start, + handle_end, + } => { + pb.cubic_to(to_point(handle_start), to_point(handle_end), to_point(b.end)); + } + } + } + pb.close(); +} + +#[inline] +fn to_point(p: glam::DVec2) -> skia::Point { + skia::Point::new(p.x as f32, p.y as f32) +} + +fn map_join(svg_attrs: Option<&SvgAttrs>) -> Join { + match svg_attrs.map(|a| a.stroke_linejoin) { + Some(StrokeLineJoin::Round) => Join::Round, + Some(StrokeLineJoin::Bevel) => Join::Bevel, + // Miter limit defaults to 4 (SVG default) when None. + Some(StrokeLineJoin::Miter) | None => Join::Miter(None), + } +} + +fn map_cap(svg_attrs: Option<&SvgAttrs>) -> Cap { + match svg_attrs.map(|a| a.stroke_linecap) { + Some(StrokeLineCap::Round) => Cap::Round, + Some(StrokeLineCap::Square) => Cap::Square, + Some(StrokeLineCap::Butt) | None => Cap::Butt, + } +}