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.