From 438a39fdf4577eb2af46675532851442fe8036e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:09:18 -0800 Subject: [PATCH] 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. --- crates/render/src/fx/bounds.rs | 20 ++++++++++++++--- crates/render/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 9d5e9c0d1a..4b5055e67f 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -505,17 +505,31 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { /// 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 diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 83f6131071..9cf4ac9ca0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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| { + 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| { + 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.