//! 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, effects: Vec) -> (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, bool, Pt, Option, Probes); let rows: Vec = 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, Probes); let rows: Vec = 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, 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, 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| { 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"); }