Files
designcraft/crates/render/tests/it/effects.rs
T

377 lines
16 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.
//! Golden images for the soft object effects (drop shadow, outer glow, inner shadow, feather),
//! plus property checks and a multithreaded == single-threaded check.
//!
//! Goldens live in `tests/golden/*.png` and are compared with a tolerance (antialiasing and blur
//! rounding may differ by a few levels between SIMD levels). Regenerate them after an intended
//! change with `DESIGNCRAFT_BLESS=1 cargo test -p designcraft-render --test it effects::`.
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Fill, Item, ItemId, ParaFormat, Shape, SpreadRef};
use designcraft_geom::{Affine, Rect, Vec2, shapes};
use designcraft_render::{Placed, RenderOptions, Rendered, Renderer};
const SIZE: u32 = 160;
fn doc() -> Document {
Document::new(&NewDocument { width: 160.0, height: 160.0, facing_pages: false, ..Default::default() })
}
fn rect_item(d: &mut Document, r: Rect, swatch: &str) -> ItemId {
let id = ItemId(d.alloc());
let lid = d.default_layer();
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
it.fill = Fill::swatch(swatch);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
id
}
fn render_with(d: &Document, threads: u16) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], SIZE, SIZE, Affine::IDENTITY, &opts)
}
fn render(d: &Document) -> Rendered {
render_with(d, 0)
}
fn lum(p: [u8; 4]) -> u32 {
p[0] as u32 + p[1] as u32 + p[2] as u32
}
const WHITE: u32 = 765;
/// Tolerant comparison with `tests/golden/{name}.png` (written when missing or when blessing).
fn golden(name: &str, img: &Rendered) {
let path = format!("{}/tests/golden/{name}.png", env!("CARGO_MANIFEST_DIR"));
let bless = std::env::var_os("DESIGNCRAFT_BLESS").is_some();
let Ok(bytes) = std::fs::read(&path).map_err(|_| ()).and_then(|b| if bless { Err(()) } else { Ok(b) }) else {
std::fs::create_dir_all(format!("{}/tests/golden", env!("CARGO_MANIFEST_DIR"))).unwrap();
std::fs::write(&path, img.to_png()).unwrap();
eprintln!("wrote golden {path}");
return;
};
let want = image::load_from_memory(&bytes).unwrap().to_rgba8();
assert_eq!((want.width(), want.height()), (img.width, img.height), "{name}: size");
let got = img.to_straight();
let (mut bad, mut sum, mut worst) = (0usize, 0u64, 0u8);
for (a, b) in got.as_chunks::<4>().0.iter().zip(want.as_raw().as_chunks::<4>().0) {
let d = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap();
sum += d as u64;
worst = worst.max(d);
if d > 8 {
bad += 1;
}
}
let n = (img.width * img.height) as usize;
let mean = sum as f64 / n as f64;
if bad * 200 > n || mean > 1.0 {
let out = std::env::temp_dir().join(format!("designcraft-golden-{name}.png"));
let _ = std::fs::write(&out, img.to_png());
panic!("{name}: {bad} pixels differ by > 8 (worst {worst}, mean {mean:.2}); got {}", out.display());
}
}
fn shadow_doc(spread: f64) -> Document {
let mut d = doc();
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0");
let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow;
ds.on = true;
ds.distance = 12.0;
ds.angle = 135.0;
ds.size = 10.0;
ds.spread = spread;
ds.opacity = 0.8;
d
}
#[test]
fn drop_shadow_is_soft_offset_and_under_the_object() {
let img = render(&shadow_doc(0.0));
golden("drop-shadow", &img);
// The object is unchanged on top.
let c = img.pixel(60, 60);
assert!(c[0] < 60 && c[2] > 200, "object on top: {c:?}");
// Below-right is shadowed, above-left is not.
assert!(lum(img.pixel(104, 70)) < 500, "shadow below-right: {:?}", img.pixel(104, 70));
assert_eq!(lum(img.pixel(22, 22)), WHITE);
// Soft: the shadow fades out gradually past its hard edge (100 + 8.5 offset).
let row: Vec<u32> = (104..130).map(|x| lum(img.pixel(x, 80))).collect();
let mid = row.iter().filter(|&&v| v > 60 && v < 700).count();
assert!(mid >= 6, "gradual falloff: {row:?}");
assert!(row.windows(2).all(|w| w[1] + 2 >= w[0]), "monotonic: {row:?}");
}
#[test]
fn drop_shadow_spread_hardens_and_grows() {
let soft = render(&shadow_doc(0.0));
let hard = render(&shadow_doc(100.0));
golden("drop-shadow-spread", &hard);
// Full spread: no blur, the silhouette grows by `size` → solid well past the offset edge.
let x = 100 + 8 + 6;
assert!(lum(hard.pixel(x, 80)) < lum(soft.pixel(x, 80)), "spread grows the shadow");
assert_eq!(lum(hard.pixel(126, 80)), WHITE, "hard edge");
}
#[test]
fn drop_shadow_of_text_follows_the_glyphs() {
let mut d = doc();
let lid = d.default_layer();
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 150.0, 150.0), lid, "Shadow", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().format_chars(0..6, |f| f.over.size = Some(40.0));
let ds = &mut d.item_mut(fid).unwrap().effects.drop_shadow;
ds.on = true;
ds.angle = 135.0;
ds.distance = 4.0;
ds.size = 3.0;
let img = render(&d);
golden("drop-shadow-text", &img);
// No frame-shaped shadow: the bottom-right of the (unfilled) frame is clear.
assert_eq!(lum(img.pixel(140, 140)), WHITE);
}
#[test]
fn outer_glow_surrounds_the_object() {
let mut d = doc();
let id = rect_item(&mut d, Rect::new(50.0, 50.0, 110.0, 110.0), "[Black]");
let g = &mut d.item_mut(id).unwrap().effects.outer_glow;
g.on = true;
g.size = 12.0;
g.color = "C=0 M=100 Y=0 K=0".into();
g.opacity = 1.0;
let img = render(&d);
golden("outer-glow", &img);
for (x, y) in [(46, 80), (114, 80), (80, 46), (80, 114)] {
let p = img.pixel(x, y);
assert!(p[1] < 200 && p[0] > p[1] + 40, "magenta glow at {x},{y}: {p:?}");
}
assert_eq!(lum(img.pixel(10, 10)), WHITE);
}
#[test]
fn inner_shadow_darkens_inside_the_lit_edge() {
let mut d = doc();
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=0 M=0 Y=100 K=0");
let s = &mut d.item_mut(id).unwrap().effects.inner_shadow;
s.on = true;
s.angle = 135.0;
s.distance = 8.0;
s.size = 8.0;
s.opacity = 1.0;
let img = render(&d);
golden("inner-shadow", &img);
// Light from the top-left: the shadow sits inside the top-left edges.
assert!(lum(img.pixel(33, 80)) + 100 < lum(img.pixel(126, 80)), "{:?} vs {:?}", img.pixel(33, 80), img.pixel(126, 80));
assert!(lum(img.pixel(80, 33)) + 100 < lum(img.pixel(80, 126)));
// Nothing outside the shape.
assert_eq!(lum(img.pixel(26, 80)), WHITE);
}
#[test]
fn feather_fades_the_edge_inward() {
let mut d = doc();
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "[Black]");
d.item_mut(id).unwrap().effects.feather = 16.0;
let img = render(&d);
golden("feather", &img);
d.item_mut(id).unwrap().effects.feather = 0.0;
let solid = lum(render(&d).pixel(80, 80));
let edge = lum(img.pixel(30, 80));
let mid = lum(img.pixel(38, 80));
let inside = lum(img.pixel(80, 80));
assert!(edge > 600, "nearly transparent at the path: {edge}");
assert!(mid > solid + 60 && mid < 650, "half way: {mid}");
assert!(inside <= solid + 6, "solid inside: {inside} vs {solid}");
assert_eq!(lum(img.pixel(27, 80)), WHITE, "nothing outside");
}
#[test]
fn shadow_of_an_object_outside_the_view_still_reaches_it() {
let mut d = doc();
let id = rect_item(&mut d, Rect::new(-60.0, 40.0, -2.0, 120.0), "[Paper]");
let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow;
ds.on = true;
ds.angle = 180.0;
ds.distance = 20.0;
ds.size = 2.0;
ds.opacity = 1.0;
let img = render(&d);
assert!(lum(img.pixel(8, 80)) < 200, "{:?}", img.pixel(8, 80));
}
/// Points 70..90 of the page at 8 px a point (400% on a 2x screen), where a wide blur is drawn
/// offscreen at a lower resolution.
fn render_zoomed(d: &Document) -> Rendered {
let mut r = Renderer::new();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let view = Affine::scale(8.0) * Affine::translate(Vec2::new(-70.0, -70.0));
r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], SIZE, SIZE, view, &opts)
}
#[test]
fn a_wide_feather_keeps_the_object_sharp_at_high_zoom() {
let mut d = doc();
// A huge yellow frame holding a small black square (pt 78..82, px 64..96 when zoomed).
let id = rect_item(&mut d, Rect::new(-5000.0, -5000.0, 5160.0, 5160.0), "C=0 M=0 Y=100 K=0");
let kid = Item {
fill: Fill::swatch("[Black]"),
..Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(78.0, 78.0, 82.0, 82.0)))
};
d.item_mut(id).unwrap().content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(kid)] };
let plain = render_zoomed(&d);
// 3σ of a 1,000 pt feather is 6,000 px here, so its mask is drawn at about 1/6 resolution.
d.item_mut(id).unwrap().effects.feather = 1000.0;
let img = render_zoomed(&d);
let worst = plain.pixels.iter().zip(&img.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "the inside of a feathered object matches the object: max channel difference {worst}");
}
#[test]
fn a_wide_inner_effect_stays_inside_the_shape_at_high_zoom() {
let mut d = doc();
// A black backdrop shows a screened glow; white paper shows a shadow.
let backdrop = rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "[Black]");
// The shape's right edge is at pt 80, px 80 when zoomed.
let id = rect_item(&mut d, Rect::new(-2000.0, -2000.0, 80.0, 2160.0), "C=100 M=0 Y=0 K=0");
for glow in [false, true] {
d.item_mut(backdrop).unwrap().fill = Fill::swatch(if glow { "[Black]" } else { "[None]" });
let plain = render_zoomed(&d);
let e = &mut d.item_mut(id).unwrap().effects;
// 3σ is 24,000 px, so the effect is drawn at about 1/23 resolution.
if glow {
e.inner_glow.on = true;
e.inner_glow.size = 2000.0;
e.inner_glow.opacity = 1.0;
} else {
e.inner_shadow.on = true;
e.inner_shadow.color = "[Black]".into();
e.inner_shadow.distance = 0.0;
e.inner_shadow.size = 2000.0;
e.inner_shadow.opacity = 1.0;
}
let img = render_zoomed(&d);
assert_ne!(lum(img.pixel(78, 80)), lum(plain.pixel(78, 80)), "glow {glow}: the effect shows inside the edge");
for x in 81..110 {
assert_eq!(lum(img.pixel(x, 80)), lum(plain.pixel(x, 80)), "glow {glow}: nothing outside the edge at x {x}");
}
let e = &mut d.item_mut(id).unwrap().effects;
e.inner_glow.on = false;
e.inner_shadow.on = false;
}
}
/// Multithreaded contexts draw effects offscreen; the result must match single-threaded.
#[test]
fn multithreaded_effects_match_single_threaded() {
let mut docs = vec![shadow_doc(0.0), shadow_doc(40.0)];
let mut d = doc();
rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "C=0 M=30 Y=60 K=0");
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "C=100 M=0 Y=0 K=0");
{
let it = d.item_mut(id).unwrap();
it.effects.feather = 10.0;
it.effects.inner_shadow.on = true;
it.effects.outer_glow.on = true;
it.effects.drop_shadow.on = true;
it.opacity = 0.8;
}
docs.push(d);
for (i, d) in docs.iter().enumerate() {
let a = render_with(d, 0);
let b = render_with(d, 3);
let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap();
assert!(worst <= 4, "case {i}: max channel difference {worst}");
}
}
/// A frame (30..130) holding a pasted-in black square that overhangs it (Edit › Paste Into).
fn pasted_into(d: &mut Document) -> ItemId {
let id = rect_item(d, Rect::new(30.0, 30.0, 130.0, 130.0), "[None]");
let kid = Item {
fill: Fill::swatch("[Black]"),
..Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 160.0)))
};
d.item_mut(id).unwrap().content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(kid)] };
assert!(d.item(id).unwrap().has_nested_items());
id
}
#[test]
fn effects_apply_to_a_frame_with_pasted_in_items() {
let mut d = doc();
let id = pasted_into(&mut d);
let plain = render(&d);
let black = lum(plain.pixel(80, 80));
assert!(black < 200 && lum(plain.pixel(31, 80)) <= black + 6, "the content fills the frame: {:?} {:?}", plain.pixel(31, 80), plain.pixel(80, 80));
assert_eq!(lum(plain.pixel(27, 80)), WHITE, "clipped by the frame");
// Basic Feather fades the frame's edge, content and all.
d.item_mut(id).unwrap().effects.feather = 16.0;
let img = render(&d);
assert!(lum(img.pixel(30, 80)) > 600, "nearly transparent at the path: {:?}", img.pixel(30, 80));
assert!(lum(img.pixel(80, 80)) <= black + 6, "solid inside");
assert_eq!(lum(img.pixel(27, 80)), WHITE, "still clipped");
// Inner Glow: white screened in from the edges.
d.item_mut(id).unwrap().effects.feather = 0.0;
let g = &mut d.item_mut(id).unwrap().effects.inner_glow;
g.on = true;
g.size = 12.0;
g.opacity = 1.0;
let img = render(&d);
assert!(lum(img.pixel(32, 80)) > black + 150, "lit at the edge: {:?}", img.pixel(32, 80));
assert!(lum(img.pixel(80, 80)) <= black + 6, "dark in the middle");
// Drop shadow: cast by the content's silhouette, outside the frame.
d.item_mut(id).unwrap().effects.inner_glow.on = false;
let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow;
ds.on = true;
ds.angle = 180.0;
ds.global_light = false;
ds.distance = 12.0;
ds.size = 0.0;
ds.opacity = 1.0;
let img = render(&d);
assert!(lum(img.pixel(136, 80)) < 300, "the shadow to the right: {:?}", img.pixel(136, 80));
}
#[test]
fn inner_glow_mode_and_technique() {
let mut d = doc();
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "[Paper]");
let g = &mut d.item_mut(id).unwrap().effects.inner_glow;
g.on = true;
g.color = "[Black]".into();
g.opacity = 1.0;
g.size = 20.0;
// Screen (the default) can't darken white paper; Multiply can.
let screen = render(&d);
assert!(lum(screen.pixel(32, 80)) > 740, "{:?}", screen.pixel(32, 80));
d.item_mut(id).unwrap().effects.inner_glow.mode = designcraft_color::BlendMode::Multiply;
let soft = render(&d);
assert!(lum(soft.pixel(32, 80)) + 150 < lum(screen.pixel(32, 80)), "a dark edge: {:?}", soft.pixel(32, 80));
// Precise keeps the glow nearer the edge than Softer.
d.item_mut(id).unwrap().effects.inner_glow.technique = designcraft_doc::GlowTechnique::Precise;
let precise = render(&d);
assert!(lum(precise.pixel(42, 80)) > lum(soft.pixel(42, 80)) + 30, "{:?} vs {:?}", precise.pixel(42, 80), soft.pixel(42, 80));
}
#[test]
fn feather_choke_and_corners() {
let mut d = doc();
let id = rect_item(&mut d, Rect::new(30.0, 30.0, 130.0, 130.0), "[Black]");
d.item_mut(id).unwrap().effects.feather = 16.0;
let diffused = render(&d);
golden("feather", &diffused);
// Choke: half the width stays opaque, so the fade is shorter.
d.item_mut(id).unwrap().effects.feather_options.choke = 50.0;
let choked = render(&d);
assert!(lum(choked.pixel(38, 80)) + 60 < lum(diffused.pixel(38, 80)), "{:?} vs {:?}", choked.pixel(38, 80), diffused.pixel(38, 80));
assert!(lum(choked.pixel(30, 80)) > 500, "still transparent at the path");
// Sharp corners keep more of the corner than Rounded ones (past the fade, near the corner).
d.item_mut(id).unwrap().effects.feather_options = designcraft_doc::FeatherOptions { choke: 0.0, corners: designcraft_doc::FeatherCorners::Sharp };
let sharp = render(&d);
d.item_mut(id).unwrap().effects.feather_options.corners = designcraft_doc::FeatherCorners::Rounded;
let rounded = render(&d);
assert!(lum(sharp.pixel(41, 41)) + 20 < lum(rounded.pixel(41, 41)), "{:?} vs {:?}", sharp.pixel(41, 41), rounded.pixel(41, 41));
}