//! 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 = (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)); }