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:?}"); + } +}