//! Noise and texture: grains in the layer's own coordinates (stable under pans), colour modes, //! size and density; texture scattering edges, clipped or not. use figcraft_color::{Color, Paint}; use figcraft_doc::{Document, Effect, EffectKind, NodeId, NodeKind, Noise, NoiseMode, Texture}; use super::*; /// A mid-grey 100 × 100 rectangle at (50, 50) with `kind`. fn square(kind: EffectKind) -> (Document, NodeId) { let mut d = Document::new("grain"); let p = d.first_page().unwrap(); let r = d.create(p, NodeKind::Rectangle, "R", 50.0, 50.0, 100.0, 100.0).unwrap(); let n = d.get_mut(r).unwrap(); n.fills = vec![Paint::solid(Color::from_hex("808080").unwrap())]; n.effects = vec![Effect { kind, visible: true }]; (d, r) } /// Node `id` on a transparent `w` × `h` view whose top-left is page `origin`. fn render_at(d: &Document, id: NodeId, origin: (f64, f64), scale: f64) -> Pixmap { let mut pix = Pixmap::new(200, 200).unwrap(); render_nodes_into(d, &[id], &View { origin, scale, width: 200, height: 200 }, &mut pix); pix } 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 distinct pixels of the square's inside (60..140). fn inside(p: &Pixmap) -> Vec<[u8; 4]> { (60..140).flat_map(|y| (60..140).map(move |x| (x, y))).map(|(x, y)| px(p, x, y)).collect() } fn noise(n: Noise) -> EffectKind { EffectKind::Noise(n) } #[test] fn noise_density_sets_the_share_of_grains() { let grey = [128, 128, 128, 255]; for (density, lo, hi) in [(0.0, 0.0, 0.0), (0.3, 0.25, 0.35), (1.0, 1.0, 1.0)] { let (d, r) = square(noise(Noise { density, color: Color::BLACK, size: 0.5, ..Noise::default() })); let p = render_at(&d, r, (0.0, 0.0), 1.0); let px = inside(&p); let share = px.iter().filter(|c| **c != grey).count() as f64 / px.len() as f64; assert!((lo..=hi).contains(&share), "density {density}: {share}"); } } #[test] fn noise_colour_modes() { let (red, blue) = (Color::from_hex("FF0000").unwrap(), Color::from_hex("0000FF").unwrap()); // Mono: grains of the one colour. let (d, r) = square(noise(Noise { density: 1.0, color: red, ..Noise::default() })); assert!(inside(&render_at(&d, r, (0.0, 0.0), 1.0)).iter().all(|c| *c == [255, 0, 0, 255])); // Duo: grains of both colours, about half each. let (d, r) = square(noise(Noise { mode: NoiseMode::Duo, density: 1.0, color: red, secondary: blue, ..Noise::default() })); let px = inside(&render_at(&d, r, (0.0, 0.0), 1.0)); let reds = px.iter().filter(|c| **c == [255, 0, 0, 255]).count(); let blues = px.iter().filter(|c| **c == [0, 0, 255, 255]).count(); assert_eq!(reds + blues, px.len()); assert!(reds > px.len() / 3 && blues > px.len() / 3, "{reds} / {blues}"); // Multi: grains of many colours at the noise's opacity. let (d, r) = square(noise(Noise { mode: NoiseMode::Multi, density: 1.0, opacity: 1.0, ..Noise::default() })); let mut colours = inside(&render_at(&d, r, (0.0, 0.0), 1.0)); colours.sort_unstable(); colours.dedup(); assert!(colours.len() > 1000, "{}", colours.len()); } #[test] fn noise_grains_have_their_size_and_stay_put() { // Size 2: grains 4 units across, aligned to the layer (at 50, 50: grains start at 62). let (d, r) = square(noise(Noise { density: 0.5, size: 2.0, color: Color::BLACK, ..Noise::default() })); let a = render_at(&d, r, (0.0, 0.0), 1.0); for gy in 0..20 { for gx in 0..20 { let (x0, y0) = (62 + gx * 4, 62 + gy * 4); let c = px(&a, x0, y0); for (x, y) in [(x0 + 3, y0), (x0, y0 + 3), (x0 + 3, y0 + 3)] { assert_eq!(px(&a, x, y), c, "grain at ({x0}, {y0})"); } } } // Panning by whole pixels moves the grains with the layer. let b = render_at(&d, r, (7.0, 3.0), 1.0); for (x, y) in [(60, 60), (90, 75), (130, 131)] { assert_eq!(px(&a, x, y), px(&b, x - 7, y - 3)); } // Zooming scales them: at 2× a grain is 8 pixels. let z = render_at(&d, r, (50.0, 50.0), 2.0); let c = px(&z, 16, 16); assert_eq!(px(&z, 23, 23), c); } #[test] fn noise_blend_mode_applies() { let (d, r) = square(noise(Noise { density: 1.0, color: Color::WHITE, blend: figcraft_color::BlendMode::Multiply, ..Noise::default() })); // White multiplied changes nothing. assert!(inside(&render_at(&d, r, (0.0, 0.0), 1.0)).iter().all(|c| *c == [128, 128, 128, 255])); } #[test] fn texture_roughens_edges() { let plain = { let (d, r) = square(EffectKind::Texture(Texture { radius: 0.0, ..Texture::default() })); render_at(&d, r, (0.0, 0.0), 1.0) }; // Radius 6: pixels scatter up to 6 past the edge; the inside of a flat fill stays flat. let (d, r) = square(EffectKind::Texture(Texture { radius: 6.0, size: 0.5, ..Texture::default() })); let rough = render_at(&d, r, (0.0, 0.0), 1.0); let outside: usize = (40..50).flat_map(|y| (60..140).map(move |x| (x, y))).filter(|&(x, y)| px(&rough, x, y)[3] > 0).count(); assert!(outside > 50, "{outside}"); assert_eq!(px(&rough, 100, 100), px(&plain, 100, 100)); let edge_holes: usize = (50..56).flat_map(|y| (60..140).map(move |x| (x, y))).filter(|&(x, y)| px(&rough, x, y)[3] == 0).count(); assert!(edge_holes > 20, "{edge_holes}"); // Clipped to the shape: nothing outside. let (d, r) = square(EffectKind::Texture(Texture { radius: 6.0, clip_to_shape: true, ..Texture::default() })); let clipped = render_at(&d, r, (0.0, 0.0), 1.0); assert!((0..200).all(|x| (0..48).all(|y| px(&clipped, x, y)[3] == 0))); } #[test] fn hostile_grain_values_do_not_crash() { for n in [ Noise { size: f64::NAN, density: f64::INFINITY, opacity: f32::NAN, ..Noise::default() }, Noise { size: 0.0, size_y: Some(-5.0), density: -1.0, ..Noise::default() }, Noise { size: 1e300, mode: NoiseMode::Multi, ..Noise::default() }, ] { let (d, r) = square(noise(n)); let _ = render_at(&d, r, (0.0, 0.0), 1.0); } for t in [Texture { radius: f64::NAN, ..Texture::default() }, Texture { radius: 1e12, size: 0.0, ..Texture::default() }] { let (d, r) = square(EffectKind::Texture(t)); let _ = render_at(&d, r, (0.0, 0.0), 1.0); } }