Merge pull request #6868 from dotCooCoo/fix/blend-shadow-unclipped-mask

Render: blend steps' drop shadows draw under an opacity mask with Clip off
This commit is contained in:
BFlat
2026-11-12 22:01:05 +07:00
committed by GitHub
2 changed files with 56 additions and 3 deletions
+17 -3
View File
@@ -505,17 +505,31 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option<Rect> {
/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual
/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that
/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the
/// farthest side. 0 for any other kind of object.
/// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far
/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any
/// other kind of object.
fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
if !vectorcraft_doc::live::is_live(n) {
return 0.0;
}
let mut reach = 0.0f64;
n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c)));
n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c)));
reach
}
/// How far the effects of path or compound path `n` (a drop shadow, a glow, a geometry effect)
/// paint past its visual bounds, on its farthest side ([`visual_bounds`]): 0 when it has no
/// visible effects, and for any other kind of object.
fn path_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
if !has_fx(n) {
return 0.0;
}
match (visual_bounds(symbols, n), n.visual_bounds_with(symbols)) {
(Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max),
_ => 0.0,
}
}
/// How far the effects in the art of symbol instance `n` (and in the art of the symbols the
/// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the
/// art as the instance places it before its effects run: 0 when the art has no visible effects, and
+39
View File
@@ -1260,6 +1260,45 @@ fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() {
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask");
}
/// The drop shadows of a blend's steps draw under an opacity mask with Clip off, where the mask
/// art leaves them uncovered, as they draw without the mask; with Clip on they are hidden.
#[test]
fn blend_step_shadows_draw_under_an_unclipped_opacity_mask() {
use vectorcraft_doc::live::{BlendSpacing, BlendSpec};
use vectorcraft_doc::{NodeKind, OpacityMask};
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 15, "y": 15, "blur": 0}), visible: true };
let blend = |d: &mut Document, mask: Option<bool>| {
let end = |d: &mut Document, x: f64| {
let mut n = rect_node(d, Rect::new(x, 10.0, x + 10.0, 20.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
n.appearance.effects.push(shadow.clone());
Arc::new(n)
};
let ends = vec![end(d, 10.0), end(d, 70.0)];
let spec = BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) });
if let Some(clip) = mask {
let white = rect_node(d, Rect::new(0.0, 0.0, 5.0, 5.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, clip)));
}
n
};
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let draw = |mask: Option<bool>| {
let mut d = Document::new(120.0, 60.0);
let n = blend(&mut d, mask);
d.insert(Some(d.layers[0].id), 0, n).unwrap();
Renderer::new().render(&d, 120, 60, Affine::IDENTITY, &opts)
};
let (plain, unclipped, clipped) = (draw(None), draw(Some(false)), draw(Some(true)));
for (x, y) in [(30, 30), (90, 32)] {
assert!(plain.pixel(x, y)[0] < 128, "the shadow draws at ({x}, {y}) without a mask: {:?}", plain.pixel(x, y));
assert_eq!(unclipped.pixel(x, y), plain.pixel(x, y), "the shadow draws at ({x}, {y}) under the unclipped mask");
assert_eq!(clipped.pixel(x, y), [255, 255, 255, 255], "the clipped mask hides the shadow at ({x}, {y})");
}
assert_eq!(unclipped.pixel(15, 15), plain.pixel(15, 15), "the first end draws under the unclipped mask");
}
/// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300
/// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and
/// still draw where their masks show them.