Files
figcraft/crates/render/src/effects_behaviour_tests.rs
T
2026-10-15 11:12:25 +00:00

406 lines
19 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.
//! Effects behaviour, written from the public documentation of the design tools FigCraft follows:
//! drop and inner shadows (offset, blur, spread, colour, showing behind transparent areas),
//! layer and background blurs, effects on groups, the order effects stack in and what exports
//! include. Each case says in one line why it expects what it does; where the documentation is
//! silent the case says so and pins today's behaviour. Scenes are small and synthetic, drawn on
//! a white page at 1×.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Blur, BlurType, Document, Effect, EffectKind, NodeId, NodeKind, Shadow, StrokeAlign};
use super::paint_behaviour_tests::{Probes, Pt, Px, hex, near, page, px, rect, render, solid_at};
use super::*;
const WHITE: [u8; 3] = [255, 255, 255];
const BLACK: [u8; 3] = [0, 0, 0];
const RED: [u8; 3] = [255, 0, 0];
fn fx(kind: EffectKind) -> Effect {
Effect { kind, visible: true }
}
/// A sharp, opaque black shadow.
fn sharp(offset: (f64, f64), spread: f64) -> Shadow {
Shadow { color: Color::BLACK, offset, radius: 0.0, spread, ..Shadow::default() }
}
/// A 40 × 40 rectangle at (50, 50) with `fills` and `effects`.
fn square(fills: Vec<Paint>, effects: Vec<Effect>) -> (Document, NodeId) {
let (mut d, p) = page();
let r = rect(&mut d, p, (50.0, 50.0, 40.0, 40.0), fills);
d.get_mut(r).unwrap().effects = effects;
(d, r)
}
/// The standard normal distribution function (Abramowitz–Stegun 7.1.26 for erf).
fn phi(z: f64) -> f64 {
let x = z.abs() / std::f64::consts::SQRT_2;
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
let erf = 1.0 - poly * (-x * x).exp();
if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) }
}
#[test]
fn new_effects_start_from_the_documented_defaults() {
// A new shadow: offset 0, 4, blur 4, spread 0, black at 25%, normal blend, not shown behind
// transparent areas; a new blur: 4, uniform.
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.25), offset: (0.0, 4.0), radius: 4.0, spread: 0.0, ..Shadow::default() };
for (ty, want) in [
("dropShadow", EffectKind::DropShadow(shadow.clone())),
("innerShadow", EffectKind::InnerShadow(shadow.clone())),
("layerBlur", EffectKind::LayerBlur(Blur::uniform(4.0))),
("backgroundBlur", EffectKind::BackgroundBlur(Blur::uniform(4.0))),
] {
let e = Effect::of_type(ty).unwrap();
assert_eq!(e.kind, want, "{ty}");
assert!(e.visible, "{ty} starts visible");
}
assert!(!shadow.show_behind && shadow.blend == figcraft_color::BlendMode::Normal);
assert_eq!(Blur::default().blur_type, BlurType::Uniform);
}
#[test]
fn drop_shadows_move_by_the_offset_and_grow_by_the_spread() {
// (why, offset, spread, colour, probes). The red square covers 50..90 on both axes.
let rows: Vec<(&str, Pt, f64, Color, Probes)> = vec![
(
"x moves the shadow right; the layer paints over it",
(10.0, 0.0),
0.0,
Color::BLACK,
vec![((95, 70), BLACK), ((45, 70), WHITE), ((70, 70), RED)],
),
("y moves it down", (0.0, 10.0), 0.0, Color::BLACK, vec![((70, 95), BLACK), ((70, 45), WHITE)]),
("negative offsets move it up and left", (-10.0, -10.0), 0.0, Color::BLACK, vec![((45, 45), BLACK), ((95, 95), WHITE)]),
(
"spread grows the shadow on every side",
(10.0, 0.0),
5.0,
Color::BLACK,
vec![((102, 70), BLACK), ((107, 70), WHITE), ((70, 47), BLACK), ((70, 43), WHITE)],
),
(
"negative spread shrinks it (here, out of sight under the layer)",
(0.0, 0.0),
-5.0,
Color::BLACK,
vec![((48, 70), WHITE), ((92, 70), WHITE)],
),
("the colour's alpha is the shadow's opacity", (10.0, 0.0), 0.0, Color::new(0.0, 0.0, 0.0, 0.25), vec![((95, 70), [191, 191, 191])]),
("the shadow takes its colour", (10.0, 0.0), 0.0, hex("0000FF"), vec![((95, 70), [0, 0, 255])]),
];
for (why, offset, spread, color, probes) in rows {
let (d, _) = square(vec![Paint::solid(hex("FF0000"))], vec![fx(EffectKind::DropShadow(Shadow { color, ..sharp(offset, spread) }))]);
let pix = render(&d);
for ((x, y), want) in probes {
let got = px(&pix, x, y);
assert!(near(got, want, 2), "{why} at ({x}, {y}): {got:?} want {want:?}");
}
}
}
#[test]
fn blurs_spread_by_a_gaussian_of_half_the_radius() {
// The blur value is the CSS blur radius (what code inspection shows for it), which CSS turns
// into a Gaussian with a standard deviation of half the radius: across a straight edge the
// coverage falls off as the normal distribution function. Radius 20: sigma 10.
let sigma = 10.0;
let edge = |d: f64| ((1.0 - phi(-d / sigma)) * 255.0).round() as u8;
// Drop shadow of a white 120 × 120 square at (40, 40), black, no offset.
let (mut d, p) = page();
let r = rect(&mut d, p, (40.0, 40.0, 120.0, 120.0), vec![Paint::solid(Color::WHITE)]);
d.get_mut(r).unwrap().effects = vec![fx(EffectKind::DropShadow(Shadow { radius: 20.0, ..sharp((0.0, 0.0), 0.0) }))];
let shadow = render(&d);
// Layer blur of a black square, same place.
let (mut d, p) = page();
let r = rect(&mut d, p, (40.0, 40.0, 120.0, 120.0), vec![Paint::solid(Color::BLACK)]);
d.get_mut(r).unwrap().effects = vec![fx(EffectKind::LayerBlur(Blur::uniform(20.0)))];
let layer = render(&d);
// Background blur: black left of x = 160 seen through a clear pane across the edge.
let (mut d, p) = page();
rect(&mut d, p, (0.0, 0.0, 160.0, 200.0), vec![Paint::solid(Color::BLACK)]);
let pane = rect(&mut d, p, (110.0, 40.0, 90.0, 120.0), vec![]);
d.get_mut(pane).unwrap().effects = vec![fx(EffectKind::BackgroundBlur(Blur::uniform(20.0)))];
let back = render(&d);
// (blur, pixel column, its centre's distance past the edge at x = 160)
let rows = [
("drop shadow", &shadow, 160, 0.5),
("drop shadow", &shadow, 170, 10.5),
("drop shadow", &shadow, 180, 20.5),
("layer blur", &layer, 150, -9.5),
("layer blur", &layer, 160, 0.5),
("layer blur", &layer, 170, 10.5),
("background blur", &back, 150, -9.5),
("background blur", &back, 160, 0.5),
("background blur", &back, 170, 10.5),
];
for (why, pix, x, dist) in rows {
let (got, want) = (px(pix, x, 100)[1], edge(dist));
assert!(got.abs_diff(want) <= 8, "{why} at x {x}: {got} want {want}");
}
}
#[test]
fn shadows_show_behind_transparent_areas_only_when_asked() {
// Off (the default), a drop shadow doesn't show through the layer: the parts it covers,
// translucent ones included, hide the shadow. On, the shadow shows through them. The shadow
// is as strong as the layer's pixels are opaque (silent, pinned): a 50% fill casts a 50% one.
let stroke_only = |d: &mut Document, r: NodeId| {
let n = d.get_mut(r).unwrap();
n.strokes = vec![Paint::solid(hex("FF0000"))];
(n.stroke.weight, n.stroke.align) = (4.0, StrokeAlign::Inside);
};
type Setup = fn(&mut Document, NodeId);
let half = [128, 128, 128];
// (why, fill opacity (none: no fill), layer opacity, show behind, offset, extra, probes)
type Row<'a> = (&'a str, Option<f32>, f32, bool, Pt, Option<Setup>, Probes);
let rows: Vec<Row> = vec![
("off: a 50% fill shows the page, not the shadow, through it", Some(0.5), 1.0, false, (0.0, 0.0), None, vec![((70, 70), [255, 128, 128])]),
("on: the shadow shows through the 50% fill", Some(0.5), 1.0, true, (0.0, 0.0), None, vec![((70, 70), [191, 64, 64])]),
(
"off: outside the layer the shadow shows as ever",
Some(0.5),
1.0,
false,
(10.0, 0.0),
None,
vec![((95, 70), half), ((85, 70), [255, 128, 128])],
),
("on: offset, it shows under the layer too", Some(0.5), 1.0, true, (10.0, 0.0), None, vec![((95, 70), half), ((85, 70), [191, 64, 64])]),
(
"layer opacity fades the layer and its shadow together",
Some(1.0),
0.5,
false,
(10.0, 0.0),
None,
vec![((70, 70), [255, 128, 128]), ((95, 70), [128, 128, 128])],
),
(
"a stroke without a fill: the empty middle isn't part of the layer",
None,
1.0,
false,
(10.0, 0.0),
Some(stroke_only),
vec![((62, 70), BLACK), ((52, 70), RED)],
),
];
for (why, fill, layer_op, show_behind, offset, extra, probes) in rows {
let fills = fill.map(|o| vec![solid_at("FF0000", o)]).unwrap_or_default();
let (mut d, r) = square(fills, vec![fx(EffectKind::DropShadow(Shadow { show_behind, ..sharp(offset, 0.0) }))]);
d.get_mut(r).unwrap().opacity = layer_op;
if let Some(f) = extra {
f(&mut d, r);
}
let pix = render(&d);
for ((x, y), want) in probes {
let got = px(&pix, x, y);
assert!(near(got, want, 2), "{why} at ({x}, {y}): {got:?} want {want:?}");
}
}
}
#[test]
fn effects_on_a_group_apply_to_the_group_as_a_whole() {
// A red square over a blue one, shadow (10, 10). On the group the pair casts one shadow;
// on each layer the top one's shadow falls on the bottom one.
for (why, on_group, want) in
[("on the group: one shadow, under both", true, [0, 0, 255]), ("on each layer: the top layer shadows the bottom one", false, BLACK)]
{
let (mut d, p) = page();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 200.0, 200.0).unwrap();
let blue = rect(&mut d, g, (60.0, 60.0, 40.0, 40.0), vec![Paint::solid(hex("0000FF"))]);
let red = rect(&mut d, g, (40.0, 40.0, 40.0, 40.0), vec![Paint::solid(hex("FF0000"))]);
let shadow = vec![fx(EffectKind::DropShadow(sharp((10.0, 10.0), 0.0)))];
if on_group {
d.get_mut(g).unwrap().effects = shadow;
} else {
for id in [blue, red] {
d.get_mut(id).unwrap().effects = shadow.clone();
}
}
let pix = render(&d);
// (85, 70): on the blue square, inside the red one's shadow, outside the red square.
assert!(near(px(&pix, 85, 70), want, 2), "{why}: {:?}", px(&pix, 85, 70));
assert!(near(px(&pix, 105, 105), BLACK, 2), "{why}: the blue square's own shadow");
}
}
#[test]
fn inner_shadows_paint_inside_over_the_fills_under_strokes_and_children() {
let green = [0, 255, 0];
type Setup = fn(&mut Document, NodeId);
let inside_stroke: Setup = |d, r| {
let n = d.get_mut(r).unwrap();
n.strokes = vec![Paint::solid(hex("00FF00"))];
(n.stroke.weight, n.stroke.align) = (4.0, StrokeAlign::Inside);
};
let child: Setup = |d, r| {
rect(d, r, (0.0, 0.0, 40.0, 3.0), vec![Paint::solid(hex("0000FF"))]);
};
// (why, kind, offset, spread, extra, probes) on a white 40 × 40 square at (50, 50).
type Row<'a> = (&'a str, NodeKind, Pt, f64, Option<Setup>, Probes);
let rows: Vec<Row> = vec![
(
"y moves the outside in from the top edge",
NodeKind::Rectangle,
(0.0, 10.0),
0.0,
None,
vec![((70, 55), BLACK), ((70, 65), WHITE), ((70, 85), WHITE)],
),
("x moves it in from the left edge", NodeKind::Rectangle, (10.0, 0.0), 0.0, None, vec![((55, 70), BLACK), ((65, 70), WHITE)]),
(
"spread grows it in from every edge",
NodeKind::Rectangle,
(0.0, 0.0),
5.0,
None,
vec![((52, 70), BLACK), ((57, 70), WHITE), ((70, 52), BLACK), ((87, 70), BLACK)],
),
("it never paints outside the layer", NodeKind::Rectangle, (0.0, 10.0), 0.0, None, vec![((70, 45), WHITE), ((70, 95), WHITE)]),
("strokes paint over it", NodeKind::Rectangle, (0.0, 0.0), 5.0, Some(inside_stroke), vec![((51, 70), green), ((54, 70), BLACK)]),
("a frame's children paint over it", NodeKind::Frame, (0.0, 0.0), 5.0, Some(child), vec![((70, 51), [0, 0, 255]), ((70, 54), BLACK)]),
];
for (why, kind, offset, spread, extra, probes) in rows {
let (mut d, p) = page();
let r = d.create(p, kind, "S", 50.0, 50.0, 40.0, 40.0).unwrap();
let n = d.get_mut(r).unwrap();
n.fills = vec![Paint::solid(Color::WHITE)];
n.effects = vec![fx(EffectKind::InnerShadow(sharp(offset, spread)))];
if let Some(f) = extra {
f(&mut d, r);
}
let pix = render(&d);
for ((x, y), want) in probes {
let got = px(&pix, x, y);
assert!(near(got, want, 2), "{why} at ({x}, {y}): {got:?} want {want:?}");
}
}
}
#[test]
fn effects_stack_in_list_order() {
let red = Shadow { color: hex("FF0000"), ..sharp((10.0, 0.0), 0.0) };
let blue = Shadow { color: hex("0000FF"), ..sharp((10.0, 0.0), 0.0) };
let hidden = Effect { visible: false, ..fx(EffectKind::DropShadow(sharp((10.0, 0.0), 0.0))) };
// (why, effects, pixel, check)
type Check = fn([u8; 3]) -> bool;
let rows: Vec<(&str, Vec<Effect>, Px, Check)> = vec![
(
"two shadows: the later one in the list paints over the earlier (silent, pinned like paints)",
vec![fx(EffectKind::DropShadow(red)), fx(EffectKind::DropShadow(blue))],
(95, 70),
|c| c == [0, 0, 255],
),
("a hidden effect does nothing", vec![hidden], (95, 70), |c| c == WHITE),
(
"a layer blur blurs the layer before it casts its shadow (silent, pinned)",
vec![fx(EffectKind::DropShadow(sharp((20.0, 0.0), 0.0))), fx(EffectKind::LayerBlur(Blur::uniform(10.0)))],
(108, 70),
|c| (30..=220).contains(&c[0]),
),
];
for (why, effects, (x, y), ok) in rows {
let (d, _) = square(vec![Paint::solid(hex("808080"))], effects);
let got = px(&render(&d), x, y);
assert!(ok(got), "{why}: {got:?}");
}
}
#[test]
fn background_blur_shows_through_the_fill_inside_the_shape() {
// Black left of x = 100 under a pane (50..150) with a background blur of 20: at x = 110 the
// blurred backdrop is 218 (sigma 10, 10.5 px past the edge).
let blurred = 218;
// (why, pane fills, pixel, colour there)
let rows: Vec<(&str, Vec<Paint>, Px, [u8; 3])> = vec![
("no fill: the blurred backdrop", vec![], (110, 100), [blurred; 3]),
("a 0% fill: the blurred backdrop", vec![solid_at("FF0000", 0.0)], (110, 100), [blurred; 3]),
("a 50% fill paints over the blurred backdrop", vec![solid_at("FF0000", 0.5)], (110, 100), [236, 109, 109]),
("an opaque fill hides it", vec![Paint::solid(hex("FF0000"))], (110, 100), RED),
("outside the shape nothing is blurred", vec![], (110, 160), WHITE),
("outside the shape nothing is blurred (dark side)", vec![], (90, 160), BLACK),
];
for (why, fills, (x, y), want) in rows {
let (mut d, p) = page();
rect(&mut d, p, (0.0, 0.0, 100.0, 200.0), vec![Paint::solid(Color::BLACK)]);
let pane = rect(&mut d, p, (50.0, 50.0, 100.0, 100.0), fills);
d.get_mut(pane).unwrap().effects = vec![fx(EffectKind::BackgroundBlur(Blur::uniform(20.0)))];
let got = px(&render(&d), x, y);
assert!(near(got, want, 8), "{why}: {got:?} want {want:?}");
}
}
#[test]
fn exports_include_what_effects_paint_outside_the_layer() {
let size = |effects: Vec<Effect>| {
let (d, r) = square(vec![Paint::solid(hex("FF0000"))], effects);
let p = render_node(&d, r, 1.0).unwrap();
(p.width(), p.height())
};
// (why, effects, grows the export)
let rows = [
("nothing: the layer's box", vec![], false),
("a drop shadow reaches out", vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))], true),
("a layer blur reaches out", vec![fx(EffectKind::LayerBlur(Blur::uniform(4.0)))], true),
("an inner shadow stays inside", vec![fx(EffectKind::InnerShadow(sharp((0.0, 10.0), 0.0)))], false),
("a background blur stays inside", vec![fx(EffectKind::BackgroundBlur(Blur::uniform(4.0)))], false),
("noise stays on the layer's pixels", vec![fx(EffectKind::Noise(figcraft_doc::Noise::default()))], false),
("texture roughens past the edge", vec![fx(EffectKind::Texture(figcraft_doc::Texture::default()))], true),
(
"texture clipped to the shape stays inside",
vec![fx(EffectKind::Texture(figcraft_doc::Texture { clip_to_shape: true, ..Default::default() }))],
false,
),
("glass stays inside", vec![fx(EffectKind::Glass(figcraft_doc::Glass::default()))], false),
];
for (why, effects, grows) in rows {
let (w, h) = size(effects);
// Effects grow the export only on the sides they reach (a shadow falling down grows its height).
assert!(w >= 40 && h >= 40, "{why}: {w} × {h}");
assert_eq!(w > 40 || h > 40, grows, "{why}: {w} × {h}");
}
// The export holds the shadow where the canvas draws it, pixel for pixel.
let (d, r) = square(vec![Paint::solid(hex("FF0000"))], vec![fx(EffectKind::DropShadow(Shadow { radius: 6.0, ..sharp((0.0, 10.0), 2.0) }))]);
let b = export_bounds(&d, r).unwrap();
let export = render_node(&d, r, 1.0).unwrap();
let mut canvas = Pixmap::new(export.width(), export.height()).unwrap();
render_nodes_into(&d, &[r], &View { origin: (b.x0, b.y0), scale: 1.0, width: export.width(), height: export.height() }, &mut canvas);
assert_eq!(export.data(), canvas.data(), "export and canvas agree");
assert!(b.y1 >= 90.0 + 10.0 + 2.0 + 6.0, "the shadow's blur and spread are inside the export: {b:?}");
// A clipping frame exports its shadow too.
let (mut d, p) = page();
let f = d.create(p, NodeKind::Frame, "F", 50.0, 50.0, 40.0, 40.0).unwrap();
d.get_mut(f).unwrap().effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))];
assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported");
}
/// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×.
fn blurred_frame(radius: f64) -> Pixmap {
let mut d = Document::new("Blur range");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Square", 156.0, 156.0, 200.0, 200.0).unwrap();
let n = d.get_mut(f).unwrap();
n.fills = vec![Paint::solid(Color::BLACK)];
n.effects = vec![fx(EffectKind::LayerBlur(Blur::uniform(radius)))];
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 512, height: 512 };
render_view(&d, p, &view, &Options::default()).unwrap()
}
#[test]
fn layer_blur_radii_up_to_the_documented_limit_all_draw() {
// The documented blur range runs to 10,000: a larger radius in it blurs further, it doesn't
// stop at some smaller one.
let differ = |a: &Pixmap, b: &Pixmap| a.data().chunks_exact(4).zip(b.data().chunks_exact(4)).filter(|(a, b)| a != b).count();
let sharp = blurred_frame(0.0);
let small = blurred_frame(100.0);
let medium = blurred_frame(1_000.0);
let large = blurred_frame(10_000.0);
assert!(differ(&sharp, &small) > 0, "radius 0 draws the sharp shape");
assert!(differ(&small, &medium) > 0, "100 vs 1000");
assert!(differ(&medium, &large) > 0, "radii 1000 and 10000 draw the same pixels");
}