From 8aa8bb4d9d8a63ffbcd6e196d118b6e84a89f8f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 18:53:25 -0700 Subject: [PATCH] Render: a mask art root, symbol art root or thumbnail keeps its own opacity mask The renderer drew the root node of an opacity mask's art, of placed symbol art, of an art brush piece, of an Image Trace source image and of a node thumbnail without its own opacity mask. Make Opacity Mask uses the top object as the mask art, so a mask made from an object that already had an opacity mask ignored that inner mask. These roots now draw through the same masked group as any other node, still drawn whatever their visibility and bounds, and an incomplete inner mask still leaves out the outer object. --- crates/render/src/brush_fx.rs | 32 +++++++- crates/render/src/renderer.rs | 2 +- crates/render/src/renderer/nodes.rs | 46 +++++++---- crates/render/src/tests.rs | 121 ++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 17 deletions(-) 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};