Merge pull request #4789 from dotCooCoo/fix/mask-root-node-mask

Render: an object used as an opacity mask keeps its own opacity mask
This commit is contained in:
BFlat
2026-10-31 20:11:06 +07:00
committed by GitHub
4 changed files with 184 additions and 17 deletions
+31 -1
View File
@@ -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]
+1 -1
View File
@@ -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() })
+31 -15
View File
@@ -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<Node>, 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<Node>) {
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<Vec<u8>> {
@@ -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<Node>) {
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<Node>], 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);
+121
View File
@@ -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};