Render: blend steps' drop shadows draw under an opacity mask with Clip off

An object with an opacity mask draws offscreen over the bounds the
renderer gives it. For a live object (a blend, envelope or repeat) those
bounds took in effects in symbol art and brush art inside it, but not a
path's own effects, so with Clip off the drop shadows of a blend's steps
were cut off at the steps' bounds and did not draw where the mask art
leaves them uncovered. The bounds now also take in how far each path's
own effects paint past it.
This commit is contained in:
dotCooCoo
2026-11-12 05:09:18 -08:00
parent 61650e44a2
commit 438a39fdf4
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.