🐛 Fix stroke to path dropping caps and markers (#11614)

* 🐛 Fix stroke to path dropping caps and markers

Caps and markers (round ends, arrows, square/diamond/circle markers) are
painted separately by `handle_stroke_caps` after the stroke itself, so
`stroke_to_path` — which only outlines the shape path — dropped them.
Converting a stroke to a path lost every end decoration except the
Round/Round and Square/Square pairs Skia draws natively, and even those
were missing because the outline paint kept the default butt cap.

Cap geometry now lives in `shapes/stroke_paths.rs` as plain path
builders, shared by the canvas renderer and by `stroke_to_path`, which
unions the caps into the outline for open paths and honors
`to_skia_linecap`. The SVG export no longer overlays the caps a second
time on top of the expanded outline, which would double the alpha of
translucent strokes; the clip silhouette for image strokes gets them
from the outline too.

Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
This commit is contained in:
Shreyash 2026-09-16 16:16:00 +05:30 committed by GitHub
parent 9a11dfa7f9
commit 76e9289fcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 411 additions and 146 deletions

View File

@ -1,8 +1,8 @@
use crate::math::{Matrix, Point, Rect}; use crate::math::{Matrix, Point, Rect};
use crate::shapes::{ use crate::shapes::{
merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, arrow_cap_path, merge_fills, square_cap_path, triangle_cap_path, Corners, Fill, ImageFill,
Type, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type,
}; };
use skia_safe::{self as skia, ImageFilter, RRect}; use skia_safe::{self as skia, ImageFilter, RRect};
@ -445,40 +445,10 @@ fn draw_square_cap(
size: f32, size: f32,
extra_rotation: f32, extra_rotation: f32,
) { ) {
let dx = direction.x - center.x; canvas.draw_path(
let dy = direction.y - center.y; &square_cap_path(center, direction, size, extra_rotation),
let angle = dy.atan2(dx); paint,
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 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( fn draw_arrow_cap(
@ -488,33 +458,7 @@ fn draw_arrow_cap(
direction: &Point, direction: &Point,
size: f32, size: f32,
) { ) {
let dx = direction.x - center.x; canvas.draw_path(&arrow_cap_path(center, direction, size), paint);
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);
} }
fn draw_triangle_cap( fn draw_triangle_cap(
@ -524,32 +468,7 @@ fn draw_triangle_cap(
direction: &Point, direction: &Point,
size: f32, size: f32,
) { ) {
let dx = direction.x - center.x; canvas.draw_path(&triangle_cap_path(center, direction, size), paint);
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);
} }
fn draw_image_stroke_in_container( fn draw_image_stroke_in_container(

View File

@ -4,7 +4,5 @@ expression: svg
--- ---
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="344" height="294" viewBox="0 0 344 294"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="344" height="294" viewBox="0 0 344 294"><defs><clipPath id="imgstrokeclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(102 102)" d="M146 -6L0 -6L0 6L134 6L134 84L0 84L0 96L146 96L146 -6Z" clip-rule="evenodd"/> <path transform="translate(102 102)" d="M24 -48L-24 0L24 48L24 6L134 6L134 84L23.2379 84C22.572813 81.424126 21.508844 79.025162 20.045998 76.803108C18.583151 74.581055 16.800131 72.655464 14.696939 71.026337C12.593748 69.397209 10.283548 68.152206 7.7663383 67.291321C5.2491293 66.430443 2.6603498 66 0 66C-3.1825976 66 -6.2440643 66.608963 -9.1844006 67.826889C-12.124738 69.044815 -14.720124 70.778999 -16.970562 73.029434C-19.220997 75.279869 -20.955179 77.875252 -22.173107 80.81559C-23.391035 83.75592 -23.999998 86.81739 -24 90C-23.999998 93.182594 -23.391035 96.244057 -22.173107 99.184387C-20.955179 102.12473 -19.220997 104.72012 -16.970562 106.97056C-14.720124 109.22099 -12.124738 110.95517 -9.1844006 112.1731C-6.2440643 113.39102 -3.1825976 113.99999 0 114C2.6603498 114 5.2491293 113.56956 7.7663383 112.70868C10.283548 111.84779 12.593748 110.60279 14.696939 108.97366C16.800131 107.34454 18.583151 105.41895 20.045998 103.1969C21.508844 100.97485 22.572813 98.575874 23.2379 96L146 96L146 -6L24 -6L24 -48Z" clip-rule="evenodd"/>
<path transform="translate(102 102)" d="M-24 0L24 48L24 -48L-24 0Z"/>
<ellipse transform="translate(102 102)" cx="0" cy="90" rx="24" ry="24"/>
</clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-48" y="-48" width="236" height="186" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 102 102)"/></g></svg> </clipPath></defs><g clip-path="url(#imgstrokeclip0)"><image href="images/test-fill.svg" x="-48" y="-48" width="236" height="186" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 102 102)"/></g></svg>

View File

@ -1789,7 +1789,7 @@ fn exports_open_path_with_dotted_center_stroke() {
fn exports_open_path_with_dotted_stroke_and_caps() { fn exports_open_path_with_dotted_stroke_and_caps() {
// Regression: dotted/dashed SVG expansion used stroke_to_path and returned // Regression: dotted/dashed SVG expansion used stroke_to_path and returned
// before draw_stroke_geometry, so open-path caps (triangle/circle/…) were // 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 mut pool = ShapesPool::new();
let id = uid(1); let id = uid(1);
let mut stroke = dotted_stroke( 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\""), svg.contains("fill=\"#1040FF\"") || svg.to_ascii_lowercase().contains("fill=\"#1040ff\""),
"dotted stroke with caps must emit filled geometry: {svg}" "dotted stroke with caps must emit filled geometry: {svg}"
); );
// Caps are separate filled draws (triangle + circle), not only the dotted outline. // Caps are unioned into the expanded outline by `stroke_to_path`, so they
assert!( // must not be emitted as extra draws: overlapping draws would double the
svg.matches("<path ").count() >= 2 || svg.contains("<circle"), // alpha of translucent strokes. The snapshot below covers the geometry.
"expected separate cap geometry besides the dotted outline: {svg}" assert_eq!(
svg.matches("<path ").count(),
1,
"caps must be part of the outline, not extra draws: {svg}"
); );
insta::assert_snapshot!(svg); insta::assert_snapshot!(svg);
} }
@ -2402,8 +2405,8 @@ fn exports_open_path_with_solid_center_image_stroke() {
#[test] #[test]
fn exports_open_path_with_image_stroke_and_caps() { fn exports_open_path_with_image_stroke_and_caps() {
// Caps go into the clip silhouette with the outline. Image dest must grow // Caps are part of the clip silhouette (unioned into the outline). Image
// past stroke.delta() so triangle/circle markers stay textured. // dest must grow past stroke.delta() so triangle/circle markers stay textured.
let mut pool = ShapesPool::new(); let mut pool = ShapesPool::new();
let id = uid(1); let id = uid(1);
let image_id = uid(42); let image_id = uid(42);
@ -2425,11 +2428,12 @@ fn exports_open_path_with_image_stroke_and_caps() {
.nth(1) .nth(1)
.and_then(|s| s.split("</clipPath>").next()) .and_then(|s| s.split("</clipPath>").next())
.expect("imgstroke clipPath"); .expect("imgstroke clipPath");
assert!( // `stroke_to_path` unions the caps into the outline, so the clip is a
clip.matches("<path ").count() >= 2 // single path covering both.
|| clip.contains("<circle") assert_eq!(
|| clip.contains("<ellipse"), clip.matches("<path ").count(),
"clip must include cap geometry besides the stroke outline: {svg}" 1,
"clip must be the outline with the caps unioned in: {svg}"
); );
// TriangleArrow margin is width*4 = 48. // TriangleArrow margin is width*4 = 48.
assert!( assert!(

View File

@ -1192,12 +1192,9 @@ fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> b
paint.set_anti_alias(true); paint.set_anti_alias(true);
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint);
// Expanded dotted/dashed strokes skip `draw_stroke_geometry`, which is // Caps are already part of the outline (`stroke_to_path` unions them in),
// where open-path caps are drawn. Overlay them here in local path space // so they must not be overlaid again: a second draw would double the
// (same as fills / the outline above under the leaf CTM). // alpha of translucent strokes.
if is_open {
paint_svg_stroke_caps(canvas, shape, stroke, false);
}
true true
} }
@ -1212,8 +1209,6 @@ pub(super) fn paint_svg_stroke_silhouette(
stroke: &Stroke, stroke: &Stroke,
scale: f32, scale: f32,
) -> bool { ) -> bool {
let is_open = shape.is_open();
if stroke.per_side_widths().is_some() if stroke.per_side_widths().is_some()
&& matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_)) && 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); paint.set_color(skia::Color::BLACK);
canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); 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 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 /// 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 /// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width
/// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette. /// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette.

View File

@ -196,9 +196,12 @@ impl Path {
let w = conic_weights[current_conic]; let w = conic_weights[current_conic];
current_conic += 1; current_conic += 1;
// pow2=0: 1 quad per conic. A circle (4 conics) becomes // pow2=2: 4 quads per conic, so a circle (4 conics)
// 4 cubics, matching the standard bezier approximation. // becomes 16 cubics and stays within ~0.03% of the real
const POW2: usize = 0; // 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 quad_count = 1 << POW2;
let pts_count = 1 + 2 * quad_count; let pts_count = 1 + 2 * quad_count;
let mut quad_pts = vec![skia::Point::default(); pts_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()) 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
);
}
}
}

View File

@ -1,9 +1,9 @@
use skia_safe::{self as skia}; use skia_safe::{self as skia};
use super::paths::Path; use super::paths::Path;
use super::strokes::{Stroke, StrokeKind}; use super::strokes::{Stroke, StrokeCap, StrokeKind};
use super::svg_attrs::SvgAttrs; use super::svg_attrs::SvgAttrs;
use crate::math::Rect; use crate::math::{Matrix, Point, Rect};
/// Converts a stroke into a filled path outline. /// 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); 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 mut stroke_outline = skia::Path::default();
let success = skia::path_utils::fill_path_with_paint( let success = skia::path_utils::fill_path_with_paint(
&transformed_shape_path, &transformed_shape_path,
@ -78,6 +84,18 @@ pub fn stroke_to_path(
StrokeKind::Center => stroke_outline.simplify().unwrap_or(stroke_outline), 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 // If there was a path_transform, invert it back to local coords
let final_path = if let Some(pt) = path_transform { let final_path = if let Some(pt) = path_transform {
if let Some(inv) = pt.invert() { if let Some(inv) = pt.invert() {
@ -91,3 +109,318 @@ pub fn stroke_to_path(
Some(Path::from_skia_path_accurate(final_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<skia::Path> {
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<skia::Path> {
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<skia::Path> {
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<skia::Path> = 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<StrokeCap>, cap_end: Option<StrokeCap>) -> 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:?}");
}
}