From be9223a9ed495753552cd37b368604672cb4f885 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 05:58:28 +0900 Subject: [PATCH] M8: stacked layer blurs keep the margin they reach together Layer blurs and texture apply one after another, so together they reach further than any one of them, and drop shadows are cast from the blurred layer. effect_margin took the largest single effect, so a layer cut at a view's or band's edge missed content that still reached in: four blurs of 25 drawn in 64-row bands differed from a render in one piece by 8 levels, and a blurred rectangle above the view cast its shadow into it 143 levels off. The margin is now the layer effects' reaches added up, plus the largest of the others; each reach counts up to 1e12 so the sum stays finite. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/draw.rs | 28 +++++++--- crates/render/src/retained_tests.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 4ef427e4..a30c7bb0 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -472,17 +472,32 @@ pub(crate) fn bounds_within<'a>( (r, levels) } +/// The most one effect counts for in [`effect_margin`] (document units): far past any view, and +/// sums of any number of them stay finite. +const MAX_REACH: f64 = 1e12; + /// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass, /// drop shadows and texture. Culling and partial renders keep this much margin. +/// +/// Layer blurs and texture apply one after another, each moving what the one before it moved, so +/// their reaches add up. What the other effects paint goes through them or is cast from their +/// result (drop shadows from the blurred layer, a backdrop blurred with it), so the largest of +/// those reaches comes on top. Each reach counts up to [`MAX_REACH`], so the sum stays finite. pub fn effect_margin(n: &Node) -> f64 { - n.effects - .iter() - .filter(|e| e.visible) + let visible = || n.effects.iter().filter(|e| e.visible); + let finite = |m: f64| if m.is_finite() { m.clamp(0.0, MAX_REACH) } else { 0.0 }; + let layer: f64 = visible() .map(|e| match &e.kind { EffectKind::LayerBlur(b) => b.max_radius() * 2.0, + EffectKind::Texture(t) => t.radius.abs(), + _ => 0.0, + }) + .map(finite) + .sum(); + let others = visible() + .map(|e| match &e.kind { EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0, EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(), - EffectKind::Texture(t) => t.radius.abs(), // Inner shadows cast from around a layer's pixels (groups, image fills with alpha; any // image counts here): a layer cut off at the view would cast one from the cut. EffectKind::InnerShadow(s) @@ -492,8 +507,9 @@ pub fn effect_margin(n: &Node) -> f64 { } _ => 0.0, }) - .filter(|m| m.is_finite()) - .fold(0.0, f64::max) + .map(finite) + .fold(0.0, f64::max); + layer + others } /// Everything node `id` can paint, in page coordinates: its paint bounds (glyphs of overflowing diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 518033ae..bdc1be19 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -373,3 +373,84 @@ fn stacked_backdrop_blurs_draw_the_same_in_bands() { assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows")); } } + +/// Layer blurs stacked on one layer apply one after another, so they reach as far as all of them +/// together: a layer cut at a band's edge keeps that much margin, and the bands (the browser's, +/// the desktop's) match a render in one piece, as does a view cut across the blur. +#[test] +fn sequential_layer_blurs_draw_the_same_in_bands() { + fonts(); + for (count, radius) in [(4, 25.0), (4, 100.0), (8, 10.0)] { + let mut d = Document::new("sequential"); + let page = d.first_page().unwrap(); + for i in 0..40 { + let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap(); + solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" }); + } + let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 150.0, 200.0, 100.0).unwrap(); + solid(d.get_mut(b).unwrap(), "20C040"); + for _ in 0..count { + d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(radius)), visible: true }); + } + let doc = Arc::new(d); + let what = format!("{count} blurs of {radius}"); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }; + for band_h in [64, 100] { + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h); + assert!(n >= 4, "{what}: {n} bands of {band_h} rows"); + let one = crate::render_view_single(&doc, page, &shown, &Options::default()).unwrap(); + assert_within(&pix, &one, 0, 2, &format!("{what}, bands of {band_h} rows")); + } + // The desktop splits a view this large into parallel bands. + let big = View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 800 }; + let one = crate::render_view_single(&doc, page, &big, &Options::default()).unwrap(); + assert_within(&render_view(&doc, page, &big, &Options::default()).unwrap(), &one, 0, 2, &format!("{what}, parallel bands")); + // A view starting halfway down the blur: its rows as in the whole view. + let cut = View { origin: (0.0, 200.0), scale: 1.0, width: 800, height: 600 }; + let part = crate::render_view_single(&doc, page, &cut, &Options::default()).unwrap(); + assert_within(&part, &one, 200, 2, &format!("{what}, view cut across the blur")); + } +} + +/// A drop shadow is cast from the blurred layer: a layer cut at the view keeps the blur's reach on +/// top of the shadow's, or a rectangle above the view casts a shadow into it from a cut blur. +#[test] +fn a_shadow_cast_from_a_blurred_layer_draws_the_same_in_a_cut_view() { + fonts(); + let mut d = Document::new("shadowed blur"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 0.0, 200.0, 100.0).unwrap(); + solid(d.get_mut(b).unwrap(), "20C040"); + let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 300.0), radius: 0.0, ..Shadow::default() }; + d.get_mut(b).unwrap().effects = vec![ + Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(50.0)), visible: true }, + Effect { kind: EffectKind::DropShadow(shadow), visible: true }, + ]; + let whole = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 600 }; + let one = crate::render_view_single(&d, page, &whole, &Options::default()).unwrap(); + // The view starts below the rectangle, near the bottom of its shadow: a margin of the shadow's + // reach alone would cut the layer across the rectangle. + let cut = View { origin: (0.0, 390.0), scale: 1.0, width: 400, height: 210 }; + let part = crate::render_view_single(&d, page, &cut, &Options::default()).unwrap(); + assert_within(&part, &one, 390, 2, "the shadow of a blurred layer above the view"); +} + +/// Reaches too large to add (texture of 1e308, twice) still keep a margin, not none. +#[test] +fn effect_margins_too_large_to_add_stay_large() { + let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "T", 10.0, 10.0); + let t = figcraft_doc::Texture { radius: 1e308, ..Default::default() }; + n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }]; + let m = crate::draw::effect_margin(&n); + assert!(m.is_finite() && m > 1e6, "{m}"); +} + +/// Every channel of `a` within `tol` of the same channel of `b` from row `rows_down` on. +fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) { + assert_eq!(a.width(), b.width(), "{what}: widths"); + let row = a.width() as usize * 4; + let below = b.data().get(rows_down as usize * row..).unwrap(); + let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + assert!(worst <= tol, "{what}: differs by {worst} levels"); +}