mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 10:26:14 +00:00
🐛 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:
parent
9a11dfa7f9
commit
76e9289fcf
@ -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(
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -4,7 +4,5 @@ expression: svg
|
||||
---
|
||||
<?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">
|
||||
<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="M-24 0L24 48L24 -48L-24 0Z"/>
|
||||
<ellipse transform="translate(102 102)" cx="0" cy="90" rx="24" ry="24"/>
|
||||
<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"/>
|
||||
</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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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("<path ").count() >= 2 || svg.contains("<circle"),
|
||||
"expected separate cap geometry besides the dotted outline: {svg}"
|
||||
// Caps are unioned into the expanded outline by `stroke_to_path`, so they
|
||||
// must not be emitted as extra draws: overlapping draws would double the
|
||||
// alpha of translucent strokes. The snapshot below covers the geometry.
|
||||
assert_eq!(
|
||||
svg.matches("<path ").count(),
|
||||
1,
|
||||
"caps must be part of the outline, not extra draws: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
@ -2402,8 +2405,8 @@ fn exports_open_path_with_solid_center_image_stroke() {
|
||||
|
||||
#[test]
|
||||
fn exports_open_path_with_image_stroke_and_caps() {
|
||||
// Caps go into the clip silhouette with the outline. Image dest must grow
|
||||
// past stroke.delta() so triangle/circle markers stay textured.
|
||||
// Caps are part of the clip silhouette (unioned into the outline). Image
|
||||
// dest must grow past stroke.delta() so triangle/circle markers stay textured.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
let image_id = uid(42);
|
||||
@ -2425,11 +2428,12 @@ fn exports_open_path_with_image_stroke_and_caps() {
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("</clipPath>").next())
|
||||
.expect("imgstroke clipPath");
|
||||
assert!(
|
||||
clip.matches("<path ").count() >= 2
|
||||
|| clip.contains("<circle")
|
||||
|| clip.contains("<ellipse"),
|
||||
"clip must include cap geometry besides the stroke outline: {svg}"
|
||||
// `stroke_to_path` unions the caps into the outline, so the clip is a
|
||||
// single path covering both.
|
||||
assert_eq!(
|
||||
clip.matches("<path ").count(),
|
||||
1,
|
||||
"clip must be the outline with the caps unioned in: {svg}"
|
||||
);
|
||||
// TriangleArrow margin is width*4 = 48.
|
||||
assert!(
|
||||
|
||||
@ -1192,12 +1192,9 @@ fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> 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.
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user