diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index cb370520f..c19bfb6e6 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -212,7 +212,7 @@ impl Renderer { self.draw_kept_node(ctx, f, p); self.fx_owner = owner; } else { - self.draw_node(ctx, f, p, true); + self.draw_forced_arc(ctx, f, p); } } (self.made_again, self.cur) = outer; @@ -260,6 +260,36 @@ mod tests { assert_eq!(again.pixels, img.pixels); } + #[test] + fn art_brush_piece_applies_its_root_mask() { + use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization}; + use vectorcraft_doc::OpacityMask; + + let mut d = doc_with_line(Some("Masked art"), 1.0); + let mut art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, -5.0, 10.0, 5.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + let black = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + art.extras_mut().mask = Some(Box::new(OpacityMask::new(black, true))); + let brush = Brush { + name: "Masked art".into(), + kind: BrushKind::Art(ArtBrush { art, scale: ArtScale::Stretch, colorization: Colorization::None, ..Default::default() }), + }; + vectorcraft_brush::store(&mut d, &[brush]); + + let mut renderer = Renderer::new(); + for _ in 0..2 { + let img = render(&mut renderer, &d); + assert_eq!(img.pixel(100, 50), [255, 255, 255, 255]); + } + } + /// Object opacity fades a brushed stroke's art as a whole, with or without a dash and a fill: /// the same as the object at full opacity inside a group at that opacity. #[test] diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 26702d57b..3593bf041 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -361,7 +361,7 @@ impl Renderer { self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); - self.draw_node(&mut ctx, &frame, n, true); + self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); self.end_frame(); Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index edf3409ac..6586be0b3 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -157,23 +157,39 @@ impl Renderer { self.stats.drawn += 1; return; } - if let Some(m) = a.extras().mask.as_deref() - && !m.disabled - && !f.opts.outline - && !self.is_shape(a) - { - // A masked object is a non-isolated group of its own, drawn over its own pixels: the - // mask is rendered over those only. - let bounds = self.bounds_of(f.symbols, a); - let Some((r, sub)) = group::area(ctx, f, bounds) else { return }; - let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return }; - let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); - self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); + if self.draw_masked_arc(ctx, f, a, false) { return; } self.draw_arc_body(ctx, f, a); } + /// Draw `a` through its opacity mask when it has one. Forced roots draw regardless of their + /// visibility and bounds. + fn draw_masked_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, force: bool) -> bool { + let Some(m) = a.extras().mask.as_deref().filter(|m| !m.disabled && !f.opts.outline && !self.is_shape(a)) else { return false }; + // A masked object is a non-isolated group of its own, drawn over its own pixels: the + // mask is rendered over those only. + let bounds = self.bounds_of(f.symbols, a); + let Some((r, sub)) = group::area(ctx, f, bounds) else { return true }; + let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return true }; + let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); + self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| { + if force { + r.draw_node(c, fr, a, true); + } else { + r.draw_arc_body(c, fr, a); + } + }); + true + } + + /// Draw an `Arc` regardless of its visibility and bounds, applying its opacity mask. + pub(crate) fn draw_forced_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + if !self.draw_masked_arc(ctx, f, a, true) { + self.draw_node(ctx, f, a, true); + } + } + /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, /// as luminance ([`mask_value`]). pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option> { @@ -489,7 +505,7 @@ impl Renderer { if kept { self.draw_kept_node(ctx, f, art); } else { - self.draw_node(ctx, f, art, true); + self.draw_forced_arc(ctx, f, art); } self.made_again = outer; } @@ -500,7 +516,7 @@ impl Renderer { pub(crate) fn draw_kept_node(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { let live = vectorcraft_doc::live::is_live(a).then(|| a.clone()); let outer = std::mem::replace(&mut self.live_root, live); - self.draw_node(ctx, f, a, true); + self.draw_forced_arc(ctx, f, a); self.live_root = outer; } @@ -518,7 +534,7 @@ impl Renderer { fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], view: TraceView, knockout: bool) { let Some((source, shapes)) = children.split_first() else { return }; if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) { - self.draw_node(ctx, f, source, true); + self.draw_forced_arc(ctx, f, source); } if view.shows_result() { self.draw_children(ctx, f, shapes, knockout); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 6b6ec3e79..6f15ae313 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -662,6 +662,39 @@ fn trace_source_draws_the_source_image_of_the_listed_objects() { } } +#[test] +fn trace_source_applies_the_hidden_image_mask() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut png = Vec::new(); + image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = Document::new(100.0, 100.0); + d.images.insert("trace-source".into(), ImageBlob::new("image/png", png)); + let image = ImageObject { + key: "trace-source".into(), + width: 1, + height: 1, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(80.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut source = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + source.visible = false; + let mask = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + source.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); + let result = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let mut trace = Node::group(d.alloc_id(), vec![Arc::new(source), Arc::new(result)]); + trace.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": "tracingResult" }))); + let trace_id = trace.id; + d.insert(Some(d.layers[0].id), 0, trace).unwrap(); + + let opts = RenderOptions { trace_views: true, trace_source: Some(Arc::new(vec![trace_id])), background: Some([255; 4]), ..Default::default() }; + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]); +} + #[test] fn artboards_white_on_pasteboard() { let d = Document::new(50.0, 50.0); @@ -747,6 +780,94 @@ fn opacity_mask_without_clip_and_inverted() { assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible"); } +#[test] +fn opacity_mask_art_root_applies_its_own_mask() { + use vectorcraft_doc::OpacityMask; + + let mut d = Document::new(100.0, 100.0); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut art = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let inner = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + art.extras_mut().mask = Some(Box::new(OpacityMask::new(inner, true))); + art.visible = false; + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + let object_id = object.id; + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut renderer = Renderer::new(); + let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]); + let view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts }); + assert_eq!(view.pixel(30, 50), [255, 255, 255, 255]); + assert_eq!(view.pixel(70, 50), [0, 0, 0, 255]); +} + +#[test] +fn undecodable_image_in_mask_art_root_mask_leaves_out_outer_object() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + d.images.insert("broken-root-mask-image".into(), ImageBlob::new("image/png", b"not a PNG".to_vec())); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut art = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let image = ImageObject { + key: "broken-root-mask-image".into(), + width: 20, + height: 20, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + art.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))), false))); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + let object_id = object.id; + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255; 4]), ..Default::default() }; + let mut renderer = Renderer::new(); + let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]); + let view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts }); + assert_eq!(view.pixel(70, 50), [0, 0, 0, 255]); +} + +#[test] +fn placed_symbol_art_root_applies_its_own_mask() { + use vectorcraft_doc::{NodeKind, OpacityMask}; + + for grouped in [true, false] { + let mut d = Document::new(100.0, 100.0); + let red = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut art = if grouped { Node::group(d.alloc_id(), vec![Arc::new(red)]) } else { red }; + let inner = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + art.extras_mut().mask = Some(Box::new(OpacityMask::new(inner, true))); + d.symbols.push(vectorcraft_doc::Symbol { name: "Masked".into(), art: Arc::new(art) }); + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Masked".into(), xf: Affine::IDENTITY }); + d.insert(Some(d.layers[0].id), 0, instance).unwrap(); + + let img = render(&d); + assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "grouped {grouped}"); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "grouped {grouped}"); + } +} + +#[test] +fn node_thumbnail_applies_the_root_mask() { + use vectorcraft_doc::OpacityMask; + + let mut d = Document::new(100.0, 100.0); + let mut node = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mask = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + node.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true))); + + let img = Renderer::new().render_node_thumbnail(&d, &node, 100, Some([255; 4])).unwrap(); + assert_eq!(img.pixel(25, 50), [255, 0, 0, 255]); + assert_eq!(img.pixel(75, 50), [255, 255, 255, 255]); +} + #[test] fn opacity_mask_with_undecodable_image_leaves_out_object() { use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};