figcraft/crates/render/src/grain_tests.rs

141 lines
6.2 KiB
Rust
Raw Permalink 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.

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