diff --git a/crates/render/src/backdrop_shadow_tests.rs b/crates/render/src/backdrop_shadow_tests.rs new file mode 100644 index 00000000..9c5fb562 --- /dev/null +++ b/crates/render/src/backdrop_shadow_tests.rs @@ -0,0 +1,79 @@ +//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer +//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its +//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow +//! that shows behind transparent areas is seen, blurred, through the layer. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow}; + +use super::*; + +/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80 +/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half) +/// and, when `blur`, a background blur. +fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) { + let mut d = Document::new("shadow behind blur"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() { + let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())]; + } + let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))]; + let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() }; + n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }]; + if blur { + n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true }); + } + (d, f) +} + +fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { + let i = ((y * p.width() + x) * 4) as usize; + [p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]] +} + +/// The largest channel difference between `a` and `b` over the shadow below the pane +/// (60..180 × 122..160). +fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 { + let mut worst = 0; + for y in 122..160 { + for x in 60..180 { + worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap()); + } + } + worst +} + +#[test] +fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() { + for show_behind in [false, true] { + let (with_blur, f) = scene(show_behind, true); + let (without, _) = scene(show_behind, false); + let got = render_node(&with_blur, f, 1.0).unwrap(); + let want = render_node(&without, f, 1.0).unwrap(); + let diff = max_diff_below_pane(&got, &want); + assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}"); + // A 20% fill casts a faint shadow: the white between the stripes stays light. + let light = px(&got, 120, 140); + assert!(light[0] > 180, "show behind {show_behind}: {light:?}"); + } +} + +#[test] +fn a_shadow_showing_behind_is_seen_through_the_background_blur() { + let (d, f) = scene(true, true); + let got = render_node(&d, f, 1.0).unwrap(); + // Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow + // under it, from y = 80). + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}"); + // Hidden behind the layer, it doesn't. + let (d, f) = scene(false, true); + let got = render_node(&d, f, 1.0).unwrap(); + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b27..af73aeeb 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -423,16 +423,15 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep let shape = node_outline(ctx.doc, n); // Background blur and glass: the whole backdrop under the layer (every surface below this // one, composited), through the shape, under the content. - if backdrop::has_effects(n) - && let Some(sh) = &backdrop::outline(ctx.doc, n) - { - let pad = backdrop::pad(n, ctx.scale); - let p = i32::try_from(pad).unwrap_or(0); - if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2) - && let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) - { - layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); - } + let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None }; + let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf)); + // With drop shadows too, the layer casts them from what it paints, not from the backdrop, and + // the backdrop is read again once they lie under it (a shadow showing behind is seen through + // it). Until then, the layer's content sees the backdrop read first under it (#255). + let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); + let first_back = read_backdrop(pix); + if !casts && let Some(b) = &first_back { + layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } let op = n.opacity.clamp(0.0, 1.0); { @@ -448,9 +447,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } }; let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_))); - // Backdrops further down see this layer composited onto `pix`. + // Backdrops further down see this layer composited onto `pix` (over its backdrop, when + // that is kept apart for now). let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) }; - draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); + let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under))); + let content_under = over_back.as_ref().unwrap_or(&under); + draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); } // A group is no outline of its own: its inner shadows are cast from around what its layers // draw together, over them. @@ -465,24 +467,58 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } } // Layer blur, noise and texture, in list order, over everything else. + if !casts { + layer_effects(ctx, &mut layer, n, id, lxf); + } + // Drop shadows go under the layer, in effect-list order. + { + // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content + // with the layer effects it will get. + let content = casts.then(|| { + let mut c = layer.clone(); + layer_effects(ctx, &mut c, n, id, lxf); + c + }); + let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale); + for e in n.effects.iter().filter(|e| e.visible) { + if let EffectKind::DropShadow(s) = &e.kind + && let Some(sh) = shadow::drop_shadow(&caster, s) + { + composite(pix, (lx, ly), &sh, op, s.blend); + } + } + } + if casts { + // The backdrop with the shadows under it, the content over it, then the layer effects. + if let Some(mut b) = read_backdrop(pix) { + b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + layer = b; + } + layer_effects(ctx, &mut layer, n, id, lxf); + } + composite(pix, (lx, ly), &layer, op, n.blend); +} + +/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on +/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it. +fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option { + let ((lx, ly), (lw, lh)) = (at, size); + let pad = backdrop::pad(n, ctx.scale); + let p = i32::try_from(pad).unwrap_or(0); + let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?; + backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) +} + +/// Node `n`'s layer blur, noise and texture on its `layer`, in list order. +fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) { for e in n.effects.iter().filter(|e| e.visible) { match &e.kind { - EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale), - EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32), - EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32), + EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale), + EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32), + EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32), _ => {} } } - // Drop shadows go under the layer, in effect-list order. - let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale); - for e in n.effects.iter().filter(|e| e.visible) { - if let EffectKind::DropShadow(s) = &e.kind - && let Some(sh) = shadow::drop_shadow(&caster, s) - { - composite(pix, (lx, ly), &sh, op, s.blend); - } - } - composite(pix, (lx, ly), &layer, op, n.blend); } fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda..1025d507 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec, kind: &[u8; 4], data: &[u8]) -> Vec { out } +#[cfg(test)] +mod backdrop_shadow_tests; #[cfg(test)] mod backdrop_tests; #[cfg(test)]