diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 1026bcc4a1..3c3311f4f2 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -305,3 +305,19 @@ test("Renders a clipped frame with a large blur drop shadow", async ({ await expect(workspace.canvas).toHaveScreenshot(); }); + +test("Renders a file with solid, dotted, dashed and mixed stroke styles", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-stroke-styles.json"); + + await workspace.goToWorkspace({ + id: "b888b894-3697-80d3-8006-51cc8a55c200", + pageId: "b888b894-3697-80d3-8006-51cc8a55c210", + }); + await workspace.waitForFirstRenderWithoutUI(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png new file mode 100644 index 0000000000..f23422a736 Binary files /dev/null and b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-solid-dotted-dashed-and-mixed-stroke-styles-1.png differ diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 0d7797b8fb..2e889abe26 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -26,7 +26,7 @@ fn draw_stroke_on_rect( // Draw the different kind of strokes for a rect is straightforward, we just need apply a stroke to: // - The same rect if it's a center stroke // - A bigger rect if it's an outer stroke - // - A smaller rect if it's an outer stroke + // - A smaller rect if it's an inner stroke let stroke_rect = stroke.aligned_rect(rect, scale); let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); @@ -34,14 +34,75 @@ fn draw_stroke_on_rect( let filter = compose_filters(blur, shadow); paint.set_image_filter(filter); - match corners { - Some(radii) => { - let radii = stroke.outer_corners(radii); - let rrect = RRect::new_rect_radii(stroke_rect, &radii); - canvas.draw_rrect(rrect, &paint); + // For inner/outer strokes, we need clipping to prevent stroke pattern + // (like dotted circles) from appearing in wrong areas + match stroke.kind { + StrokeKind::Inner => { + // Inner: clip to original rect to hide parts outside boundary + canvas.save(); + match corners { + Some(radii) => { + let rrect = RRect::new_rect_radii(*rect, radii); + canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias); + } + None => { + canvas.clip_rect(*rect, skia::ClipOp::Intersect, antialias); + } + } + + // Draw the stroke + match corners { + Some(radii) => { + let radii = stroke.outer_corners(radii); + let rrect = RRect::new_rect_radii(stroke_rect, &radii); + canvas.draw_rrect(rrect, &paint); + } + None => { + canvas.draw_rect(stroke_rect, &paint); + } + } + + canvas.restore(); } - None => { - canvas.draw_rect(stroke_rect, &paint); + StrokeKind::Outer => { + // Outer: clip to exclude original rect to hide parts inside boundary + canvas.save(); + match corners { + Some(radii) => { + let rrect = RRect::new_rect_radii(*rect, radii); + canvas.clip_rrect(rrect, skia::ClipOp::Difference, antialias); + } + None => { + canvas.clip_rect(*rect, skia::ClipOp::Difference, antialias); + } + } + + // Draw the stroke + match corners { + Some(radii) => { + let radii = stroke.outer_corners(radii); + let rrect = RRect::new_rect_radii(stroke_rect, &radii); + canvas.draw_rrect(rrect, &paint); + } + None => { + canvas.draw_rect(stroke_rect, &paint); + } + } + + canvas.restore(); + } + StrokeKind::Center => { + // Center strokes don't need clipping + match corners { + Some(radii) => { + let radii = stroke.outer_corners(radii); + let rrect = RRect::new_rect_radii(stroke_rect, &radii); + canvas.draw_rrect(rrect, &paint); + } + None => { + canvas.draw_rect(stroke_rect, &paint); + } + } } } } @@ -62,7 +123,7 @@ fn draw_stroke_on_circle( // Draw the different kind of strokes for an oval is straightforward, we just need apply a stroke to: // - The same oval if it's a center stroke // - A bigger oval if it's an outer stroke - // - A smaller oval if it's an outer stroke + // - A smaller oval if it's an inner stroke let stroke_rect = stroke.aligned_rect(rect, scale); let mut paint = stroke.to_paint(selrect, svg_attrs, antialias); @@ -70,7 +131,32 @@ fn draw_stroke_on_circle( let filter = compose_filters(blur, shadow); paint.set_image_filter(filter); - canvas.draw_oval(stroke_rect, &paint); + // For inner/outer strokes, we need clipping to prevent stroke pattern + // (like dotted circles) from appearing in wrong areas + match stroke.kind { + StrokeKind::Inner => { + // Inner: clip to original rect to hide parts outside boundary + canvas.save(); + let mut clip_path = skia::Path::new(); + clip_path.add_oval(rect, None); + canvas.clip_path(&clip_path, skia::ClipOp::Intersect, antialias); + canvas.draw_oval(stroke_rect, &paint); + canvas.restore(); + } + StrokeKind::Outer => { + // Outer: clip to exclude original rect to hide parts inside boundary + canvas.save(); + let mut clip_path = skia::Path::new(); + clip_path.add_oval(rect, None); + canvas.clip_path(&clip_path, skia::ClipOp::Difference, antialias); + canvas.draw_oval(stroke_rect, &paint); + canvas.restore(); + } + StrokeKind::Center => { + // Center strokes don't need clipping + canvas.draw_oval(stroke_rect, &paint); + } + } } fn draw_outer_stroke_path( diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index e45c011a14..3e4d719188 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -128,20 +128,28 @@ impl Stroke { } pub fn outer_rect(&self, rect: &Rect) -> Rect { - match self.kind { - StrokeKind::Inner => Rect::from_xywh( - rect.left + (self.width / 2.), - rect.top + (self.width / 2.), - rect.width() - self.width, - rect.height() - self.width, - ), - StrokeKind::Center => Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()), - StrokeKind::Outer => Rect::from_xywh( - rect.left - (self.width / 2.), - rect.top - (self.width / 2.), - rect.width() + self.width, - rect.height() + self.width, - ), + match (self.kind, self.style) { + (StrokeKind::Inner, StrokeStyle::Dotted) | (StrokeKind::Outer, StrokeStyle::Dotted) => { + // Boundary so circles center on it and semicircles match after clipping + *rect + } + _ => match self.kind { + StrokeKind::Inner => Rect::from_xywh( + rect.left + (self.width / 2.), + rect.top + (self.width / 2.), + rect.width() - self.width, + rect.height() - self.width, + ), + StrokeKind::Center => { + Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()) + } + StrokeKind::Outer => Rect::from_xywh( + rect.left - (self.width / 2.), + rect.top - (self.width / 2.), + rect.width() + self.width, + rect.height() + self.width, + ), + }, } } @@ -155,6 +163,11 @@ impl Stroke { } pub fn outer_corners(&self, corners: &Corners) -> Corners { + if matches!(self.style, StrokeStyle::Dotted | StrokeStyle::Dashed) { + // Path at boundary so no corner offset + return *corners; + } + let offset = match self.kind { StrokeKind::Center => 0.0, StrokeKind::Inner => -self.width / 2.0,