Files
figcraft/crates/render/src/image_shadow_tests.rs
T
BFlat 1df9190d0c M3: shadows of images with transparent pixels follow their alpha (#1289) (#1509)
* M3: shadows of images with transparent pixels follow their alpha (#1289)

A drop shadow on an image fill was hidden behind the layer's whole box, so a
transparent PNG's shadow only showed outside its bounding box. Fills that
paint their own alpha (patterns; images with transparent pixels or that leave
part of the box empty) now cast and hide drop shadows, and shade inner
shadows, by the layer's pixels, like groups. Opaque pictures keep their
outline. Each image is scanned for transparency once. Canvas, PNG, SVG and PDF.

* M3: the image shadow tests import their names explicitly (#1289)
2026-10-29 15:43:08 +07:00

109 lines
5.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shadows of image fills follow what the image paints (#1289): a picture with transparent pixels
//! casts its drop shadow from its alpha and hides it only behind its pixels, and its inner shadow
//! shades the inside of its alpha; an opaque picture keeps its outline.
use std::sync::Arc;
use figcraft_color::{Color, ImageScaleMode, Paint};
use figcraft_doc::{Document, Effect, EffectKind, ImageData, NodeId, NodeKind, Shadow};
use super::{Pixmap, View, render_nodes_into, shadow};
/// A 200 × 200 PNG, red: a circle of radius 50 in its middle on transparent pixels (`circle`), or
/// all over.
fn png(circle: bool) -> Vec<u8> {
let img = image::RgbaImage::from_fn(200, 200, |x, y| {
let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 100.0);
image::Rgba(if !circle || r <= 50.0 { [255, 0, 0, 255] } else { [0; 4] })
});
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A 200 × 200 rectangle at (100, 100) filled with the image, with `effect`, at `opacity`.
fn scene(circle: bool, effect: EffectKind, opacity: f32) -> (Document, NodeId) {
let mut d = Document::new("image shadow");
let p = d.first_page().unwrap();
// Image ids key the shared decode cache: fixtures running in parallel need ids of their own.
let id = if circle { "image-shadow-tests-circle" } else { "image-shadow-tests-opaque" };
d.images.insert(id.into(), ImageData { bytes: Arc::new(png(circle)), width: 200, height: 200 });
let n = d.create(p, NodeKind::Rectangle, "R", 100.0, 100.0, 200.0, 200.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::image(id, ImageScaleMode::Fill)];
node.effects = vec![Effect { kind: effect, visible: true }];
node.opacity = opacity;
(d, n)
}
fn shadow(offset: (f64, f64), show_behind: bool) -> Shadow {
Shadow { color: Color::BLACK, offset, radius: 0.0, show_behind, ..Default::default() }
}
/// Node `id` drawn on a transparent 400 × 400 view of the page.
fn render(d: &Document, id: NodeId) -> Pixmap {
let mut pix = Pixmap::new(400, 400).unwrap();
render_nodes_into(d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }, &mut pix);
pix
}
/// The premultiplied RGBA at the page point `(x, y)`.
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
p.data()[i..i + 4].try_into().unwrap()
}
fn near(v: u8, want: u8) -> bool {
v.abs_diff(want) <= 3
}
#[test]
fn a_transparent_images_drop_shadow_follows_its_alpha() {
for show_behind in [false, true] {
// At half opacity, a shadow under the circle shows through it.
let (d, n) = scene(true, EffectKind::DropShadow(shadow((20.0, 20.0), show_behind)), 0.5);
assert!(shadow::cover(&d, d.node(n).unwrap()).is_none(), "the image's pixels hide its shadow, not its box");
let p = render(&d, n);
// Inside the box, outside the circle, inside the circle's shadow: the shadow.
let s = px(&p, 265, 220);
assert!(s[..3] == [0, 0, 0] && near(s[3], 128), "show behind {show_behind}: {s:?}");
// Inside the box, away from the circle and its shadow: nothing.
assert_eq!(px(&p, 150, 150), [0; 4]);
// Where the circle covers the shadow, the option decides.
let c = px(&p, 225, 225);
assert!(near(c[3], if show_behind { 191 } else { 128 }), "show behind {show_behind}: {c:?}");
}
}
#[test]
fn an_opaque_images_drop_shadow_still_hides_behind_its_box() {
let (d, n) = scene(false, EffectKind::DropShadow(shadow((20.0, 20.0), false)), 0.5);
let node = d.node(n).unwrap();
assert!(shadow::cover(&d, node).is_some() && shadow::silhouette(&d, node).is_some(), "an opaque fill keeps its outline");
let p = render(&d, n);
assert!(near(px(&p, 250, 250)[3], 128), "behind the picture: hidden");
let s = px(&p, 310, 250);
assert!(s[..3] == [0, 0, 0] && near(s[3], 128), "past it: the shadow {s:?}");
// Fit leaves part of a wider box empty: the pixels decide there too.
let (mut d, n) = scene(false, EffectKind::DropShadow(shadow((20.0, 20.0), false)), 0.5);
d.get_mut(n).unwrap().width = 300.0;
d.get_mut(n).unwrap().fills = vec![Paint::image("image-shadow-tests-opaque", ImageScaleMode::Fit)];
assert!(shadow::cover(&d, d.node(n).unwrap()).is_none());
}
#[test]
fn a_transparent_images_inner_shadow_shades_its_alpha() {
let (d, n) = scene(true, EffectKind::InnerShadow(shadow((0.0, 10.0), false)), 1.0);
let p = render(&d, n);
// The top 10 of the circle (it starts at y 150) is in shadow; its middle stays red.
assert_eq!(px(&p, 200, 155), [0, 0, 0, 255]);
assert_eq!(px(&p, 200, 200), [255, 0, 0, 255]);
// The box's top edge is transparent: no shadow there.
assert_eq!(px(&p, 200, 105), [0; 4]);
// An opaque picture is shaded from its box's edge, as before.
let (d, n) = scene(false, EffectKind::InnerShadow(shadow((0.0, 10.0), false)), 1.0);
let p = render(&d, n);
assert_eq!(px(&p, 200, 105), [0, 0, 0, 255]);
assert_eq!(px(&p, 200, 200), [255, 0, 0, 255]);
}