From e3393cd6b29e1747c35480cb5eca5af0ca116936 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Wed, 24 Dec 2025 12:16:46 +0100 Subject: [PATCH 1/7] classic: implement dashed stroked clipping --- examples/scenes/src/test_scenes.rs | 40 ++++++++++++++ vello/src/scene.rs | 67 +++++++++++++++-------- vello_tests/snapshots/clip_test.png | 4 +- vello_tests/tests/snapshot_test_scenes.rs | 2 +- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs index 836cadf84..b173784cd 100644 --- a/examples/scenes/src/test_scenes.rs +++ b/examples/scenes/src/test_scenes.rs @@ -1673,6 +1673,46 @@ mod impls { ); scene.pop_layer(); + // Dashed stroke clip demo: clip to the stroked outline of a path. + let stroke_demo_rect = Rect::new(250.0, 460.0, 450.0, 660.0); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + palette::css::LIGHT_GREEN, + None, + &stroke_demo_rect, + ); + let mut stroke_star = BezPath::new(); + let center = Point::new(350.0, 560.0); + let outer_r = 85.0; + let start_angle = -std::f64::consts::FRAC_PI_2; + let pts: [Point; 5] = core::array::from_fn(|i| { + let a = start_angle + (i as f64) * (2.0 * std::f64::consts::PI / 5.0); + center + Vec2::new(a.cos() * outer_r, a.sin() * outer_r) + }); + let order = [0_usize, 2, 4, 1, 3]; + stroke_star.move_to(pts[order[0]]); + for &idx in &order[1..] { + stroke_star.line_to(pts[idx]); + } + stroke_star.close_path(); + let mut stroke = Stroke::new(5.0); + stroke.dash_pattern = [10.].into_iter().collect(); + stroke.join = Join::Round; + stroke.start_cap = Cap::Round; + stroke.end_cap = Cap::Round; + scene.push_clip_layer(&stroke, Affine::IDENTITY, &stroke_star); + let grad = Gradient::new_linear((250.0, 460.0), (450.0, 660.0)) + .with_stops([palette::css::MAGENTA, palette::css::CYAN]); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + &grad, + None, + &stroke_demo_rect, + ); + scene.pop_layer(); + let large_background_rect = Rect::new(-1000.0, -1000.0, 2000.0, 2000.0); let inside_clip_rect = Rect::new(11.0, 13.399999999999999, 59.0, 56.6); let outside_clip_rect = Rect::new( diff --git a/vello/src/scene.rs b/vello/src/scene.rs index c32ecaf91..06a7d78bb 100644 --- a/vello/src/scene.rs +++ b/vello/src/scene.rs @@ -212,34 +212,57 @@ impl Scene { ) { let t = Transform::from_kurbo(&transform); self.encoding.encode_transform(t); - let (is_fill, stroke_for_estimate) = match clip_style { + + // The logic for encoding the clip shape differs between fill and stroke style clips, but + // the logic is otherwise similar. + // + // `encoded_result` will be `true` if and only if a valid path has been encoded. If it is + // `false`, we will need to explicitly encode a valid empty path. + let encoded_result = match clip_style { StyleRef::Fill(fill) => { self.encoding.encode_fill_style(fill); - (true, None) + #[cfg(feature = "bump_estimate")] + self.estimator.count_path(clip.path_elements(0.1), &t, None); + self.encoding.encode_shape(clip, true) } StyleRef::Stroke(stroke) => { let encoded_stroke = self.encoding.encode_stroke_style(stroke); - (false, encoded_stroke.then_some(stroke)) + if !encoded_stroke { + // If the stroke has zero width, encode a fill style and indicate no path was + // encoded. + self.encoding.encode_fill_style(Fill::NonZero); + false + } else { + if stroke.dash_pattern.is_empty() { + #[cfg(feature = "bump_estimate")] + self.estimator + .count_path(clip.path_elements(0.1), &t, Some(stroke)); + self.encoding.encode_shape(clip, false) + } else { + // TODO: We currently collect the output of the dash iterator because + // `encode_path_elements` wants to consume the iterator. We want to avoid calling + // `dash` twice when `bump_estimate` is enabled because it internally allocates. + // Bump estimation will move to resolve time rather than scene construction time, + // so we can revert this back to not collecting when that happens. + let dashed = peniko::kurbo::dash( + clip.path_elements(0.1), + stroke.dash_offset, + &stroke.dash_pattern, + ) + .collect::>(); + #[cfg(feature = "bump_estimate")] + self.estimator + .count_path(dashed.iter().copied(), &t, Some(stroke)); + self.encoding + .encode_path_elements(dashed.into_iter(), false) + } + } } }; - if stroke_for_estimate.is_none() && matches!(clip_style, StyleRef::Stroke(_)) { - // If the stroke has zero width, encode a valid empty path. This suppresses - // all drawing until the layer is popped. - self.encoding.encode_fill_style(Fill::NonZero); - self.encoding.encode_empty_shape(); - #[cfg(feature = "bump_estimate")] - { - use peniko::kurbo::PathEl; - let path = [PathEl::MoveTo(Point::ZERO), PathEl::LineTo(Point::ZERO)]; - self.estimator.count_path(path.into_iter(), &t, None); - } - self.encoding.encode_begin_clip(parameters); - return; - } - if !self.encoding.encode_shape(clip, is_fill) { - // If the layer shape is invalid, encode a valid empty path. This suppresses - // all drawing until the layer is popped. + if !encoded_result { + // If the layer shape is invalid or a zero-width stroke, encode a valid empty path. + // This suppresses all drawing until the layer is popped. self.encoding.encode_empty_shape(); #[cfg(feature = "bump_estimate")] { @@ -247,10 +270,6 @@ impl Scene { let path = [PathEl::MoveTo(Point::ZERO), PathEl::LineTo(Point::ZERO)]; self.estimator.count_path(path.into_iter(), &t, None); } - } else { - #[cfg(feature = "bump_estimate")] - self.estimator - .count_path(clip.path_elements(0.1), &t, stroke_for_estimate); } self.encoding.encode_begin_clip(parameters); } diff --git a/vello_tests/snapshots/clip_test.png b/vello_tests/snapshots/clip_test.png index ec78edd78..a2669d1e2 100644 --- a/vello_tests/snapshots/clip_test.png +++ b/vello_tests/snapshots/clip_test.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7ef8db8960774793d3be59d29a9972966994088bacc7d7de3c09aa02b7d561a -size 13198 +oid sha256:d9ce096ce053ca5c27089bbd265beccd44f11dfe78696180094e6b4e19e2d0cb +size 60621 diff --git a/vello_tests/tests/snapshot_test_scenes.rs b/vello_tests/tests/snapshot_test_scenes.rs index 204b8ab55..c3c9a83e8 100644 --- a/vello_tests/tests/snapshot_test_scenes.rs +++ b/vello_tests/tests/snapshot_test_scenes.rs @@ -102,7 +102,7 @@ fn snapshot_many_clips() { #[cfg_attr(skip_gpu_tests, ignore)] fn snapshot_clip_test() { let test_scene = test_scenes::clip_test(); - let params = TestParams::new("clip_test", 512, 512); + let params = TestParams::new("clip_test", 512, 768); snapshot_test_scene(test_scene, params); } From f96c2fa87f4c406f1c7c1c8f284ca10f094d7e4b Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Mon, 5 Jan 2026 12:18:33 +0100 Subject: [PATCH 2/7] Factor out some stroke code to a shared method --- vello/src/scene.rs | 101 +++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 54 deletions(-) diff --git a/vello/src/scene.rs b/vello/src/scene.rs index 06a7d78bb..597787de3 100644 --- a/vello/src/scene.rs +++ b/vello/src/scene.rs @@ -226,36 +226,13 @@ impl Scene { self.encoding.encode_shape(clip, true) } StyleRef::Stroke(stroke) => { - let encoded_stroke = self.encoding.encode_stroke_style(stroke); - if !encoded_stroke { + if stroke.width == 0. { // If the stroke has zero width, encode a fill style and indicate no path was // encoded. self.encoding.encode_fill_style(Fill::NonZero); false } else { - if stroke.dash_pattern.is_empty() { - #[cfg(feature = "bump_estimate")] - self.estimator - .count_path(clip.path_elements(0.1), &t, Some(stroke)); - self.encoding.encode_shape(clip, false) - } else { - // TODO: We currently collect the output of the dash iterator because - // `encode_path_elements` wants to consume the iterator. We want to avoid calling - // `dash` twice when `bump_estimate` is enabled because it internally allocates. - // Bump estimation will move to resolve time rather than scene construction time, - // so we can revert this back to not collecting when that happens. - let dashed = peniko::kurbo::dash( - clip.path_elements(0.1), - stroke.dash_offset, - &stroke.dash_pattern, - ) - .collect::>(); - #[cfg(feature = "bump_estimate")] - self.estimator - .count_path(dashed.iter().copied(), &t, Some(stroke)); - self.encoding - .encode_path_elements(dashed.into_iter(), false) - } + self.stroke_gpu_inner(stroke, clip) } } }; @@ -401,35 +378,7 @@ impl Scene { let t = Transform::from_kurbo(&transform); self.encoding.encode_transform(t); - let encoded_stroke = self.encoding.encode_stroke_style(style); - debug_assert!(encoded_stroke, "Stroke width is non-zero"); - - // We currently don't support dashing on the GPU. If the style has a dash pattern, then - // we convert it into stroked paths on the CPU and encode those as individual draw - // objects. - let encode_result = if style.dash_pattern.is_empty() { - #[cfg(feature = "bump_estimate")] - self.estimator - .count_path(shape.path_elements(SHAPE_TOLERANCE), &t, Some(style)); - self.encoding.encode_shape(shape, false) - } else { - // TODO: We currently collect the output of the dash iterator because - // `encode_path_elements` wants to consume the iterator. We want to avoid calling - // `dash` twice when `bump_estimate` is enabled because it internally allocates. - // Bump estimation will move to resolve time rather than scene construction time, - // so we can revert this back to not collecting when that happens. - let dashed = peniko::kurbo::dash( - shape.path_elements(SHAPE_TOLERANCE), - style.dash_offset, - &style.dash_pattern, - ) - .collect::>(); - #[cfg(feature = "bump_estimate")] - self.estimator - .count_path(dashed.iter().copied(), &t, Some(style)); - self.encoding - .encode_path_elements(dashed.into_iter(), false) - }; + let encode_result = self.stroke_gpu_inner(style, shape); if encode_result { if let Some(brush_transform) = brush_transform && self @@ -451,6 +400,50 @@ impl Scene { } } + /// Encodes the stroke of a shape using the specified style. The stroke style must have + /// non-zero width. + /// + /// This handles encoding the stroke style and shape, including dashing, but not, e.g., the + /// shape transform. If applicable, that should be handled by the caller. + /// + /// Returns `true` if a non-zero number of segments were encoded. + fn stroke_gpu_inner(&mut self, style: &Stroke, shape: &impl Shape) -> bool { + // See the note about tolerances in `Self::stroke`. + const SHAPE_TOLERANCE: f64 = 0.01; + + let encoded_stroke = self.encoding.encode_stroke_style(style); + debug_assert!(encoded_stroke, "Stroke width is non-zero"); + + // We currently don't support dashing on the GPU. If the style has a dash pattern, then + // we convert it into stroked paths on the CPU and encode those as individual draw + // objects. + let encode_result = if style.dash_pattern.is_empty() { + #[cfg(feature = "bump_estimate")] + self.estimator + .count_path(shape.path_elements(SHAPE_TOLERANCE), &t, Some(style)); + self.encoding.encode_shape(shape, false) + } else { + // TODO: We currently collect the output of the dash iterator because + // `encode_path_elements` wants to consume the iterator. We want to avoid calling + // `dash` twice when `bump_estimate` is enabled because it internally allocates. + // Bump estimation will move to resolve time rather than scene construction time, + // so we can revert this back to not collecting when that happens. + let dashed = peniko::kurbo::dash( + shape.path_elements(SHAPE_TOLERANCE), + style.dash_offset, + &style.dash_pattern, + ) + .collect::>(); + #[cfg(feature = "bump_estimate")] + self.estimator + .count_path(dashed.iter().copied(), &t, Some(style)); + self.encoding + .encode_path_elements(dashed.into_iter(), false) + }; + + encode_result + } + /// Draws an image at its natural size with the given transform. pub fn draw_image<'b>(&mut self, image: impl Into>, transform: Affine) { let brush = image.into(); From 5148adf200ebb6a1714883201b2325dbd58ac89d Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Mon, 5 Jan 2026 12:59:32 +0100 Subject: [PATCH 3/7] Move in `transform` as well --- vello/src/scene.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/vello/src/scene.rs b/vello/src/scene.rs index 597787de3..ee383828e 100644 --- a/vello/src/scene.rs +++ b/vello/src/scene.rs @@ -210,9 +210,6 @@ impl Scene { transform: Affine, clip: &impl Shape, ) { - let t = Transform::from_kurbo(&transform); - self.encoding.encode_transform(t); - // The logic for encoding the clip shape differs between fill and stroke style clips, but // the logic is otherwise similar. // @@ -220,6 +217,8 @@ impl Scene { // `false`, we will need to explicitly encode a valid empty path. let encoded_result = match clip_style { StyleRef::Fill(fill) => { + let t = Transform::from_kurbo(&transform); + self.encoding.encode_transform(t); self.encoding.encode_fill_style(fill); #[cfg(feature = "bump_estimate")] self.estimator.count_path(clip.path_elements(0.1), &t, None); @@ -232,7 +231,7 @@ impl Scene { self.encoding.encode_fill_style(Fill::NonZero); false } else { - self.stroke_gpu_inner(stroke, clip) + self.stroke_gpu_inner(stroke, transform, clip) } } }; @@ -375,10 +374,7 @@ impl Scene { if style.width == 0. { return; } - - let t = Transform::from_kurbo(&transform); - self.encoding.encode_transform(t); - let encode_result = self.stroke_gpu_inner(style, shape); + let encode_result = self.stroke_gpu_inner(style, transform, shape); if encode_result { if let Some(brush_transform) = brush_transform && self @@ -403,14 +399,15 @@ impl Scene { /// Encodes the stroke of a shape using the specified style. The stroke style must have /// non-zero width. /// - /// This handles encoding the stroke style and shape, including dashing, but not, e.g., the - /// shape transform. If applicable, that should be handled by the caller. + /// This handles encoding the stroke style (including dashing), transform, and shape. /// /// Returns `true` if a non-zero number of segments were encoded. - fn stroke_gpu_inner(&mut self, style: &Stroke, shape: &impl Shape) -> bool { + fn stroke_gpu_inner(&mut self, style: &Stroke, transform: Affine, shape: &impl Shape) -> bool { // See the note about tolerances in `Self::stroke`. const SHAPE_TOLERANCE: f64 = 0.01; + let t = Transform::from_kurbo(&transform); + self.encoding.encode_transform(t); let encoded_stroke = self.encoding.encode_stroke_style(style); debug_assert!(encoded_stroke, "Stroke width is non-zero"); From 3d7369513a7e8e745256e399ad16ed0b95f367c2 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Mon, 5 Jan 2026 13:04:35 +0100 Subject: [PATCH 4/7] More transform stuff for `bump_estimate` --- vello/src/scene.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vello/src/scene.rs b/vello/src/scene.rs index ee383828e..e3416fa8a 100644 --- a/vello/src/scene.rs +++ b/vello/src/scene.rs @@ -244,7 +244,8 @@ impl Scene { { use peniko::kurbo::PathEl; let path = [PathEl::MoveTo(Point::ZERO), PathEl::LineTo(Point::ZERO)]; - self.estimator.count_path(path.into_iter(), &t, None); + self.estimator + .count_path(path.into_iter(), &Transform::IDENTITY, None); } } self.encoding.encode_begin_clip(parameters); From 55730966bf982de78a714cc6cd21ecdaf186fb10 Mon Sep 17 00:00:00 2001 From: Thomas Churchman Date: Mon, 5 Jan 2026 13:09:22 +0100 Subject: [PATCH 5/7] Clippy --- vello/src/scene.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/vello/src/scene.rs b/vello/src/scene.rs index e3416fa8a..078841bf3 100644 --- a/vello/src/scene.rs +++ b/vello/src/scene.rs @@ -415,7 +415,10 @@ impl Scene { // We currently don't support dashing on the GPU. If the style has a dash pattern, then // we convert it into stroked paths on the CPU and encode those as individual draw // objects. - let encode_result = if style.dash_pattern.is_empty() { + // + // Note both branches return a boolean indicating whether a non-zero number of segments + // were encoded. + if style.dash_pattern.is_empty() { #[cfg(feature = "bump_estimate")] self.estimator .count_path(shape.path_elements(SHAPE_TOLERANCE), &t, Some(style)); @@ -437,9 +440,7 @@ impl Scene { .count_path(dashed.iter().copied(), &t, Some(style)); self.encoding .encode_path_elements(dashed.into_iter(), false) - }; - - encode_result + } } /// Draws an image at its natural size with the given transform. From e9cd32f4908ec77304ff7f09b2b1be58dcf8d581 Mon Sep 17 00:00:00 2001 From: Tom Churchman Date: Thu, 8 Jan 2026 22:03:54 +0100 Subject: [PATCH 6/7] Changelog --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38928b5d4..7cc332c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,8 @@ This release has an [MSRV][] of 1.88. ### Changed - Breaking change: wgpu has been updated to wgpu 27. ([#1280][] by [@theoparis][]) -- Breaking change: Make `Scene` clip / layers honor fill rule (even-odd clips). ([#1332][] by [@waywardmonkeys][]) - When pushing a layer, you should use `Fill::NonZero` as the clip fill rule to achieve the same behavior as previous versions. +- Breaking change: Allow setting `Scene` layer clip shape drawing style, adding even-odd filled path clipping and stroked path clipping to the various scene layer methods (`Scene::{push_layer, push_luminance_mask_layer, push_clip_layer}`). ([#1332][] by [@waywardmonkeys][], [#1342][] by [@tomcur][]) + When pushing a layer, you should use `Fill::NonZero` as the clip draw style to achieve the same behavior as previous versions. ### Fixed @@ -386,6 +386,7 @@ This release has an [MSRV][] of 1.75. [#1280]: https://github.com/linebender/vello/pull/1280 [#1323]: https://github.com/linebender/vello/pull/1323 [#1332]: https://github.com/linebender/vello/pull/1332 +[#1342]: https://github.com/linebender/vello/pull/1342 [Unreleased]: https://github.com/linebender/vello/compare/v0.5.0...HEAD From 05611b361793b6e367ec53fda9af4a81a0554748 Mon Sep 17 00:00:00 2001 From: Tom Churchman Date: Thu, 8 Jan 2026 22:12:24 +0100 Subject: [PATCH 7/7] Changelog newline --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc332c1d..9e2de1231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ This release has an [MSRV][] of 1.88. ### Changed - Breaking change: wgpu has been updated to wgpu 27. ([#1280][] by [@theoparis][]) -- Breaking change: Allow setting `Scene` layer clip shape drawing style, adding even-odd filled path clipping and stroked path clipping to the various scene layer methods (`Scene::{push_layer, push_luminance_mask_layer, push_clip_layer}`). ([#1332][] by [@waywardmonkeys][], [#1342][] by [@tomcur][]) +- Breaking change: Allow setting `Scene` layer clip shape drawing style, adding even-odd filled path clipping and stroked path clipping to the various scene layer methods (`Scene::{push_layer, push_luminance_mask_layer, push_clip_layer}`). ([#1332][] by [@waywardmonkeys][], [#1342][] by [@tomcur][]) When pushing a layer, you should use `Fill::NonZero` as the clip draw style to achieve the same behavior as previous versions. ### Fixed