diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs
index b4ac78eb05..2ecf5b7bb1 100644
--- a/render-wasm/src/render/strokes.rs
+++ b/render-wasm/src/render/strokes.rs
@@ -1,8 +1,8 @@
use crate::math::{Matrix, Point, Rect};
use crate::shapes::{
- merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs,
- Type,
+ arrow_cap_path, merge_fills, square_cap_path, triangle_cap_path, Corners, Fill, ImageFill,
+ Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type,
};
use skia_safe::{self as skia, ImageFilter, RRect};
@@ -445,40 +445,10 @@ fn draw_square_cap(
size: f32,
extra_rotation: f32,
) {
- let dx = direction.x - center.x;
- let dy = direction.y - center.y;
- let angle = dy.atan2(dx);
-
- let mut matrix = Matrix::new_identity();
- matrix.pre_rotate(
- angle.to_degrees() + extra_rotation,
- Point::new(center.x, center.y),
+ canvas.draw_path(
+ &square_cap_path(center, direction, size, extra_rotation),
+ paint,
);
-
- let half_size = size / 2.0;
- let rect = Rect::from_xywh(center.x - half_size, center.y - half_size, size, size);
-
- let points = [
- Point::new(rect.left(), rect.top()),
- Point::new(rect.right(), rect.top()),
- Point::new(rect.right(), rect.bottom()),
- Point::new(rect.left(), rect.bottom()),
- ];
-
- let mut transformed_points = points;
- matrix.map_points(&mut transformed_points, &points);
-
- let path = {
- let mut pb = skia::PathBuilder::new();
- pb.move_to(Point::new(center.x, center.y));
- pb.move_to(transformed_points[0]);
- pb.line_to(transformed_points[1]);
- pb.line_to(transformed_points[2]);
- pb.line_to(transformed_points[3]);
- pb.close();
- pb.detach()
- };
- canvas.draw_path(&path, paint);
}
fn draw_arrow_cap(
@@ -488,33 +458,7 @@ fn draw_arrow_cap(
direction: &Point,
size: f32,
) {
- let dx = direction.x - center.x;
- let dy = direction.y - center.y;
- let angle = dy.atan2(dx);
-
- let mut matrix = Matrix::new_identity();
- matrix.pre_rotate(angle.to_degrees() - 90., Point::new(center.x, center.y));
-
- let half_height = size / 2.;
- let points = [
- Point::new(center.x, center.y - half_height),
- Point::new(center.x - size, center.y + half_height),
- Point::new(center.x + size, center.y + half_height),
- ];
-
- let mut transformed_points = points;
- matrix.map_points(&mut transformed_points, &points);
-
- let path = {
- let mut pb = skia::PathBuilder::new();
- pb.move_to(transformed_points[1]);
- pb.line_to(transformed_points[0]);
- pb.line_to(transformed_points[2]);
- pb.move_to(Point::new(center.x, center.y));
- pb.line_to(transformed_points[0]);
- pb.detach()
- };
- canvas.draw_path(&path, paint);
+ canvas.draw_path(&arrow_cap_path(center, direction, size), paint);
}
fn draw_triangle_cap(
@@ -524,32 +468,7 @@ fn draw_triangle_cap(
direction: &Point,
size: f32,
) {
- let dx = direction.x - center.x;
- let dy = direction.y - center.y;
- let angle = dy.atan2(dx);
-
- let mut matrix = Matrix::new_identity();
- matrix.pre_rotate(angle.to_degrees() - 90., Point::new(center.x, center.y));
-
- let half_height = size / 2.;
- let points = [
- Point::new(center.x, center.y - half_height),
- Point::new(center.x - size, center.y + half_height),
- Point::new(center.x + size, center.y + half_height),
- ];
-
- let mut transformed_points = points;
- matrix.map_points(&mut transformed_points, &points);
-
- let path = {
- let mut pb = skia::PathBuilder::new();
- pb.move_to(transformed_points[0]);
- pb.line_to(transformed_points[1]);
- pb.line_to(transformed_points[2]);
- pb.close();
- pb.detach()
- };
- canvas.draw_path(&path, paint);
+ canvas.draw_path(&triangle_cap_path(center, direction, size), paint);
}
fn draw_image_stroke_in_container(
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap
index d6fa94869e..ea1fa9ded2 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap
index 180801df6e..08a1bb1ff0 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap
index afc467606b..857a9d987c 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap
index b25f12faa4..384c3f82c0 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap
index 375278612c..45a58f753b 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap
index 8977724407..4187d2a96b 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap
@@ -1,11 +1,8 @@
---
source: src/render/svg/tests.rs
-assertion_line: 744
expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap
index a9a4c6b760..cce294b100 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap
@@ -4,7 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap
index 82c042efce..ea62ae42aa 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap
index 94e23b3876..35160286eb 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap
index bf0ba3795e..ec6297ae36 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap
index b71a5e4ebc..738ecd9e7d 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap
@@ -4,5 +4,5 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs
index 06a9dcea7f..c15ca03048 100644
--- a/render-wasm/src/render/svg/tests.rs
+++ b/render-wasm/src/render/svg/tests.rs
@@ -1789,7 +1789,7 @@ fn exports_open_path_with_dotted_center_stroke() {
fn exports_open_path_with_dotted_stroke_and_caps() {
// Regression: dotted/dashed SVG expansion used stroke_to_path and returned
// before draw_stroke_geometry, so open-path caps (triangle/circle/…) were
- // dropped. Caps must be overlaid after the expanded outline.
+ // dropped. `stroke_to_path` now unions them into the expanded outline.
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dotted_stroke(
@@ -1806,10 +1806,13 @@ fn exports_open_path_with_dotted_stroke_and_caps() {
svg.contains("fill=\"#1040FF\"") || svg.to_ascii_lowercase().contains("fill=\"#1040ff\""),
"dotted stroke with caps must emit filled geometry: {svg}"
);
- // Caps are separate filled draws (triangle + circle), not only the dotted outline.
- assert!(
- svg.matches("= 2 || svg.contains("").next())
.expect("imgstroke clipPath");
- assert!(
- clip.matches("= 2
- || clip.contains(" b
paint.set_anti_alias(true);
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
- // Expanded dotted/dashed strokes skip `draw_stroke_geometry`, which is
- // where open-path caps are drawn. Overlay them here in local path space
- // (same as fills / the outline above under the leaf CTM).
- if is_open {
- paint_svg_stroke_caps(canvas, shape, stroke, false);
- }
+ // Caps are already part of the outline (`stroke_to_path` unions them in),
+ // so they must not be overlaid again: a second draw would double the
+ // alpha of translucent strokes.
true
}
@@ -1212,8 +1209,6 @@ pub(super) fn paint_svg_stroke_silhouette(
stroke: &Stroke,
scale: f32,
) -> bool {
- let is_open = shape.is_open();
-
if stroke.per_side_widths().is_some()
&& matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_))
{
@@ -1258,26 +1253,9 @@ pub(super) fn paint_svg_stroke_silhouette(
paint.set_color(skia::Color::BLACK);
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
- if is_open {
- paint_svg_stroke_caps(canvas, shape, stroke, true);
- }
-
true
}
-fn paint_svg_stroke_caps(canvas: &Canvas, shape: &Shape, stroke: &Stroke, opaque: bool) {
- let Some(cap_path) = transformed_skia_path(shape) else {
- return;
- };
- let mut cap_paint =
- stroke.to_stroked_paint(true, &shape.selrect, shape.svg_attrs.as_ref(), true);
- if opaque {
- cap_paint.set_shader(None);
- cap_paint.set_color(skia::Color::BLACK);
- }
- super::strokes::handle_stroke_caps(&cap_path, stroke, canvas, true, &cap_paint, None, true);
-}
-
/// Draws a stroke's geometry by shape type, kind and dash style. Rect/Circle
/// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width
/// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette.
diff --git a/render-wasm/src/shapes/paths.rs b/render-wasm/src/shapes/paths.rs
index f63b732f15..61dec54972 100644
--- a/render-wasm/src/shapes/paths.rs
+++ b/render-wasm/src/shapes/paths.rs
@@ -196,9 +196,12 @@ impl Path {
let w = conic_weights[current_conic];
current_conic += 1;
- // pow2=0: 1 quad per conic. A circle (4 conics) becomes
- // 4 cubics, matching the standard bezier approximation.
- const POW2: usize = 0;
+ // pow2=2: 4 quads per conic, so a circle (4 conics)
+ // becomes 16 cubics and stays within ~0.03% of the real
+ // radius. One quad per conic is off by ~6% at the arc
+ // midpoint, which makes round caps and circle markers
+ // look like squircles once converted to a path.
+ const POW2: usize = 2;
let quad_count = 1 << POW2;
let pts_count = 1 + 2 * quad_count;
let mut quad_pts = vec![skia::Point::default(); pts_count];
@@ -355,3 +358,36 @@ impl Path {
math::Bounds::from_rect(self.skia_path.bounds())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Samples a converted circle and returns its largest radius error.
+ fn circle_conversion_error(radius: f32) -> f32 {
+ let center = skia::Point::new(0., 0.);
+ let converted = Path::from_skia_path_accurate(skia::Path::circle(center, radius, None))
+ .to_skia_path(None);
+
+ let mut measure = skia::PathMeasure::new(&converted, false, None);
+ let length = measure.length();
+ (0..64)
+ .filter_map(|i| measure.pos_tan(length * i as f32 / 64.))
+ .map(|(p, _)| (p.length() - radius).abs())
+ .fold(0., f32::max)
+ }
+
+ #[test]
+ fn converts_conics_to_accurate_circles() {
+ // Regression: one quad per conic left circles about 6% off the real
+ // radius at the arc midpoints, which showed up as squircle stroke caps.
+ for radius in [2., 40., 500.] {
+ let error = circle_conversion_error(radius);
+ assert!(
+ error < radius * 0.001,
+ "radius {radius}: off by {error}, expected under {}",
+ radius * 0.001
+ );
+ }
+ }
+}
diff --git a/render-wasm/src/shapes/stroke_paths.rs b/render-wasm/src/shapes/stroke_paths.rs
index cbf06874db..a38ba96fb1 100644
--- a/render-wasm/src/shapes/stroke_paths.rs
+++ b/render-wasm/src/shapes/stroke_paths.rs
@@ -1,9 +1,9 @@
use skia_safe::{self as skia};
use super::paths::Path;
-use super::strokes::{Stroke, StrokeKind};
+use super::strokes::{Stroke, StrokeCap, StrokeKind};
use super::svg_attrs::SvgAttrs;
-use crate::math::Rect;
+use crate::math::{Matrix, Point, Rect};
/// Converts a stroke into a filled path outline.
///
@@ -42,6 +42,12 @@ pub fn stroke_to_path(
paint.set_stroke_width(stroke.width * 2.0);
}
+ // Round/Round and Square/Square caps are drawn natively by Skia; the rest
+ // are added below as extra geometry.
+ if let Some(cap) = stroke.to_skia_linecap() {
+ paint.set_stroke_cap(cap);
+ }
+
let mut stroke_outline = skia::Path::default();
let success = skia::path_utils::fill_path_with_paint(
&transformed_shape_path,
@@ -78,6 +84,18 @@ pub fn stroke_to_path(
StrokeKind::Center => stroke_outline.simplify().unwrap_or(stroke_outline),
};
+ // Markers and arrow heads are painted on top of the stroke by
+ // `handle_stroke_caps`, so they are not part of the outline above.
+ let final_path = match is_open
+ .then(|| stroke_caps_to_path(&transformed_shape_path, stroke))
+ .flatten()
+ {
+ Some(caps) => final_path
+ .op(&caps, skia::PathOp::Union)
+ .unwrap_or(final_path),
+ None => final_path,
+ };
+
// 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() {
@@ -91,3 +109,318 @@ pub fn stroke_to_path(
Some(Path::from_skia_path_accurate(final_path))
}
+
+/// Builds the square/diamond cap quad centered on `center`, rotated to look
+/// towards `direction` plus `extra_rotation` degrees.
+pub fn square_cap_path(
+ center: &Point,
+ direction: &Point,
+ size: f32,
+ extra_rotation: f32,
+) -> skia::Path {
+ let angle = (direction.y - center.y).atan2(direction.x - center.x);
+
+ let mut matrix = Matrix::new_identity();
+ matrix.pre_rotate(
+ angle.to_degrees() + extra_rotation,
+ Point::new(center.x, center.y),
+ );
+
+ let half_size = size / 2.0;
+ let rect = Rect::from_xywh(center.x - half_size, center.y - half_size, size, size);
+
+ let points = [
+ Point::new(rect.left(), rect.top()),
+ Point::new(rect.right(), rect.top()),
+ Point::new(rect.right(), rect.bottom()),
+ Point::new(rect.left(), rect.bottom()),
+ ];
+
+ let mut transformed_points = points;
+ matrix.map_points(&mut transformed_points, &points);
+
+ let mut pb = skia::PathBuilder::new();
+ pb.move_to(transformed_points[0]);
+ pb.line_to(transformed_points[1]);
+ pb.line_to(transformed_points[2]);
+ pb.line_to(transformed_points[3]);
+ pb.close();
+ pb.detach()
+}
+
+/// Builds the (open) line-arrow polyline: the two arrow sides plus the stem
+/// back to `center`. Meant to be painted/expanded with a stroke paint.
+pub fn arrow_cap_path(center: &Point, direction: &Point, size: f32) -> skia::Path {
+ let mut pb = skia::PathBuilder::new();
+ let points = arrow_head_points(center, direction, size);
+ pb.move_to(points[1]);
+ pb.line_to(points[0]);
+ pb.line_to(points[2]);
+ pb.move_to(Point::new(center.x, center.y));
+ pb.line_to(points[0]);
+ pb.detach()
+}
+
+/// Builds the closed triangle-arrow cap.
+pub fn triangle_cap_path(center: &Point, direction: &Point, size: f32) -> skia::Path {
+ let mut pb = skia::PathBuilder::new();
+ let points = arrow_head_points(center, direction, size);
+ pb.move_to(points[0]);
+ pb.line_to(points[1]);
+ pb.line_to(points[2]);
+ pb.close();
+ pb.detach()
+}
+
+/// Tip and the two base corners of an arrow head of `size`, pointing from
+/// `center` towards `direction`.
+fn arrow_head_points(center: &Point, direction: &Point, size: f32) -> [Point; 3] {
+ let angle = (direction.y - center.y).atan2(direction.x - center.x);
+
+ let mut matrix = Matrix::new_identity();
+ matrix.pre_rotate(angle.to_degrees() - 90., Point::new(center.x, center.y));
+
+ let half_height = size / 2.;
+ let points = [
+ Point::new(center.x, center.y - half_height),
+ Point::new(center.x - size, center.y + half_height),
+ Point::new(center.x + size, center.y + half_height),
+ ];
+
+ let mut transformed_points = points;
+ matrix.map_points(&mut transformed_points, &points);
+ transformed_points
+}
+
+/// Expands an open path into its filled stroke region of `width`.
+fn stroke_region(path: &skia::Path, width: f32) -> Option {
+ let mut paint = skia::Paint::default();
+ paint.set_style(skia::PaintStyle::Stroke);
+ paint.set_stroke_width(width);
+
+ let mut outline = skia::Path::default();
+ skia::path_utils::fill_path_with_paint(path, &paint, &mut outline, None, None)
+ .then_some(outline)
+}
+
+/// Filled geometry of a single stroke cap, matching what `handle_stroke_caps`
+/// paints on the canvas.
+fn cap_path(cap: StrokeCap, width: f32, p1: &Point, p2: &Point) -> Option {
+ let path = match cap {
+ StrokeCap::LineArrow => {
+ // The square cap fills the gap between the path and the arrow.
+ let base = square_cap_path(p1, p2, width, 0.);
+ let arrow = stroke_region(&arrow_cap_path(p1, p2, width * 4.), width)?;
+ base.op(&arrow, skia::PathOp::Union)?
+ }
+ StrokeCap::TriangleArrow => triangle_cap_path(p1, p2, width * 4.),
+ StrokeCap::SquareMarker => square_cap_path(p1, p2, width * 4., 0.),
+ StrokeCap::CircleMarker => skia::Path::circle(*p1, width * 2., None),
+ StrokeCap::DiamondMarker => square_cap_path(p1, p2, width * 4., 45.),
+ StrokeCap::Round => skia::Path::circle(*p1, width / 2., None),
+ StrokeCap::Square => square_cap_path(p1, p2, width, 0.),
+ };
+ Some(path)
+}
+
+/// Filled region covered by the start/end caps of an open path.
+///
+/// Returns `None` when there is nothing to add: closed-ish paths with less than
+/// two points, no caps set, or caps Skia already draws natively on the stroke
+/// paint (`Round/Round`, `Square/Square`, see [`Stroke::to_skia_linecap`]).
+pub fn stroke_caps_to_path(path: &skia::Path, stroke: &Stroke) -> Option {
+ if stroke.to_skia_linecap().is_some() {
+ return None;
+ }
+
+ // Curves can have duplicated points, so let's remove consecutive duplicated points
+ let mut points = path.points().to_vec();
+ points.dedup();
+
+ let [first_point, .., last_point] = points.as_slice() else {
+ return None;
+ };
+
+ let caps = [
+ (stroke.cap_start, first_point, &points[1]),
+ (stroke.cap_end, last_point, &points[points.len() - 2]),
+ ];
+
+ let mut acc: Option = None;
+ for (cap, p1, p2) in caps {
+ let Some(cap) = cap else { continue };
+ let Some(path) = cap_path(cap, stroke.width, p1, p2) else {
+ continue;
+ };
+ acc = Some(match acc {
+ Some(acc) => acc.op(&path, skia::PathOp::Union).unwrap_or(acc),
+ None => path,
+ });
+ }
+
+ acc
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::paths::Segment;
+ use super::super::strokes::StrokeStyle;
+ use super::*;
+
+ fn horizontal_line() -> Path {
+ Path::new(vec![Segment::MoveTo((0., 0.)), Segment::LineTo((100., 0.))])
+ }
+
+ fn outline_bounds(cap_start: Option, cap_end: Option) -> Rect {
+ let stroke =
+ Stroke::new_center_stroke(4., StrokeStyle::Solid, cap_start, cap_end, None, None);
+ let path = horizontal_line();
+ let selrect = Rect::from_xywh(0., 0., 100., 0.);
+ stroke_to_path(&stroke, &path, None, &selrect, None, false)
+ .expect("stroke outline")
+ .to_skia_path(None)
+ .compute_tight_bounds()
+ }
+
+ #[test]
+ fn outline_without_caps_stays_within_the_path() {
+ let bounds = outline_bounds(None, None);
+ assert!(bounds.right <= 100.5, "bounds: {bounds:?}");
+ }
+
+ #[test]
+ fn outline_includes_the_arrow_head() {
+ // Arrow size is width * 4, so the tip sticks out ~8px past the end.
+ let bounds = outline_bounds(None, Some(StrokeCap::TriangleArrow));
+ assert!(bounds.right > 104., "bounds: {bounds:?}");
+ }
+
+ /// Farthest point from `center` still inside `path` along `angle`,
+ /// probed between 0 and `max_radius`.
+ fn radius_at(path: &skia::Path, center: (f32, f32), angle: f32, max_radius: f32) -> f32 {
+ let (mut lo, mut hi) = (0., max_radius);
+ for _ in 0..40 {
+ let mid = (lo + hi) / 2.;
+ let p = (center.0 + mid * angle.cos(), center.1 + mid * angle.sin());
+ if path.contains(p) {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ lo
+ }
+
+ #[test]
+ fn round_cap_stays_round() {
+ // Round/Round is drawn by Skia's own linecap, which also goes through
+ // the conic conversion on the way back into a Penpot path.
+ let stroke = Stroke::new_center_stroke(
+ 10.,
+ StrokeStyle::Solid,
+ Some(StrokeCap::Round),
+ Some(StrokeCap::Round),
+ None,
+ None,
+ );
+ let selrect = Rect::from_xywh(0., 0., 100., 0.);
+ let path = stroke_to_path(&stroke, &horizontal_line(), None, &selrect, None, false)
+ .expect("stroke outline")
+ .to_skia_path(None);
+
+ // The cap is a half disc of radius width / 2 around the path end.
+ let expected = 5.;
+ for step in [-1, 0, 1] {
+ let angle = std::f32::consts::FRAC_PI_4 * step as f32;
+ let radius = radius_at(&path, (100., 0.), angle, expected * 2.);
+ assert!(
+ (radius - expected).abs() < expected * 0.01,
+ "radius at {angle}rad: {radius}"
+ );
+ }
+ }
+
+ #[test]
+ fn closed_paths_get_no_caps() {
+ // Caps only exist at sub-path ends, so a closed path must come back
+ // as the plain outline, arrow cap or not.
+ let stroke = Stroke::new_center_stroke(
+ 4.,
+ StrokeStyle::Solid,
+ Some(StrokeCap::TriangleArrow),
+ Some(StrokeCap::TriangleArrow),
+ None,
+ None,
+ );
+ let square = Path::new(vec![
+ Segment::MoveTo((0., 0.)),
+ Segment::LineTo((100., 0.)),
+ Segment::LineTo((100., 100.)),
+ Segment::LineTo((0., 100.)),
+ Segment::Close,
+ ]);
+ let selrect = Rect::from_xywh(0., 0., 100., 100.);
+ let bounds = stroke_to_path(&stroke, &square, None, &selrect, None, false)
+ .expect("stroke outline")
+ .to_skia_path(None)
+ .compute_tight_bounds();
+
+ // Center stroke of width 4 grows the square by 2 on every side.
+ assert!(
+ bounds.left > -2.5 && bounds.right < 102.5,
+ "bounds: {bounds:?}"
+ );
+ }
+
+ #[test]
+ fn square_marker_cap_matches_the_canvas_size() {
+ // SquareMarker draws a width * 4 square centered on the path end, so
+ // it sticks out width * 2 past it.
+ let bounds = outline_bounds(None, Some(StrokeCap::SquareMarker));
+ assert!((bounds.right - 108.).abs() < 0.5, "bounds: {bounds:?}");
+ }
+
+ #[test]
+ fn circle_marker_cap_stays_round() {
+ // Regression: conics were converted to a single quad each, so circle
+ // markers bulged ~6% at the arc midpoints and looked like squircles.
+ let stroke = Stroke::new_center_stroke(
+ 4.,
+ StrokeStyle::Solid,
+ None,
+ Some(StrokeCap::CircleMarker),
+ None,
+ None,
+ );
+ let selrect = Rect::from_xywh(0., 0., 100., 0.);
+ let path = stroke_to_path(&stroke, &horizontal_line(), None, &selrect, None, false)
+ .expect("stroke outline")
+ .to_skia_path(None);
+
+ // CircleMarker radius is width * 2, centered on the path end.
+ let expected = 8.;
+ for step in 0..8 {
+ // Skip the direction pointing back along the line, where the cap
+ // merges into the stroke band.
+ if step == 4 {
+ continue;
+ }
+ let angle = std::f32::consts::FRAC_PI_4 * step as f32;
+ let radius = radius_at(&path, (100., 0.), angle, expected * 2.);
+ assert!(
+ (radius - expected).abs() < expected * 0.01,
+ "radius at {}rad: {radius}",
+ angle
+ );
+ }
+ }
+
+ #[test]
+ fn outline_includes_mixed_round_and_arrow_caps() {
+ // Regression for #10825: a round start plus an arrow end used to be
+ // dropped entirely by stroke-to-path.
+ let bounds = outline_bounds(Some(StrokeCap::Round), Some(StrokeCap::LineArrow));
+ assert!(bounds.left < -1., "bounds: {bounds:?}");
+ assert!(bounds.right > 104., "bounds: {bounds:?}");
+ }
+}