225 lines
8.8 KiB
Rust
225 lines
8.8 KiB
Rust
//! 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 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.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.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));
|
|
}
|
|
|
|
/// 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}");
|
|
}
|
|
}
|