Files
figcraft/crates/render/src/backdrop_tests.rs
T
BFlatandClaude Opus 5.5 70b2666c90 M3: progressive blur, inner shadows under strokes, noise and texture in SVG (#86)
- Inner shadows paint over the fills and under the children and strokes, as documented (canvas,
  PNG, PDF and SVG alike; text fills every run before the inner shadow and the strokes).
- Layer and background blur gain a progressive type: the radius ramps linearly from a start radius
  at a start point to the end radius at an end point (unit-box points, like gradient handles).
  Rendered by blending a few geometric blur levels, each blurred only around the pixels that use
  it; bands and partial renders match one piece. Design panel type menu and Start/End fields,
  on-canvas handles while the settings are open, effect.add/effect.set params, startRadius
  variable bindings, scale tool, REST import (blurType, startRadius, startOffset, endOffset),
  Inspect row; CSS leaves progressive blurs out.
- SVG export embeds renders for what filters can't match: noise grains over the vector layer
  (crisp, blended), texture and progressive layer blur whole, background blur and glass from what
  lies under the layer among the exported layers. PDF and SVG share the renderer's helpers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:20:07 +07:00

284 lines
12 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.
//! Backdrop effects (background blur, glass) see the whole backdrop under the layer: everything
//! composited below it, ancestors' own fills included, however the layer's ancestors isolate it
//! (clipping frames, rounded clips, group opacity, masks). The expected composite is built here
//! from first principles: the scene rendered without the layer, blurred, through the shape.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use tiny_skia::{PixmapPaint, Transform};
use super::*;
const BLUR: f64 = 12.0;
fn solid(d: &mut Document, id: NodeId, hex: &str) {
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex(hex).unwrap())];
}
/// A 240 × 160 red frame (clipping) with blue and green stripes inside and, on top, a transparent
/// pane with a background blur at (60, 40) 120 × 80 under `holder` (what isolates it). Returns the
/// document, the frame and the pane.
fn scene(holder: Holder) -> (Document, NodeId, NodeId) {
let mut d = Document::new("backdrop");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 160.0).unwrap();
solid(&mut d, f, "E03030");
d.get_mut(f).unwrap().clip = true;
if holder == Holder::RoundedClip {
d.get_mut(f).unwrap().corners.radii = [24.0; 4];
}
let parent = match holder {
Holder::Group => {
let g = d.create(f, NodeKind::Group, "Group", 0.0, 0.0, 240.0, 160.0).unwrap();
d.get_mut(g).unwrap().opacity = 0.5;
g
}
_ => f,
};
for (i, x) in [30.0, 90.0, 150.0].into_iter().enumerate() {
let s = d.create(parent, NodeKind::Rectangle, "Stripe", x, 0.0, 30.0, 160.0).unwrap();
solid(&mut d, s, if i % 2 == 0 { "2040E0" } else { "20C060" });
}
if holder == Holder::Mask {
// A mask covering the frame: everything above it shows through its alpha.
let m = d.create(f, NodeKind::Rectangle, "Mask", 0.0, 0.0, 240.0, 160.0).unwrap();
solid(&mut d, m, "000000");
d.get_mut(m).unwrap().is_mask = true;
let c = d.get_mut(f).unwrap();
let mi = c.children.iter().position(|k| *k == m).unwrap();
let mid = c.children.remove(mi);
c.children.insert(0, mid);
}
let pane = d.create(parent, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.fills = vec![];
n.effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(BLUR)), visible: true }];
(d, f, pane)
}
#[derive(Clone, Copy, PartialEq)]
enum Holder {
ClipFrame,
RoundedClip,
Group,
Mask,
}
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 pixels well inside the pane
/// (60..180 × 40..120, 2 px in), at `scale`.
fn max_diff_in_pane(a: &Pixmap, b: &Pixmap, scale: f64) -> u8 {
let r = |v: f64| (v * scale) as u32;
let mut worst = 0;
for y in r(42.0)..r(118.0) {
for x in r(62.0)..r(178.0) {
let (p, q) = (px(a, x, y), px(b, x, y));
worst = worst.max(p.iter().zip(q).map(|(c, d)| c.abs_diff(d)).max().unwrap());
}
}
worst
}
/// The largest channel difference between two same-sized pixmaps.
fn max_diff(a: &Pixmap, b: &Pixmap) -> u8 {
a.data().iter().zip(b.data()).map(|(p, q)| p.abs_diff(*q)).max().unwrap_or(0)
}
/// The scene's frame exported without the pane, blurred (sigma = radius / 2): what the pane
/// must show.
fn expected_blurred(d: &Document, f: NodeId, pane: NodeId, scale: f64) -> (Pixmap, Pixmap) {
let mut without = d.clone();
without.get_mut(pane).unwrap().visible = false;
let back = render_node(&without, f, scale).unwrap();
let mut blurred = back.clone();
fx::blur(&mut blurred, BLUR / 2.0 * scale);
(back, blurred)
}
#[test]
fn background_blur_in_a_clipping_frame_sees_the_frames_fill() {
for holder in [Holder::ClipFrame, Holder::RoundedClip, Holder::Mask] {
for scale in [1.0, 2.0] {
let (d, f, pane) = scene(holder);
let got = render_node(&d, f, scale).unwrap();
let (_, want) = expected_blurred(&d, f, pane, scale);
let diff = max_diff_in_pane(&got, &want, scale);
assert!(
diff <= 3,
"{} at {scale}×: differs by {diff}",
match holder {
Holder::ClipFrame => "clip frame",
Holder::RoundedClip => "rounded clip",
Holder::Mask => "mask",
Holder::Group => "group",
}
);
// The frame's red shows through the blur between the stripes (it used to be missing,
// the sharp stripes below showing instead).
let mid = px(&got, (75.0 * scale) as u32, (80.0 * scale) as u32);
assert!(mid[0] > 80 && mid[3] == 255, "{mid:?}");
}
}
}
#[test]
fn background_blur_inside_a_faded_group_blends_with_the_group() {
let (d, f, pane) = scene(Holder::Group);
let got = render_node(&d, f, 1.0).unwrap();
let (_, blurred) = expected_blurred(&d, f, pane, 1.0);
// The pane (the blurred backdrop, opaque) lives in the group, which is half transparent over
// the frame: half the blurred backdrop over the frame alone.
let group = d.node(pane).unwrap().parent.unwrap();
let mut frame_only = d.clone();
frame_only.get_mut(group).unwrap().visible = false;
let mut want = render_node(&frame_only, f, 1.0).unwrap();
want.draw_pixmap(0, 0, blurred.as_ref(), &PixmapPaint { opacity: 0.5, ..Default::default() }, Transform::identity(), None);
let diff = max_diff_in_pane(&got, &want, 1.0);
assert!(diff <= 3, "differs by {diff}");
}
#[test]
fn background_blur_on_the_canvas_matches_the_export() {
let (d, f, _) = scene(Holder::ClipFrame);
let p = d.first_page().unwrap();
let export = render_node(&d, f, 1.0).unwrap();
let canvas = render_view(&d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 240, height: 160 }, &Options::default()).unwrap();
assert!(max_diff_in_pane(&export, &canvas, 1.0) <= 1);
}
#[test]
fn background_blur_reads_past_its_own_box() {
// The pane's own border pixels blur what lies around them (the backdrop past the pane's box),
// not transparency: they match the blurred scene like the inside does.
let (d, f, pane) = scene(Holder::ClipFrame);
let got = render_node(&d, f, 1.0).unwrap();
let (_, want) = expected_blurred(&d, f, pane, 1.0);
for (x, y) in [(60, 80), (179, 80), (120, 40), (120, 119), (60, 40), (179, 119)] {
let (g, w) = (px(&got, x, y), px(&want, x, y));
assert!(g.iter().zip(w).all(|(a, b)| a.abs_diff(b) <= 3), "({x}, {y}): {g:?} vs {w:?}");
}
}
/// The frame with a glass pane at 80..200 × 40..120 instead of the blur: (document, frame, pane).
fn glass_scene(g: Glass) -> (Document, NodeId, NodeId) {
let (mut d, f, pane) = scene(Holder::ClipFrame);
let n = d.get_mut(pane).unwrap();
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
// 80..200 × 40..120: stripe edges (x = 90, 150, 180) run through its rims.
n.transform = Affine::translate((80.0, 40.0));
(d, f, pane)
}
#[test]
fn glass_centre_is_the_frosted_backdrop() {
let g = Glass { frost: BLUR, depth: 10.0, ..Glass::default() };
let (d, f, pane) = glass_scene(g);
let got = render_node(&d, f, 1.0).unwrap();
let (_, want) = expected_blurred(&d, f, pane, 1.0);
// Further than `depth` from the edge nothing bends and no light falls.
let mut worst = 0;
for y in 55..105 {
for x in 95..185 {
let (p, q) = (px(&got, x, y), px(&want, x, y));
worst = worst.max(p.iter().zip(q).map(|(c, d)| c.abs_diff(d)).max().unwrap());
}
}
assert!(worst <= 3, "{worst}");
}
#[test]
fn glass_edges_bend_split_and_catch_the_light() {
let plain = Glass { frost: 0.0, refraction: 0.0, dispersion: 0.0, light_intensity: 0.0, depth: 16.0, ..Glass::default() };
let (d, f, _) = glass_scene(plain.clone());
let flat = render_node(&d, f, 1.0).unwrap();
// Without bend, frost or light the glass shows the backdrop as is.
let (d0, f0, p0) = glass_scene(plain.clone());
let mut none = d0.clone();
none.get_mut(p0).unwrap().visible = false;
let backdrop = render_node(&none, f0, 1.0).unwrap();
assert!(max_diff_in_pane(&flat, &backdrop, 1.0) <= 2);
// Refraction moves what shows near the edge (stripe edges within `depth` of the pane's
// border get pulled inwards), not in the middle.
let (d, f, _) = glass_scene(Glass { refraction: 1.0, ..plain.clone() });
let bent = render_node(&d, f, 1.0).unwrap();
// Pixels of the pane that differ between two renders, near the border (within 15 px) and in
// the middle (more than 17 px in).
let changed = |a: &Pixmap, b: &Pixmap| {
let (mut rim, mut middle) = (0, 0);
for y in 40..120u32 {
for x in 80..200u32 {
let edge = (x - 80).min(199 - x).min(y - 40).min(119 - y);
if px(a, x, y) != px(b, x, y) {
if edge < 15 {
rim += 1;
} else if edge > 17 {
middle += 1;
}
}
}
}
(rim, middle)
};
let (rim, middle) = changed(&bent, &flat);
assert!(rim > 10 && middle == 0, "rim {rim}, middle {middle}");
// Dispersion splits the channels along the rim (a fringe on the stripe edges).
let (d, f, _) = glass_scene(Glass { refraction: 1.0, dispersion: 1.0, ..plain.clone() });
let split = render_node(&d, f, 1.0).unwrap();
let (rim, middle) = changed(&split, &bent);
assert!(rim > 0 && middle == 0, "dispersion: rim {rim}, middle {middle}");
// The light (from the top left by default) brightens the top-left rim more than the
// bottom-right one, and only near the edge.
let (d, f, _) = glass_scene(Glass { light_intensity: 1.0, ..plain });
let lit = render_node(&d, f, 1.0).unwrap();
let gain = |x: u32, y: u32| i32::from(px(&lit, x, y)[1]) - i32::from(px(&flat, x, y)[1]);
assert!(gain(82, 42) > gain(197, 117), "{} vs {}", gain(82, 42), gain(197, 117));
assert!(gain(82, 42) > 40, "{}", gain(82, 42));
assert_eq!(gain(140, 80), 0);
}
#[test]
fn glass_and_backdrop_blur_survive_bands_and_partial_renders() {
let (d, _, _) = glass_scene(Glass::default());
let p = d.first_page().unwrap();
let v = View { origin: (-20.0, -20.0), scale: 1.0, width: 280, height: 400 };
let one = render_view_single(&d, p, &v, &Options::default()).unwrap();
#[cfg(not(target_arch = "wasm32"))]
{
let banded = render_bands(&d, p, &v, &Options::default(), 4).unwrap();
assert!(max_diff(&one, &banded) <= 2);
}
// A pan: the retained render patches the uncovered strip and still matches a full render.
let doc = std::sync::Arc::new(d);
let mut r = Retained::default();
r.render(&doc, p, &v, &Options::default()).unwrap();
let moved = View { origin: (-12.0, -20.0), ..v.clone() };
let (snapped, partial, _) = r.render(&doc, p, &moved, &Options::default()).unwrap();
let partial = partial.clone();
let full = render_view_single(&doc, p, &snapped, &Options::default()).unwrap();
assert!(max_diff(&partial, &full) <= 2);
}
#[test]
fn hostile_backdrop_values_do_not_crash() {
for g in [
Glass { depth: f64::NAN, frost: f64::INFINITY, refraction: -3.0, dispersion: 9.0, light_angle: f64::NAN, ..Glass::default() },
Glass { depth: 1e12, frost: 1e9, ..Glass::default() },
Glass { depth: 0.0, frost: 0.0, refraction: 0.0, light_intensity: 0.0, ..Glass::default() },
] {
let (d, f, _) = glass_scene(g);
assert!(render_node(&d, f, 1.0).is_some());
}
let (mut d, f, pane) = scene(Holder::ClipFrame);
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(f64::NAN)), visible: true }];
assert!(render_node(&d, f, 1.0).is_some());
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(1e12)), visible: true }];
assert!(render_node(&d, f, 0.5).is_some());
}