diff --git a/crates/anyrender/src/lib.rs b/crates/anyrender/src/lib.rs index 27b457ef..0637cbc2 100644 --- a/crates/anyrender/src/lib.rs +++ b/crates/anyrender/src/lib.rs @@ -187,10 +187,13 @@ pub trait PaintScene: RenderContext { fn reset(&mut self); /// Pushes a new layer clipped by the specified shape and composed with previous layers using the specified blend mode. + /// The fill rule determines which parts of the clip shape are inside the clipping region. /// Every drawing command after this call will be clipped by the shape until the layer is popped. /// However, the transforms are not saved or modified by the layer stack. + #[allow(clippy::too_many_arguments)] fn push_layer( &mut self, + fill: Fill, blend: impl Into, alpha: f32, transform: Affine, @@ -200,9 +203,10 @@ pub trait PaintScene: RenderContext { ); /// Pushes a new clip layer clipped by the specified shape. + /// The fill rule determines which parts of the clip shape are inside the clipping region. /// Every drawing command after this call will be clipped by the shape until the layer is popped. /// However, the transforms are not saved or modified by the layer stack. - fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape); + fn push_clip_layer(&mut self, fill: Fill, transform: Affine, clip: &impl Shape); /// Pops the current layer. fn pop_layer(&mut self); @@ -261,6 +265,7 @@ pub trait PaintScene: RenderContext { for cmd in scene.commands { match cmd { RenderCommand::PushLayer(cmd) => self.push_layer( + cmd.fill, cmd.blend, cmd.alpha, scene_transform * cmd.transform, @@ -269,7 +274,7 @@ pub trait PaintScene: RenderContext { cmd.backdrop_filter, ), RenderCommand::PushClipLayer(cmd) => { - self.push_clip_layer(scene_transform * cmd.transform, &cmd.clip) + self.push_clip_layer(cmd.fill, scene_transform * cmd.transform, &cmd.clip) } RenderCommand::PopLayer => self.pop_layer(), RenderCommand::Stroke(cmd) => self.stroke( diff --git a/crates/anyrender/src/null_backend.rs b/crates/anyrender/src/null_backend.rs index 9047aa80..e73d6196 100644 --- a/crates/anyrender/src/null_backend.rs +++ b/crates/anyrender/src/null_backend.rs @@ -98,6 +98,7 @@ impl PaintScene for NullScenePainter { fn push_layer( &mut self, + _fill: peniko::Fill, _blend: impl Into, _alpha: f32, _transform: kurbo::Affine, @@ -107,7 +108,13 @@ impl PaintScene for NullScenePainter { ) { } - fn push_clip_layer(&mut self, _transform: kurbo::Affine, _clip: &impl kurbo::Shape) {} + fn push_clip_layer( + &mut self, + _fill: peniko::Fill, + _transform: kurbo::Affine, + _clip: &impl kurbo::Shape, + ) { + } fn pop_layer(&mut self) {} diff --git a/crates/anyrender/src/recording.rs b/crates/anyrender/src/recording.rs index 85a82320..c7392b5e 100644 --- a/crates/anyrender/src/recording.rs +++ b/crates/anyrender/src/recording.rs @@ -55,6 +55,8 @@ impl RenderCommand { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct LayerCommand { + #[cfg_attr(feature = "serde", serde(default))] + pub fill: Fill, pub blend: BlendMode, pub alpha: f32, pub transform: Affine, @@ -70,6 +72,8 @@ pub struct LayerCommand { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct ClipCommand { + #[cfg_attr(feature = "serde", serde(default))] + pub fill: Fill, pub transform: Affine, #[cfg_attr(feature = "serde", serde(with = "svg_path"))] pub clip: BezPath, // TODO: more shape options @@ -178,6 +182,7 @@ impl PaintScene for Scene { fn push_layer( &mut self, + fill: Fill, blend: impl Into, alpha: f32, transform: Affine, @@ -188,6 +193,7 @@ impl PaintScene for Scene { let blend = blend.into(); let clip = clip.into_path(self.tolerance); let layer = LayerCommand { + fill, blend, alpha, transform, @@ -198,9 +204,13 @@ impl PaintScene for Scene { self.commands.push(RenderCommand::PushLayer(layer)); } - fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) { + fn push_clip_layer(&mut self, fill: Fill, transform: Affine, clip: &impl Shape) { let clip = clip.into_path(self.tolerance); - let layer = ClipCommand { transform, clip }; + let layer = ClipCommand { + fill, + transform, + clip, + }; self.commands.push(RenderCommand::PushClipLayer(layer)); } diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index e9bd5138..26893bf0 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -19,6 +19,37 @@ fn test_empty_scene_roundtrip() { assert_scene_roundtrip(&Scene::new()); } +#[test] +fn legacy_clips_default_to_nonzero() { + let mut scene = Scene::new(); + let clip = Rect::new(0.0, 0.0, 100.0, 100.0); + scene.push_layer( + Fill::NonZero, + Mix::Normal, + 1.0, + Affine::IDENTITY, + &clip, + None, + None, + ); + scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &clip); + let archive = SceneArchive::from_scene(&scene, &default_config()).unwrap(); + for command in &archive.commands { + let mut value = serde_json::to_value(command).unwrap(); + let fields = value + .as_object_mut() + .unwrap() + .values_mut() + .next() + .unwrap() + .as_object_mut() + .unwrap(); + assert!(fields.remove("fill").is_some()); + let restored: SerializableRenderCommand = serde_json::from_value(value).unwrap(); + assert_eq!(&restored, command); + } +} + /// Tests that all non-image and non-font command types survive a roundtrip. #[test] fn test_all_command_types_roundtrip() { @@ -26,6 +57,7 @@ fn test_all_command_types_roundtrip() { // Layer with blend mode scene.push_layer( + Fill::EvenOdd, Mix::Multiply, 0.75, Affine::translate((5.0, 5.0)), @@ -71,7 +103,11 @@ fn test_all_command_types_roundtrip() { ); // Clip layer - scene.push_clip_layer(Affine::scale(1.5), &Rect::new(50.0, 50.0, 150.0, 150.0)); + scene.push_clip_layer( + Fill::EvenOdd, + Affine::scale(1.5), + &Rect::new(50.0, 50.0, 150.0, 150.0), + ); scene.fill( Fill::NonZero, Affine::IDENTITY, @@ -83,6 +119,7 @@ fn test_all_command_types_roundtrip() { // Layer with compose blend mode scene.push_layer( + Fill::NonZero, Compose::SrcOver, 1.0, Affine::IDENTITY, @@ -450,7 +487,7 @@ fn assert_glyph_run_preserved(restored: &Scene) { match &restored.commands[0] { RenderCommand::GlyphRun(glyph_run) => { assert_eq!(glyph_run.font_size, 16.0); - assert_eq!(glyph_run.hint, false); + assert!(!glyph_run.hint); assert_eq!(glyph_run.brush_alpha, 1.0); assert_eq!(glyph_run.transform, Affine::translate((10.0, 50.0))); assert_eq!(glyph_run.glyph_transform, None); diff --git a/crates/anyrender_skia/src/scene.rs b/crates/anyrender_skia/src/scene.rs index c25c7b1e..aea443f8 100644 --- a/crates/anyrender_skia/src/scene.rs +++ b/crates/anyrender_skia/src/scene.rs @@ -113,15 +113,16 @@ impl SkiaScenePainter<'_> { self.inner.concat(&sk_kurbo::matrix_from_affine(transform)); } - fn clip(&self, shape: &impl kurbo::Shape) { + fn clip(&self, fill: peniko::Fill, shape: &impl kurbo::Shape) { if let Some(rect) = shape.as_rect() { self.inner.clip_rect(sk_kurbo::rect_from(rect), None, true); } else if let Some(rrect) = shape.as_rounded_rect() { self.inner .clip_rrect(sk_kurbo::rrect_from(rrect), None, true); } else { - self.inner - .clip_path(&sk_kurbo::path_from_shape(shape), None, true); + let mut path = sk_kurbo::path_from_shape(shape); + path.set_fill_type(sk_peniko::path_fill_type_from_fill(fill)); + self.inner.clip_path(&path, None, true); } } @@ -426,6 +427,7 @@ impl PaintScene for SkiaScenePainter<'_> { fn push_layer( &mut self, + fill: peniko::Fill, blend: impl Into, alpha: f32, transform: kurbo::Affine, @@ -446,7 +448,7 @@ impl PaintScene for SkiaScenePainter<'_> { self.inner.save(); self.set_matrix(transform); - self.clip(clip); + self.clip(fill, clip); let backdrop_filter = backdrop_filter.as_ref().and_then(|f| convert_filter(f)); let mut save_layer_rec = SaveLayerRec::default().paint(&self.cache.paint); @@ -457,11 +459,16 @@ impl PaintScene for SkiaScenePainter<'_> { self.inner.save_layer(&save_layer_rec); } - fn push_clip_layer(&mut self, transform: kurbo::Affine, clip: &impl kurbo::Shape) { + fn push_clip_layer( + &mut self, + fill: peniko::Fill, + transform: kurbo::Affine, + clip: &impl kurbo::Shape, + ) { self.inner.save(); // we need to do two saves because of pop_layer self.set_matrix(transform); - self.clip(clip); + self.clip(fill, clip); self.inner.save(); } diff --git a/crates/anyrender_svg/src/lib.rs b/crates/anyrender_svg/src/lib.rs index 2cdab8fe..3370fa2f 100644 --- a/crates/anyrender_svg/src/lib.rs +++ b/crates/anyrender_svg/src/lib.rs @@ -86,8 +86,60 @@ pub fn render_svg_tree_with( #[cfg(test)] mod tests { - // CI will fail unless cargo nextest can execute at least one test per workspace. - // Delete this dummy test once we have an actual real test. + use super::*; + use anyrender::recording::{RenderCommand, Scene}; + use peniko::Fill; + + fn clip_rule(clip_attrs: &str, path_attrs: &str) -> Fill { + let svg = format!( + r##" + + + + + "## + ); + let mut scene = Scene::new(); + render_svg_str(&mut scene, &svg, Affine::IDENTITY).unwrap(); + scene + .commands + .iter() + .find_map(|command| match command { + RenderCommand::PushLayer(layer) => Some(layer.fill), + _ => None, + }) + .expect("SVG should produce a clipping layer") + } + + #[test] + fn clip_rule_attribute_is_preserved() { + assert_eq!(clip_rule("", ""), Fill::NonZero); + assert_eq!(clip_rule("", r#"clip-rule="nonzero""#), Fill::NonZero); + assert_eq!(clip_rule("", r#"clip-rule="evenodd""#), Fill::EvenOdd); + } + + #[test] + fn clip_rule_style_overrides_attribute_and_inherits() { + assert_eq!( + clip_rule("", r#"clip-rule="nonzero" style="clip-rule: evenodd""#), + Fill::EvenOdd + ); + assert_eq!( + clip_rule(r#"style="clip-rule: evenodd""#, ""), + Fill::EvenOdd + ); + assert_eq!( + clip_rule(r#"clip-rule="evenodd""#, r#"style="clip-rule: nonzero""#), + Fill::NonZero + ); + } + #[test] - fn dummy_test_until_we_have_a_real_test() {} + fn fill_rule_does_not_control_clipping() { + assert_eq!(clip_rule("", r#"fill-rule="evenodd""#), Fill::NonZero); + assert_eq!( + clip_rule("", r#"fill-rule="nonzero" clip-rule="evenodd""#), + Fill::EvenOdd + ); + } } diff --git a/crates/anyrender_svg/src/render.rs b/crates/anyrender_svg/src/render.rs index 3049f481..2e9e1c4e 100644 --- a/crates/anyrender_svg/src/render.rs +++ b/crates/anyrender_svg/src/render.rs @@ -32,6 +32,10 @@ pub(crate) fn render_group( Some(usvg::Node::Path(clip_path)) => { let local_path = util::to_bez_path(clip_path); scene.push_layer( + match clip_path.fill().map(|fill| fill.rule()) { + Some(usvg::FillRule::EvenOdd) => Fill::EvenOdd, + _ => Fill::NonZero, + }, BlendMode { mix, compose: peniko::Compose::SrcOver, @@ -61,6 +65,7 @@ pub(crate) fn render_group( .transform_rect_bbox(rect) .inflate(2.0, 2.0); scene.push_layer( + Fill::NonZero, BlendMode { mix, compose: peniko::Compose::SrcOver, diff --git a/crates/anyrender_vello/src/scene.rs b/crates/anyrender_vello/src/scene.rs index 0bfe4525..2286a79e 100644 --- a/crates/anyrender_vello/src/scene.rs +++ b/crates/anyrender_vello/src/scene.rs @@ -68,6 +68,7 @@ impl PaintScene for VelloScenePainter<'_, '_> { fn push_layer( &mut self, + fill: Fill, blend: impl Into, alpha: f32, transform: Affine, @@ -75,12 +76,11 @@ impl PaintScene for VelloScenePainter<'_, '_> { _filter: Option>, _backdrop_filter: Option>, ) { - self.inner - .push_layer(Fill::NonZero, blend, alpha, transform, clip); + self.inner.push_layer(fill, blend, alpha, transform, clip); } - fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) { - self.inner.push_clip_layer(Fill::NonZero, transform, clip); + fn push_clip_layer(&mut self, fill: Fill, transform: Affine, clip: &impl Shape) { + self.inner.push_clip_layer(fill, transform, clip); } fn pop_layer(&mut self) { diff --git a/crates/anyrender_vello_cpu/src/scene.rs b/crates/anyrender_vello_cpu/src/scene.rs index d659ed69..0d7a0910 100644 --- a/crates/anyrender_vello_cpu/src/scene.rs +++ b/crates/anyrender_vello_cpu/src/scene.rs @@ -72,6 +72,7 @@ impl PaintScene for VelloCpuScenePainter { fn push_layer( &mut self, + fill: Fill, blend: impl Into, alpha: f32, transform: Affine, @@ -91,6 +92,7 @@ impl PaintScene for VelloCpuScenePainter { }; self.render_ctx.set_transform(transform); + self.render_ctx.set_fill_rule(fill); self.render_ctx.push_layer( Some(&clip.into_path(DEFAULT_TOLERANCE)), Some(blend.into()), @@ -100,8 +102,9 @@ impl PaintScene for VelloCpuScenePainter { ); } - fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) { + fn push_clip_layer(&mut self, fill: Fill, transform: Affine, clip: &impl Shape) { self.render_ctx.set_transform(transform); + self.render_ctx.set_fill_rule(fill); self.render_ctx .push_clip_layer(&clip.into_path(DEFAULT_TOLERANCE)); } @@ -211,3 +214,79 @@ impl PaintScene for VelloCpuScenePainter { .fill_blurred_rounded_rect(&rect, radius as f32, std_dev as f32, false); } } + +#[cfg(test)] +mod clip_rule_tests { + use anyrender::{PaintScene, recording::Scene, render_to_buffer}; + use kurbo::{Affine, BezPath, Rect}; + use peniko::{Fill, Mix, color::palette::css::RED}; + + use crate::VelloCpuImageRenderer; + + fn render_clip(fill: Fill, compositing_layer: bool, replay: bool) -> Vec { + let path = BezPath::from_svg("M0 0H100V100H0Z M25 25H75V75H25Z").unwrap(); + let draw = |scene: &mut crate::VelloCpuScenePainter| { + if replay { + let mut recording = Scene::new(); + draw_clipped(&mut recording, fill, compositing_layer, &path); + scene.append_scene(recording, Affine::IDENTITY); + } else { + draw_clipped(scene, fill, compositing_layer, &path); + } + }; + render_to_buffer::(draw, 100, 100) + } + + fn draw_clipped( + scene: &mut impl PaintScene, + fill: Fill, + compositing_layer: bool, + path: &BezPath, + ) { + if compositing_layer { + scene.push_layer(fill, Mix::Normal, 1.0, Affine::IDENTITY, path, None, None); + } else { + scene.push_clip_layer(fill, Affine::IDENTITY, path); + } + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + RED, + None, + &Rect::new(0.0, 0.0, 100.0, 100.0), + ); + scene.pop_layer(); + } + + fn assert_pixels(compositing_layer: bool, replay: bool) { + for fill in [Fill::NonZero, Fill::EvenOdd] { + let buffer = render_clip(fill, compositing_layer, replay); + let pixel = |x: usize, y: usize| &buffer[(y * 100 + x) * 4..(y * 100 + x) * 4 + 4]; + assert_eq!(pixel(10, 10), &[255, 0, 0, 255]); + assert_eq!( + pixel(50, 50), + if fill == Fill::EvenOdd { + &[0, 0, 0, 0] + } else { + &[255, 0, 0, 255] + } + ); + } + } + + #[test] + fn clip_layers_respect_fill_rule() { + assert_pixels(false, false); + } + + #[test] + fn compositing_layers_respect_fill_rule() { + assert_pixels(true, false); + } + + #[test] + fn scene_replay_preserves_clip_rules() { + assert_pixels(false, true); + assert_pixels(true, true); + } +} diff --git a/crates/anyrender_vello_hybrid/src/scene.rs b/crates/anyrender_vello_hybrid/src/scene.rs index 35fa06a1..0dc00fbe 100644 --- a/crates/anyrender_vello_hybrid/src/scene.rs +++ b/crates/anyrender_vello_hybrid/src/scene.rs @@ -174,6 +174,7 @@ impl PaintScene for VelloHybridScenePainter<'_> { fn push_layer( &mut self, + fill: Fill, blend: impl Into, alpha: f32, transform: Affine, @@ -183,6 +184,7 @@ impl PaintScene for VelloHybridScenePainter<'_> { ) { let filter = filter.and_then(crate::filters::convert_filter); self.scene.set_transform(transform); + self.scene.set_fill_rule(fill); self.layer_stack.push(LayerKind::Layer); self.scene .push_clip_path(&clip.into_path(DEFAULT_TOLERANCE)); @@ -190,8 +192,9 @@ impl PaintScene for VelloHybridScenePainter<'_> { .push_layer(None, Some(blend.into()), Some(alpha), None, filter); } - fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) { + fn push_clip_layer(&mut self, fill: Fill, transform: Affine, clip: &impl Shape) { self.scene.set_transform(transform); + self.scene.set_fill_rule(fill); self.layer_stack.push(LayerKind::Clip); self.scene .push_clip_path(&clip.into_path(DEFAULT_TOLERANCE)); diff --git a/crates/anyrender_vello_hybrid/src/webgl_scene.rs b/crates/anyrender_vello_hybrid/src/webgl_scene.rs index 2f1b1da9..15c3d61b 100644 --- a/crates/anyrender_vello_hybrid/src/webgl_scene.rs +++ b/crates/anyrender_vello_hybrid/src/webgl_scene.rs @@ -105,6 +105,7 @@ impl PaintScene for WebGlScenePainter<'_> { fn push_layer( &mut self, + fill: Fill, blend: impl Into, alpha: f32, transform: Affine, @@ -114,6 +115,7 @@ impl PaintScene for WebGlScenePainter<'_> { ) { let filter = filter.and_then(crate::filters::convert_filter); self.scene.set_transform(transform); + self.scene.set_fill_rule(fill); self.layer_stack.push(LayerKind::Layer); self.scene .push_clip_path(&clip.into_path(DEFAULT_TOLERANCE)); @@ -121,8 +123,9 @@ impl PaintScene for WebGlScenePainter<'_> { .push_layer(None, Some(blend.into()), Some(alpha), None, filter); } - fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) { + fn push_clip_layer(&mut self, fill: Fill, transform: Affine, clip: &impl Shape) { self.scene.set_transform(transform); + self.scene.set_fill_rule(fill); self.layer_stack.push(LayerKind::Clip); self.scene .push_clip_path(&clip.into_path(DEFAULT_TOLERANCE)); diff --git a/examples/serialize/src/main.rs b/examples/serialize/src/main.rs index 961782e0..171b21ab 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -104,6 +104,7 @@ fn create_demo_scene() -> Scene { // Draw some circles using layers with blend modes scene.push_layer( + Fill::NonZero, Mix::Multiply, 0.8, Affine::IDENTITY, @@ -143,6 +144,7 @@ fn create_demo_scene() -> Scene { // Draw a clipped region scene.push_clip_layer( + Fill::NonZero, Affine::IDENTITY, &Circle::new(Point::new(100.0, 220.0), 60.0), );