Files
vectorcraft/crates/effects/src/pixel/tests.rs
T
dotCooCoo c586cecf75 Effects: Glass loads its texture from an image file
Effect › Distort › Glass… offered only its four built-in textures. The
reference app's Texture menu also has Load Texture, which takes an
image file as the glass surface.

The Glass dialog now has a Load Texture… button. It reads a PNG, JPEG,
GIF, WebP, TIFF, BMP, PSD or PSB file within the pixel allowance the
renderer draws images in, and the Texture menu gains Loaded Texture.
The effect stores the image's gray levels in `textureImage` as a
grayscale PNG in base64, so the document saves the texture with the
effect, and copies of the effect carry it. effect.apply and
effect.setParams take `textureImage` as {path}, {name, dataBase64} or
the stored string; any other value is an error.

The texture lies one image pixel per point at Scaling 100%, centered on
the object and repeated. Each pixel's luma is the surface's height,
from 0 for black to 1 pt for white, and a transparent pixel counts as
middle gray. A Glass without `textureImage` draws and saves as before.

Docs: docs/mcp.md (Glass textures), docs/effects-parity.md,
docs/gaps.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00

1896 lines
98 KiB
Rust
Raw 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.
use serde_json::json;
use vectorcraft_doc::Effect;
use vectorcraft_geom::{Affine, Point, Rect};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
let mut out = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
out.extend(f(x, y));
}
}
out
}
fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
let i = (y * w + x) * 4;
[d[i], d[i + 1], d[i + 2], d[i + 3]]
}
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
let e = Effect { id: id.into(), params: p, visible: true };
match raster_effects(&[e]).first() {
Some(RasterFx::Pixel(p)) => p.clone(),
other => panic!("{id}: {other:?}"),
}
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
#[test]
fn film_grain_parameters_are_bounded_and_neutral_settings_preserve_the_art() {
assert_eq!(fx("artistic.filmGrain", json!({})), PixelFx::FilmGrain { grain: 4.0, highlight: 0.0, intensity: 10.0 });
assert_eq!(
fx("artistic.filmGrain", json!({"grain": 1e308, "highlightArea": -100, "intensity": 200})),
PixelFx::FilmGrain { grain: 20.0, highlight: 0.0, intensity: 10.0 }
);
assert_eq!(fx("artistic.filmGrain", json!({"grain": "1e999", "intensity": []})), fx("artistic.filmGrain", json!({})));
let (w, h) = (24, 18);
let src = image(w, h, |x, y| [(3 * x) as u8, (2 * y) as u8, 12, 128]);
let mut out = src.clone();
let neutral = fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": 0, "intensity": 0}));
neutral.apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src);
assert_eq!(neutral.outset(Rect::new(0.0, 0.0, w as f64, h as f64)), 0.0);
assert_eq!(neutral.reach(), Some(0.0));
}
#[test]
fn film_grain_is_monochrome_and_quieter_in_highlights() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 20, "intensity": 0}));
let mut changes = Vec::new();
for tone in [64, 128, 240] {
let mut out = image(w, h, |_, _| grey(tone));
grain.apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
changes.push(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(tone))).sum::<f64>() / (w * h) as f64);
}
assert!(changes[0] > 3.0 * changes[2] && changes[1] > 3.0 * changes[2], "shadows and midtones take more grain: {changes:?}");
}
#[test]
fn film_grain_highlight_area_and_intensity_change_the_tones() {
let (w, h) = (16, 16);
let apply = |tone, area, intensity| {
let mut out = image(w, h, |_, _| grey(tone));
fx("artistic.filmGrain", json!({"grain": 0, "highlightArea": area, "intensity": intensity})).apply(&mut out, w, h, &space(w, h));
out[0]
};
assert_eq!(apply(64, 0, 10), 64, "intensity alone doesn't recolour shadows");
assert_eq!(apply(192, 0, 10), 192, "intensity alone doesn't add global contrast");
assert!(apply(192, 20, 10) > apply(192, 20, 0), "intensity sets highlight strength");
let tone = 192;
let mut strong = image(w, h, |_, _| grey(tone));
let mut quiet = strong.clone();
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 0})).apply(&mut strong, w, h, &space(w, h));
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 0, "intensity": 10})).apply(&mut quiet, w, h, &space(w, h));
let spread = |data: &[u8]| data.as_chunks::<4>().0.iter().map(|p| u32::from(p[0].abs_diff(tone))).sum::<u32>();
assert!(spread(&strong) > spread(&quiet), "Intensity also quiets highlight grain with the default Highlight Area");
let highlights = [0, 10, 20].map(|area| apply(192, area, 10));
assert!(highlights.windows(2).all(|p| p[0] < p[1]), "wider highlight area lifts more midtones: {highlights:?}");
assert_eq!(apply(0, 20, 10), 0, "black doesn't become a white background");
}
#[test]
fn film_grain_preserves_coverage_without_a_halo() {
let (w, h) = (40, 30);
let src = image(w, h, |x, y| match (x + y) % 5 {
0 => [0; 4],
1 => [1, 0, 1, 1],
2 => [75, 12, 25, 128],
_ => [180, 60, 90, 255],
});
let mut out = src.clone();
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
for (p, original) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], original[3]);
assert!(p[..3].iter().all(|c| *c <= p[3]), "premultiplied: {p:?}");
if original[3] == 0 {
assert_eq!(p, original);
}
}
}
#[test]
fn film_grain_pattern_survives_redraws_moves_resolution_changes_and_tiles() {
let (w, h) = (32, 24);
let src = image(w, h, |_, _| grey(128));
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
let mut whole = src.clone();
grain.apply(&mut whole, w, h, &space(w, h));
let mut again = src.clone();
grain.apply(&mut again, w, h, &space(w, h));
assert_eq!(whole, again);
let moved =
PixelSpace { to_doc: Affine::translate((17.0, -9.0)), center: space(w, h).center + vectorcraft_geom::Vec2::new(17.0, -9.0), ..space(w, h) };
let mut shifted = src;
grain.apply(&mut shifted, w, h, &moved);
assert_eq!(whole, shifted);
let mut two = image(2 * w, 2 * h, |_, _| grey(128));
grain.apply(&mut two, 2 * w, 2 * h, &PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) });
let mut difference = 0u64;
for y in 0..h {
for x in 0..w {
let down = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| u32::from(at(&two, 2 * w, 2 * x + dx, 2 * y + dy)[0]));
let down = (down.iter().sum::<u32>() / 4) as u8;
difference += u64::from(at(&whole, w, x, y)[0].abs_diff(down));
}
}
let mean_delta = difference as f64 / (w * h) as f64;
let fine_deviation = film_grain_deviation(two.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
// Two diagonal samples approximate the four point samples in the finer raster. Bound
// their error relative to grain strength, so a stronger tonal curve does not fail it.
assert!(mean_delta < 0.25 * fine_deviation, "the field keeps its document scale: mean delta {mean_delta}, point deviation {fine_deviation}");
let (tw, th) = (13, 11);
let mut tile = image(tw, th, |_, _| grey(128));
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: Affine::translate((5.0, 3.0)), ..space(w, h) });
for y in 0..th {
for x in 0..tw {
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 5, y + 3), "no tile seam");
}
}
}
#[test]
fn film_grain_keeps_colour_without_an_extra_contrast_curve_and_scales_with_alpha() {
let (w, h) = (256, 256);
let original = [180, 100, 60, 255];
let mut out = image(w, h, |_, _| original);
// Intensity with no highlight area must not silently change colour or clip channels.
fx("artistic.filmGrain", json!({"grain": 0, "intensity": 10})).apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| *p == original));
let grain = fx("artistic.filmGrain", json!({"grain": 4}));
grain.apply(&mut out, w, h, &space(w, h));
for p in out.as_chunks::<4>().0 {
// A neutral floating offset can round either side of a half-byte tie in each channel.
assert!((i32::from(p[0]) - i32::from(p[1]) - 80).abs() <= 1, "one neutral offset, no channel clipping");
assert!((i32::from(p[1]) - i32::from(p[2]) - 40).abs() <= 1);
}
// These straight colours have exact premultiplied representations at alpha 85 and 170.
let colour = [180, 120, 60];
let mut opaque = image(w, h, |_, _| [colour[0], colour[1], colour[2], 255]);
grain.apply(&mut opaque, w, h, &space(w, h));
for alpha in [85u8, 170] {
let mut translucent = image(w, h, |_, _| {
let [r, g, b] = colour.map(|c| (u32::from(c) * u32::from(alpha) / 255) as u8);
[r, g, b, alpha]
});
grain.apply(&mut translucent, w, h, &space(w, h));
for (p, full) in translucent.as_chunks::<4>().0.iter().zip(opaque.as_chunks::<4>().0) {
for c in 0..3 {
let expected = (u32::from(full[c]) * u32::from(alpha) + 127) / 255;
assert!(i32::from(p[c]).abs_diff(expected as i32) <= 1, "alpha scales colour consistently: {p:?}, {full:?}");
}
assert_eq!(p[3], alpha);
}
}
let mut white = image(w, h, |_, _| grey(255));
fx("artistic.filmGrain", json!({"grain": 20, "highlightArea": 20, "intensity": 10})).apply(&mut white, w, h, &space(w, h));
assert!(white.as_chunks::<4>().0.iter().all(|p| *p == grey(255)), "white stays clean");
}
#[test]
fn film_grain_is_visible_on_dark_fills_with_even_shadow_and_midtone_strength() {
let (w, h) = (128, 96);
let grain = fx("artistic.filmGrain", json!({}));
let render = |tone| {
let mut out = image(w, h, |_, _| grey(tone));
grain.apply(&mut out, w, h, &space(w, h));
out
};
let black = render(0);
assert!(black.as_chunks::<4>().0.iter().any(|p| p[0] > 2), "pure black takes light grain speckles");
assert!(film_grain_deviation(black.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))) > 2.0);
let dark = render(26);
let mid = render(128);
let [dark, mid] = [dark, mid].map(|out| film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
assert!(dark > 4.0 && (dark / mid - 1.0).abs() < 0.02, "default grain must remain even across shadows and midtones: {dark}, {mid}");
}
#[test]
fn film_grain_leaves_soft_but_visible_grain_on_light_fills_at_defaults() {
let (w, h) = (128, 96);
let colour = [245, 240, 225, 255];
let mut cream = image(w, h, |_, _| colour);
fx("artistic.filmGrain", json!({})).apply(&mut cream, w, h, &space(w, h));
let deviation = film_grain_deviation(cream.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
let changed = cream.as_chunks::<4>().0.iter().filter(|p| **p != colour).count();
assert!((0.5..3.0).contains(&deviation), "soft grain must remain visible on cream: {deviation}");
assert!(changed > w * h / 4, "grain should cover the fill, not just a few outliers: {changed}");
assert!(cream.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
}
#[test]
fn film_grain_tiles_match_at_fractional_centres_and_rotated_scales() {
let (w, h) = (40, 32);
let grain = fx("artistic.filmGrain", json!({"grain": 15}));
for scale in [0.7, 2.5] {
let s = PixelSpace { to_doc: Affine::rotate(0.37) * Affine::scale(scale), px: scale, center: Point::new(12.375, -4.625), ..space(w, h) };
let mut whole = image(w, h, |_, _| grey(128));
grain.apply(&mut whole, w, h, &s);
let (tw, th) = (19, 13);
let mut tile = image(tw, th, |_, _| grey(128));
grain.apply(&mut tile, tw, th, &PixelSpace { to_doc: s.to_doc * Affine::translate((7.0, 5.0)), ..s });
for y in 0..th {
for x in 0..tw {
assert_eq!(at(&tile, tw, x, y), at(&whole, w, x + 7, y + 5), "fractional rotated tile seam at scale {scale}");
}
}
}
}
#[test]
fn film_grain_minification_quiets_the_noise_instead_of_aliasing_at_full_strength() {
let (w, h) = (64, 64);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let spread = |scale| {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, ..space(w, h) });
out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0].abs_diff(128))).sum::<f64>() / (w * h) as f64
};
let normal = spread(1.0);
assert!(normal > 2.0);
assert!(spread(2.0) < normal && spread(4.0) < normal / 2.0);
}
#[test]
fn film_grain_sampling_modes_blend_without_a_zoom_threshold_pop() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for boundary in [0.5, 0.75, 1.0, 1.5, 2.0] {
// Hold sample coordinates fixed to isolate the footprint-mode boundary. Changing
// to_doc as well moves the texture, which can exceed this limit far from the origin.
let render = |scale| {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(boundary), px: scale, ..space(w, h) });
out
};
let (left, right) = (render(boundary - 1e-4), render(boundary + 1e-4));
let change = left.iter().zip(right).map(|(a, b)| f64::from(a.abs_diff(b))).sum::<f64>() / left.len() as f64;
assert!(change < 0.25, "no sudden grain change around px={boundary}: {change}");
}
}
#[test]
fn film_grain_coarse_variance_tracks_an_independent_dense_footprint_average() {
let (w, h, samples) = (32, 32, 16);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let deviation = |values: &[f64]| {
let mean = values.iter().sum::<f64>() / values.len() as f64;
(values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64).sqrt()
};
for scale in [1.0, 2.0, 4.0, 8.0] {
let s = PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
..space(w, h)
};
let mut actual = image(w, h, |_, _| grey(128));
grain.apply(&mut actual, w, h, &s);
// 16×16 independent point samples per coarse pixel; the finest raster never takes
// the footprint branch, so this does not reproduce the filter's quadrature weights.
let fine_scale = scale / samples as f64;
let mut fine = image(w * samples, h * samples, |_, _| grey(128));
grain.apply(&mut fine, w * samples, h * samples, &PixelSpace { to_doc: Affine::scale(fine_scale), px: fine_scale, ..s });
let mut reference = Vec::new();
let mut coarse = Vec::new();
for y in 0..h {
for x in 0..w {
let mut sum = 0u32;
for dy in 0..samples {
for dx in 0..samples {
sum += u32::from(at(&fine, w * samples, x * samples + dx, y * samples + dy)[0]);
}
}
reference.push(f64::from(sum) / (samples * samples) as f64);
coarse.push(f64::from(at(&actual, w, x, y)[0]));
}
}
let ratio = deviation(&coarse) / deviation(&reference);
assert!((0.75..=1.25).contains(&ratio), "bounded footprint approximation at px={scale}: relative deviation {ratio}");
}
}
#[test]
fn film_grain_strength_is_stable_under_subpixel_centre_shifts() {
let (w, h) = (192, 128);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for scale in [0.25, 0.5, 1.0] {
let mut deviations = Vec::new();
for phase in [0.0, 0.25, 0.5, 0.75] {
let mut out = image(w, h, |_, _| grey(128));
let s = PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + phase, h as f64 * scale / 2.0 + phase),
..space(w, h)
};
grain.apply(&mut out, w, h, &s);
deviations.push(film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0]))));
}
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
let max = deviations.iter().copied().fold(0.0, f64::max);
assert!(min > 1.0 && max / min < 1.06, "centre phase must not change grain strength at px={scale}: {deviations:?}");
}
}
fn film_grain_deviation(values: impl Iterator<Item = f64>) -> f64 {
let (mut count, mut sum, mut square_sum) = (0.0, 0.0, 0.0);
for v in values {
count += 1.0;
sum += v;
square_sum += v * v;
}
(square_sum / count - (sum / count).powi(2)).max(0.0).sqrt()
}
#[test]
fn film_grain_preserves_byte_values_when_the_grain_is_too_small_to_round() {
let (w, h) = (256, 256);
let src = image(w, h, |x, y| {
let alpha = y as u8;
[(x as u8).min(alpha), (255 - x as u8).min(alpha), (x / 2) as u8 % alpha.max(1), alpha]
});
let mut out = src.clone();
// Even the maximum field value cannot change half a premultiplied byte at this strength.
fx("artistic.filmGrain", json!({"grain": 0.01})).apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src, "invisible noise preserves every colour/coverage combination");
}
#[test]
fn film_grain_cmy_and_k_planes_share_the_noise_and_preserve_coverage() {
let (w, h) = (64, 48);
let grain = fx("artistic.filmGrain", json!({"grain": 14}));
// Complemented inks of a rich black, premultiplied by the same coverage.
let mut cmy = image(w, h, |_, _| [70, 80, 90, 200]);
let mut k = image(w, h, |_, _| [40, 40, 40, 200]);
grain.apply(&mut cmy, w, h, &PixelSpace { channels: Channels::CmyPlane, ..space(w, h) });
grain.apply(&mut k, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
let mut changed = 0;
for (cmy, k) in cmy.as_chunks::<4>().0.iter().zip(k.as_chunks::<4>().0) {
assert_eq!((cmy[3], k[3]), (200, 200));
assert!(cmy[..3].iter().all(|v| *v <= 200) && k[..3].iter().all(|v| *v <= 200));
assert_eq!(k[0], k[1]);
assert_eq!(k[1], k[2]);
let (dc, dk) = (i16::from(cmy[0]) - 70, i16::from(k[0]) - 40);
if dc != 0 && dk != 0 {
assert_eq!(dc.signum(), dk.signum(), "the same field modulates both ink planes");
changed += 1;
}
}
assert!(changed > w * h / 2);
}
#[test]
fn film_grain_does_not_imprint_a_periodic_contrast_grid_at_close_zoom() {
let (w, h) = (256, 256);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
for (scale, period) in [(0.25, 4), (0.125, 8)] {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(&mut out, w, h, &PixelSpace { to_doc: Affine::scale(scale), px: scale, center: Point::new(32.0, 32.0), ..space(w, h) });
// Bin by location within a document-unit lattice cell. An axis-aligned value field
// puts stronger noise at corners than between them; rotated normalised fields do not.
let mut deviations = Vec::new();
for dy in 0..period {
for dx in 0..period {
let values = (dy..h).step_by(period).flat_map(|y| (dx..w).step_by(period).map(move |x| (x, y)));
deviations.push(film_grain_deviation(values.map(|(x, y)| f64::from(at(&out, w, x, y)[0]))));
}
}
let min = deviations.iter().copied().fold(f64::INFINITY, f64::min);
let max = deviations.iter().copied().fold(0.0, f64::max);
assert!(max / min < 1.12, "no visible cell-contrast grid at px={scale}: min={min}, max={max}");
}
}
#[test]
fn film_grain_strength_falls_smoothly_across_noninteger_zoom_levels() {
let (w, h) = (128, 96);
let grain = fx("artistic.filmGrain", json!({"grain": 20}));
let mut previous = f64::INFINITY;
for scale in [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0] {
let mut out = image(w, h, |_, _| grey(128));
grain.apply(
&mut out,
w,
h,
&PixelSpace {
to_doc: Affine::scale(scale),
px: scale,
center: Point::new(w as f64 * scale / 2.0 + 0.375, h as f64 * scale / 2.0 - 0.625),
..space(w, h)
},
);
let deviation = film_grain_deviation(out.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])));
assert!(deviation <= previous * 1.06, "grain must not gain contrast when zooming out at px={scale}: {deviation} after {previous}");
previous = deviation;
}
}
#[test]
fn film_grain_leaves_invalid_coordinate_spaces_alone() {
let (w, h) = (8, 8);
let src = image(w, h, |_, _| grey(128));
for invalid in [
PixelSpace { px: 0.0, ..space(w, h) },
PixelSpace { px: f64::NAN, ..space(w, h) },
PixelSpace { to_doc: Affine::scale(0.0), ..space(w, h) },
PixelSpace { center: Point::new(f64::INFINITY, 0.0), ..space(w, h) },
] {
let mut out = src.clone();
fx("artistic.filmGrain", json!({})).apply(&mut out, w, h, &invalid);
assert_eq!(out, src);
}
let mut out = src.clone();
PixelFx::FilmGrain { grain: f64::NAN, highlight: 0.0, intensity: 10.0 }.apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src, "directly constructed non-finite parameters are harmless too");
}
#[test]
fn params_take_defaults_and_stay_in_range() {
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
assert_eq!(
fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best"})),
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
);
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::Normal });
assert_eq!(
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low", "blurMode": "EDGEONLY"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5, mode: SmartBlurMode::EdgeOnly }
);
assert_eq!(
fx("blur.smart", json!({"blurMode": "overlayEdge"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::OverlayEdge }
);
assert_eq!(fx("blur.smart", json!({"blurMode": "overlay"})), fx("blur.smart", json!({})));
// Every Mode menu value is documented in the catalogue and parses to a mode of its own.
let doc = crate::effect_info("blur.smart").unwrap().params;
for (i, (_, v)) in SMART_BLUR_MODES.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
assert_eq!(SmartBlurMode::parse(v), [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge][i], "{v}");
}
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
assert_eq!(
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 }
);
assert_eq!(fx("stylize.glowingEdges", json!({})), PixelFx::GlowingEdges { width: 2.0, brightness: 6.0, smoothness: 5.0 });
for id in PIXEL_EFFECTS {
assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}");
}
}
#[test]
fn outsets_follow_the_object() {
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
let spin = fx("blur.radial", json!({}));
let far = 0.5 * 100f64.hypot(50.0);
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
assert_eq!(spin.reach(), None);
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
}
#[test]
fn wrong_sizes_and_empty_rasters_are_left_alone() {
for id in PIXEL_EFFECTS {
let f = fx(id, json!({}));
let mut d = vec![200u8; 4 * 10];
f.apply(&mut d, 3, 3, &space(3, 3));
f.apply(&mut d, 0, 10, &space(0, 10));
f.apply(&mut [], 0, 0, &space(0, 0));
assert!(d.iter().all(|v| *v == 200), "{id}");
}
}
#[test]
fn extreme_parameters_finish() {
let (w, h) = (40, 30);
let src = image(w, h, |x, y| if (x / 5 + y / 5) % 2 == 0 { grey(250) } else { [0, 0, 0, 0] });
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
for id in PIXEL_EFFECTS {
for p in [json!({"amount": 1e308, "radius": 1e308, "threshold": 1e308, "quality": "best"}), json!({"amount": 0, "radius": 0})] {
for s in [tiny, huge, space(w, h)] {
let mut d = src.clone();
fx(id, p.clone()).apply(&mut d, w, h, &s);
}
}
}
}
#[test]
fn every_effect_is_deterministic() {
let (w, h) = (32, 24);
let src = image(w, h, |x, y| {
let a = if x > 4 { 255 } else { 128 };
[((x * 8) as u8).min(a), ((y * 10) as u8).min(a), ((x * y % 256) as u8).min(a), a]
});
for id in PIXEL_EFFECTS {
let f = fx(id, json!({"amount": 40, "radius": 3, "threshold": 40}));
let (mut a, mut b) = (src.clone(), src.clone());
f.apply(&mut a, w, h, &space(w, h));
f.apply(&mut b, w, h, &space(w, h));
assert_eq!(a, b, "{id}");
assert_ne!(a, src, "{id} changes the image");
// Premultiplied stays premultiplied.
assert!(a.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
}
}
#[test]
fn glowing_edges_draw_a_bright_outline_and_preserve_premultiplication() {
let (w, h) = (9, 9);
let src = image(w, h, |x, _| if x < 4 { [0, 0, 0, 200] } else { [200, 200, 200, 200] });
let mut out = src.clone();
fx("stylize.glowingEdges", json!({"edgeWidth": 1, "smoothness": 1, "edgeBrightness": 20})).apply(&mut out, w, h, &space(w, h));
assert!(at(&out, w, 3, 4)[0] > 0 || at(&out, w, 4, 4)[0] > 0);
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
/// Glowing Edges sees transparency beyond every side of the raster: a solid square glows alike at
/// its left and right edges (the right one doesn't read the next row's first pixel).
#[test]
fn glowing_edges_glow_alike_on_every_side() {
let (w, h) = (9, 9);
let mut out = image(w, h, |_, _| [180, 180, 180, 255]);
fx("stylize.glowingEdges", json!({"edgeWidth": 1, "smoothness": 1, "edgeBrightness": 20})).apply(&mut out, w, h, &space(w, h));
let (left, right, top, bottom) = (at(&out, w, 0, 4), at(&out, w, 8, 4), at(&out, w, 4, 0), at(&out, w, 4, 8));
assert!(left[0] > 0 && left == right && top == bottom, "{left:?} {right:?} {top:?} {bottom:?}");
assert!(at(&out, w, 4, 4)[0] < left[0], "the inside stays darker than the edges");
}
#[test]
fn glowing_edges_defaults_keep_the_outline_bright_and_use_source_colour() {
let (w, h) = (17, 17);
let src = image(w, h, |x, y| if (4..13).contains(&x) && (4..13).contains(&y) { [180, 0, 0, 255] } else { [0; 4] });
let mut out = src.clone();
fx("stylize.glowingEdges", json!({})).apply(&mut out, w, h, &space(w, h));
let outline = at(&out, w, 4, 8);
assert!(outline[0] >= 170, "default outline should retain a near-full-color peak: {outline:?}");
let outside = at(&out, w, 3, 8);
assert_eq!(outside[1], 0, "transparent halo must not be grey: {outside:?}");
assert_eq!(outside[2], 0, "transparent halo must retain source hue: {outside:?}");
// The glow outside keeps the edge's colour as it fades, rather than darkening towards grey.
assert!(outside[3] > 0 && u32::from(outside[0]) * 255 >= 240 * u32::from(outside[3]), "{outside:?}");
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
#[test]
fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
let (w, h) = (41, 41);
// A white dot 12 px right of the centre (20.5, 20.5), and one on the centre.
let src = image(w, h, |x, y| if (x == 32 || x == 20) && y == 20 { grey(255) } else { [0, 0, 0, 0] });
let mut d = src.clone();
fx("blur.radial", json!({"amount": 60, "quality": "best"})).apply(&mut d, w, h, &space(w, h));
// 60° of arc around the centre: up to 30° either way, 12 px out: (20.5 + 12 cos 20°, 20.5 ± 12 sin 20°).
assert!(at(&d, w, 31, 16)[3] > 0 && at(&d, w, 31, 24)[3] > 0, "the dot spreads along its circle");
assert_eq!(at(&d, w, 32, 10)[3], 0, "not beyond the arc");
assert_eq!(at(&d, w, 26, 20)[3], 0, "not towards the centre");
assert!(at(&d, w, 20, 20)[3] > 100, "the centre barely moves: {:?}", at(&d, w, 20, 20));
// Zoom smears along the ray instead.
let mut z = src.clone();
fx("blur.radial", json!({"amount": 60, "method": "zoom", "quality": "best"})).apply(&mut z, w, h, &space(w, h));
assert!(at(&z, w, 35, 20)[3] > 0 && at(&z, w, 29, 20)[3] > 0, "the dot streaks along the ray");
assert_eq!(at(&z, w, 32, 16)[3], 0, "not across it");
}
#[test]
fn smart_blur_smooths_noise_and_keeps_edges() {
let (w, h) = (40, 20);
// Left: grey 100 with ±6 noise; right: white.
let src = image(w, h, |x, y| if x < 20 { grey(if (x + y) % 2 == 0 { 94 } else { 106 }) } else { grey(255) });
let mut d = src.clone();
fx("blur.smart", json!({"radius": 3, "threshold": 25})).apply(&mut d, w, h, &space(w, h));
let v = at(&d, w, 8, 10)[0];
assert!((97..=103).contains(&v), "noise smoothed: {v}");
assert!(at(&d, w, 19, 10)[0] < 120, "the edge's dark side doesn't take the white");
assert_eq!(at(&d, w, 20, 10)[0], 255, "the edge's white side stays white");
// A threshold above the step blurs across it.
let mut all = src.clone();
fx("blur.smart", json!({"radius": 3, "threshold": 100})).apply(&mut all, w, h, &space(w, h));
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
}
/// FNV-1a of `bytes`.
fn digest(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
}
#[test]
fn smart_blur_normal_mode_keeps_its_pixels() {
let (w, h) = (48, 40);
let src = image(w, h, |x, y| {
let a = if x > 4 && y < 36 { 255 } else { 128 };
[((x * 5 + y) as u8).min(a), ((y * 6) as u8).min(a), (((x * y) % 97) as u8).min(a), a]
});
let mut sums = Vec::new();
for p in [json!({}), json!({"radius": 2, "threshold": 10, "quality": "high"}), json!({"radius": 12, "threshold": 40, "quality": "low"})] {
let mut d = src.clone();
fx("blur.smart", p.clone()).apply(&mut d, w, h, &space(w, h));
let mut normal = src.clone();
let mut p = p;
p["blurMode"] = json!("normal");
fx("blur.smart", p).apply(&mut normal, w, h, &space(w, h));
assert_eq!(d, normal);
sums.push(digest(&d));
}
// The pixels Smart Blur gave before it had a mode.
assert_eq!(sums, [11495339692959753241, 14281410954765351056, 5111933201350526354]);
}
/// Smart Blur's test image: grays 56 and 64 in a checkerboard for x < 3 and 66 and 74 for x in 3..6
/// (steps of 2 or 18 levels at x = 3, under the threshold), gray 200 from x = 6 on (steps of 126 or
/// 134 levels). Its edges at threshold 25 are the pixels on the raster's border (transparency is
/// beyond it) and column 6, the step's lighter side.
fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool) {
let (w, h) = (12, 8);
let src = image(w, h, |x, y| match x {
0..3 => grey(if (x + y) % 2 == 0 { 56 } else { 64 }),
3..6 => grey(if (x + y) % 2 == 0 { 66 } else { 74 }),
_ => grey(200),
});
(w, h, src, move |x, y| x == 0 || y == 0 || x == w - 1 || y == h - 1 || x == 6)
}
#[test]
fn smart_blur_edge_only_draws_the_edges_white_on_black() {
let (w, h, src, edge) = smart_blur_steps();
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut d, w, h, &space(w, h));
let want = image(w, h, |x, y| if edge(x, y) { [255; 4] } else { [0, 0, 0, 255] });
assert_eq!(d, want);
// The radius changes only the blur.
let mut wide = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut wide, w, h, &space(w, h));
assert_eq!(wide, want);
// A threshold under the checkerboard's 8 levels makes its lighter squares edges too.
let mut low = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "threshold": 5})).apply(&mut low, w, h, &space(w, h));
for y in 1..h - 1 {
for x in 1..6 {
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
assert_eq!(at(&low, w, x, y)[0] == 255, lighter, "({x}, {y})");
}
}
// White and black keep each pixel's coverage, and transparency stays.
let (w, h) = (8, 8);
let shape = image(w, h, |x, y| if (2..6).contains(&x) && (2..6).contains(&y) { [64, 0, 0, 128] } else { [0; 4] });
let mut s = shape.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut s, w, h, &space(w, h));
let ring = |x: usize, y: usize| (2..6).contains(&x) && (2..6).contains(&y) && !((3..5).contains(&x) && (3..5).contains(&y));
let inside = |x: usize, y: usize| (3..5).contains(&x) && (3..5).contains(&y);
let want = image(w, h, |x, y| match (ring(x, y), inside(x, y)) {
(true, _) => [128; 4],
(_, true) => [0, 0, 0, 128],
_ => [0; 4],
});
assert_eq!(s, want);
}
#[test]
fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
let (w, h, src, edge) = smart_blur_steps();
let mut normal = src.clone();
fx("blur.smart", json!({})).apply(&mut normal, w, h, &space(w, h));
assert_ne!(normal, src, "the checkerboard is blurred");
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "overlayEdge"})).apply(&mut d, w, h, &space(w, h));
for y in 0..h {
for x in 0..w {
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
assert_eq!(at(&d, w, x, y), want, "({x}, {y})");
}
}
// Parameters past their ranges, at any resolution, still finish.
for mode in ["edgeOnly", "overlayEdge"] {
for s in [PixelSpace { px: 1e-9, ..space(w, h) }, PixelSpace { px: 1e9, ..space(w, h) }] {
let mut e = src.clone();
fx("blur.smart", json!({"blurMode": mode, "radius": 1e308, "threshold": 1e308, "quality": "high"})).apply(&mut e, w, h, &s);
}
}
}
#[test]
fn unsharp_mask_overshoots_at_edges() {
let (w, h) = (40, 10);
let src = image(w, h, |x, _| grey(if x < 20 { 100 } else { 150 }));
let mut d = src.clone();
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, w, h, &space(w, h));
assert!(at(&d, w, 19, 5)[0] < 95, "dark side darker: {:?}", at(&d, w, 19, 5));
assert!(at(&d, w, 20, 5)[0] > 155, "light side lighter: {:?}", at(&d, w, 20, 5));
assert_eq!(at(&d, w, 5, 5), grey(100), "flat areas stay");
assert_eq!(at(&d, w, 35, 5), grey(150));
// Differences under the threshold are left alone.
let mut t = src.clone();
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2, "threshold": 60})).apply(&mut t, w, h, &space(w, h));
assert_eq!(t, src);
// Coverage stays, and a shape's edge against transparency doesn't darken.
let shape = image(w, h, |x, _| if x < 20 { [200, 0, 0, 255] } else { [0, 0, 0, 0] });
let mut s = shape.clone();
fx("sharpen.unsharpMask", json!({"amount": 300, "radius": 3})).apply(&mut s, w, h, &space(w, h));
assert_eq!(s, shape);
}
#[test]
fn lengths_are_document_units() {
// The same art at twice the resolution, sharpened with the same radius in points: the
// overshoot spans twice the pixels, the same distance.
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
let run = |scale: usize| {
let mut d = edge(scale);
let s = PixelSpace {
to_doc: Affine::scale(1.0 / scale as f64),
px: 1.0 / scale as f64,
center: Point::ZERO,
channels: Channels::Rgb,
line: 1.0,
extent: 0.0,
};
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
};
let (one, two) = (run(1), run(2));
assert!(one > 1 && (two as f64 / one as f64 - 2.0).abs() < 0.5, "{one} px at 1×, {two} px at 2×");
}
#[test]
fn gaussian_and_plane_blurs_keep_their_mass() {
let (w, h) = (30, 30);
let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] });
let before: u32 = d.as_chunks::<4>().0.iter().map(|p| p[3] as u32).sum();
gaussian_rgba(&mut d, w, h, 2.0);
let after: u32 = d.as_chunks::<4>().0.iter().map(|p| p[3] as u32).sum();
assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}");
assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255);
let mut plane: Vec<f32> = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect();
blur_plane(&mut plane, w, h, 2.0);
assert!((plane.iter().sum::<f32>() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0);
}
/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored
/// --nocapture pixel_timings`).
#[test]
#[ignore]
fn pixel_timings() {
let (w, h) = (2000, 2000);
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0, extent: 0.0 };
let cases = [
("blur.radial", json!({"quality": "draft"})),
("blur.radial", json!({"quality": "good"})),
("blur.radial", json!({"quality": "best", "method": "zoom"})),
("blur.smart", json!({"quality": "low"})),
("blur.smart", json!({"quality": "high", "radius": 30})),
("sharpen.unsharpMask", json!({"radius": 20})),
("brushStrokes.accentedEdges", json!({"edgeWidth": 14})),
("brushStrokes.angledStrokes", json!({"strokeLength": 50})),
("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})),
("brushStrokes.darkStrokes", json!({})),
("brushStrokes.inkOutlines", json!({"strokeLength": 50})),
("brushStrokes.spatter", json!({"sprayRadius": 25})),
("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25})),
("brushStrokes.sumiE", json!({"strokeWidth": 15})),
("pixelate.colorHalftone", json!({"maxRadius": 4})),
("pixelate.crystallize", json!({"cellSize": 3})),
("pixelate.crystallize", json!({"cellSize": 300})),
("pixelate.mezzotint", json!({"type": "grainyDots"})),
("pixelate.pointillize", json!({"cellSize": 3})),
("pixelate.pointillize", json!({"cellSize": 300})),
("texture.craquelure", json!({"crackSpacing": 2})),
("texture.craquelure", json!({"crackSpacing": 100})),
("texture.grain", json!({"grainType": "clumped"})),
("texture.mosaicTiles", json!({"tileSize": 2})),
("texture.patchwork", json!({"squareSize": 0})),
("texture.stainedGlass", json!({"cellSize": 2})),
("texture.texturizer", json!({"texture": "sandstone", "scaling": 50})),
("texture.texturizer", json!({"texture": "burlap"})),
];
for (id, p) in cases {
let mut d = src.clone();
let t = std::time::Instant::now();
fx(id, p.clone()).apply(&mut d, w, h, &s);
println!("{id} {p}: {:?}", t.elapsed());
}
}
#[test]
fn pixelate_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("pixelate.colorHalftone", json!({})), PixelFx::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] });
assert_eq!(
fx("pixelate.colorHalftone", json!({"maxRadius": 1e9, "channel1": -1e9, "channel2": 30, "channel4": "x"})),
PixelFx::ColorHalftone { max_radius: 127.0, angles: [-360.0, 30.0, 90.0, 45.0] }
);
assert_eq!(fx("pixelate.colorHalftone", json!({"maxRadius": 0})), PixelFx::ColorHalftone { max_radius: 4.0, angles: [108.0, 162.0, 90.0, 45.0] });
assert_eq!(fx("pixelate.crystallize", json!({})), PixelFx::Crystallize { cell: 10.0 });
assert_eq!(fx("pixelate.crystallize", json!({"cellSize": -4})), PixelFx::Crystallize { cell: 3.0 });
assert_eq!(fx("pixelate.pointillize", json!({})), PixelFx::Pointillize { cell: 5.0 });
assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 1e300})), PixelFx::Pointillize { cell: 300.0 });
assert_eq!(fx("pixelate.mezzotint", json!({})), PixelFx::Mezzotint { kind: Mezzotint::FINE_DOTS });
assert_eq!(fx("pixelate.mezzotint", json!({"type": "LONGSTROKES"})), PixelFx::Mezzotint { kind: Mezzotint::Lines { length: 24, width: 2 } });
for junk in [json!({"type": 7}), json!({"type": "nonsense"})] {
assert_eq!(fx("pixelate.mezzotint", junk), PixelFx::Mezzotint { kind: Mezzotint::FINE_DOTS });
}
// Every Type is a pattern of its own, documented in the catalogue.
let doc = crate::effect_info("pixelate.mezzotint").unwrap().params;
let kinds: Vec<Mezzotint> = MEZZOTINT_TYPES.iter().map(|(_, v)| Mezzotint::parse(v)).collect();
for (i, (_, v)) in MEZZOTINT_TYPES.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
assert!(kinds.iter().skip(i + 1).all(|k| *k != kinds[i]), "{v}");
}
// Dots and crystals reach beyond the object; screens and grain stay on it.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
assert_eq!(fx("pixelate.crystallize", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("pixelate.colorHalftone", json!({})).outset(b), 0.0);
assert_eq!(fx("pixelate.mezzotint", json!({})).reach(), Some(0.0));
}
/// Fraction of `d`'s pixels for which `f` holds.
fn share(d: &[u8], f: impl Fn(&[u8; 4]) -> bool) -> f64 {
let px = d.as_chunks::<4>().0;
px.iter().filter(|p| f(p)).count() as f64 / px.len() as f64
}
#[test]
fn color_halftone_screens_midtones_into_dots_and_keeps_white_and_black() {
let (w, h) = (64, 64);
let halftone = fx("pixelate.colorHalftone", json!({}));
for v in [0, 255] {
let mut d = image(w, h, |_, _| grey(v));
halftone.apply(&mut d, w, h, &space(w, h));
assert_eq!(d, image(w, h, |_, _| grey(v)), "{v} stays");
}
// Mid grey: each channel's dots cover about half its screen, so the tone stays.
let mut d = image(w, h, |_, _| grey(128));
halftone.apply(&mut d, w, h, &space(w, h));
let px = d.as_chunks::<4>().0;
for c in 0..3 {
let mean = px.iter().map(|p| f64::from(p[c])).sum::<f64>() / px.len() as f64;
assert!((mean - 128.0).abs() < 16.0, "channel {c}: {mean}");
}
assert!(share(&d, |p| p[0] == 255) > 0.25 && share(&d, |p| p[0] == 0) > 0.25, "dots, not grey");
assert!(share(&d, |p| p[0] != p[1]) > 0.1, "the channels' screens lie at different angles");
assert!(px.iter().all(|p| p[3] == 255), "coverage stays");
// Transparency stays transparent.
let mut half = image(w, h, |x, _| if x < 32 { grey(160) } else { [0; 4] });
halftone.apply(&mut half, w, h, &space(w, h));
assert!((0..h).all(|y| (32..w).all(|x| at(&half, w, x, y) == [0; 4])));
// Dots are bigger where the image is brighter.
let mut ramp = image(w, h, |x, _| grey((x * 4) as u8));
halftone.apply(&mut ramp, w, h, &space(w, h));
let lit = |x0: usize| (0..h).flat_map(|y| (x0..x0 + 16).map(move |x| (x, y))).filter(|(x, y)| at(&ramp, w, *x, *y)[0] > 127).count();
assert!(lit(8) < lit(40), "{} < {}", lit(8), lit(40));
}
#[test]
fn color_halftone_screens_inks_in_cmyk_documents() {
let (w, h) = (64, 64);
let halftone = fx("pixelate.colorHalftone", json!({}));
let cmyk = PixelSpace { channels: Channels::Cmyk, ..space(w, h) };
// A neutral grey prints with all four inks: dots of each on white paper, black where they
// all overlap.
let mut d = image(w, h, |_, _| grey(128));
halftone.apply(&mut d, w, h, &cmyk);
assert!(share(&d, |p| *p == grey(255)) > 0.02 && share(&d, |p| *p == grey(0)) > 0.02);
for (ink, colour) in [("cyan", [0, 255, 255, 255]), ("magenta", [255, 0, 255, 255]), ("yellow", [255, 255, 0, 255])] {
assert!(share(&d, |p| *p == colour) > 0.0, "{ink} dots alone");
}
// White paper stays bare; pure cyan ink fills its cells.
let mut white = image(w, h, |_, _| grey(255));
halftone.apply(&mut white, w, h, &cmyk);
assert_eq!(white, image(w, h, |_, _| grey(255)));
let mut cyan = image(w, h, |_, _| [0, 255, 255, 255]);
halftone.apply(&mut cyan, w, h, &cmyk);
assert_eq!(cyan, image(w, h, |_, _| [0, 255, 255, 255]));
// Ink planes: the complemented K plane screens one grey channel at the black angle (45°,
// the same in every quarter turn).
let mut k = image(w, h, |_, _| grey(128));
halftone.apply(&mut k, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
assert!(k.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "one grey channel");
assert!(share(&k, |p| p[0] == 255) > 0.25 && share(&k, |p| p[0] == 0) > 0.25);
let mut turned = image(w, h, |_, _| grey(128));
fx("pixelate.colorHalftone", json!({"channel4": 135})).apply(&mut turned, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
let mut other = image(w, h, |_, _| grey(128));
fx("pixelate.colorHalftone", json!({"channel4": 30})).apply(&mut other, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
assert_eq!(turned, k, "channel 4 sets the K plane's angle");
assert_ne!(other, k);
}
#[test]
fn crystallize_redraws_in_solid_polygons() {
let (w, h) = (48, 48);
let crystals = fx("pixelate.crystallize", json!({"cellSize": 6}));
// One colour stays that colour (away from the raster's edges, where crystals around points
// beyond it take the transparency there).
let mut d = image(w, h, |_, _| [200, 100, 50, 255]);
crystals.apply(&mut d, w, h, &space(w, h));
assert!((9..39).all(|y| (9..39).all(|x| at(&d, w, x, y) == [200, 100, 50, 255])));
// Two halves: every pixel takes one of the colours, and the border between them zigzags.
let (red, blue) = ([200, 0, 0, 255], [0, 0, 200, 255]);
let src = image(w, h, |x, _| if x < 24 { red } else { blue });
let mut d = src.clone();
crystals.apply(&mut d, w, h, &space(w, h));
assert!((9..39).all(|y| (9..39).all(|x| [red, blue].contains(&at(&d, w, x, y)))));
let crossed = (9..39).flat_map(|y| (9..39).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) != at(&src, w, *x, *y)).count();
assert!(crossed > 10, "{crossed}");
let border: std::collections::BTreeSet<usize> = (9..39).map(|y| (9..39).find(|x| at(&d, w, *x, y) == blue).unwrap()).collect();
assert!(border.len() > 2, "{border:?}");
}
#[test]
fn mezzotint_turns_channels_fully_on_or_off() {
let (w, h) = (64, 64);
let grey_square = image(w, h, |x, y| if (8..56).contains(&x) && (8..56).contains(&y) { grey(128) } else { [0; 4] });
let mut seen = Vec::new();
for (_, kind) in MEZZOTINT_TYPES {
let f = fx("pixelate.mezzotint", json!({ "type": kind }));
for v in [0, 255] {
let mut d = image(w, h, |_, _| grey(v));
f.apply(&mut d, w, h, &space(w, h));
assert_eq!(d, image(w, h, |_, _| grey(v)), "{kind}: {v} stays");
}
let mut d = grey_square.clone();
f.apply(&mut d, w, h, &space(w, h));
let px = d.as_chunks::<4>().0;
assert!(px.iter().all(|p| p[..3].iter().all(|c| *c == 0 || *c == p[3])), "{kind}: pure colours");
let on = px.iter().filter(|p| p[3] > 0 && p[0] > 0).count() as f64 / (48.0 * 48.0);
assert!((0.25..0.75).contains(&on), "{kind}: {on}");
assert!(px.iter().any(|p| p[3] > 0 && p[0] != p[1]), "{kind}: each channel its own pattern");
assert!(d.iter().zip(&grey_square).skip(3).step_by(4).all(|(a, b)| a == b), "{kind}: coverage stays");
assert!(!seen.contains(&d), "{kind}: a pattern of its own");
seen.push(d);
}
}
#[test]
fn pointillize_paints_dots_on_a_white_canvas() {
let (w, h) = (80, 80);
let red = [200, 0, 0, 255];
let src = image(w, h, |x, y| if (20..60).contains(&x) && (20..60).contains(&y) { red } else { [0; 4] });
let mut d = src.clone();
fx("pixelate.pointillize", json!({})).apply(&mut d, w, h, &space(w, h));
let inside: Vec<[u8; 4]> = (26..54).flat_map(|y| (26..54).map(move |x| (x, y))).map(|(x, y)| at(&d, w, x, y)).collect();
let canvas = inside.iter().filter(|p| **p == grey(255)).count();
let dots = inside.iter().filter(|p| **p == red).count();
assert!(canvas > inside.len() / 20 && dots > inside.len() / 3, "{canvas} canvas, {dots} dots of {}", inside.len());
assert!(inside.iter().all(|p| p[3] == 255 && p[1] == p[2] && p[0] >= p[1]), "red over white only");
// Dots at the edges reach a little past the object; far from it stays transparent.
assert!((0..h).any(|y| (14..20).any(|x| at(&d, w, x, y)[3] > 0)));
assert!((0..h).all(|y| (0..10).all(|x| at(&d, w, x, y) == [0; 4])));
assert!(d.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
/// The patterns lie in document space around the object's centre: the same at twice the
/// resolution, and moving with the object.
#[test]
fn pixelate_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (40, 40);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..32.0).contains(&x) && (8.0..32.0).contains(&y) { [(x * 6.0) as u8, (y * 6.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("pixelate.colorHalftone", json!({"maxRadius": 4})),
("pixelate.crystallize", json!({"cellSize": 4})),
("pixelate.mezzotint", json!({"type": "coarseDots"})),
("pixelate.pointillize", json!({"cellSize": 4})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
// Twice the pixels for the same art, averaged back down.
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 24.0, "{id}: {diff}");
// The object and its raster 7 × 3 points further: the same pixels.
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(27.0, 23.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
/// Video › De-Interlace: lines 1, 3, 5… (or 2, 4, 6…) are made again from the others, by copying
/// the line above or by averaging the lines above and below; the lines are the document's raster
/// rows, whatever the raster's own resolution.
#[test]
fn deinterlace_remakes_one_field_from_the_other() {
let (w, h) = (2, 6);
// Line n (from 1) is grey 40 n.
let src = image(w, h, |_, y| grey(40 * (y as u8 + 1)));
let run = |p: serde_json::Value, s: &PixelSpace| {
let mut d = src.clone();
fx("video.deinterlace", p).apply(&mut d, w, h, s);
(0..h).map(|y| at(&d, w, 1, y)[0]).collect::<Vec<_>>()
};
let s = space(w, h);
// Odd lines (1, 3, 5) copy the line above; line 1 has none, so it takes line 2.
assert_eq!(run(json!({}), &s), [80, 80, 80, 160, 160, 240]);
assert_eq!(run(json!({"eliminate": "even"}), &s), [40, 40, 120, 120, 200, 200]);
// Interpolated: the average of the lines around (one side at the edges).
assert_eq!(run(json!({"eliminate": "even", "create": "interpolation"}), &s), [40, 80, 120, 160, 200, 200]);
// Lines two pixels tall: lines 1 and 3 (pixels 0–1 and 4–5) copy their neighbour, row by row.
let coarse = PixelSpace { line: 2.0, ..space(w, h) };
assert_eq!(run(json!({}), &coarse), [120, 160, 120, 160, 120, 160]);
// A damaged space changes nothing.
assert_eq!(run(json!({}), &PixelSpace { line: f64::NAN, ..space(w, h) }), [40, 80, 120, 160, 200, 240]);
}
/// Video › NTSC Colors: saturated yellow and cyan lose saturation (their composite signal is too
/// strong) but keep their brightness; greys, dark colours and transparency stay as they are.
#[test]
fn ntsc_colors_tame_only_colours_too_strong_for_the_signal() {
let (w, h) = (5, 1);
let px = [[255, 255, 0, 255], [0, 255, 255, 255], [128, 128, 128, 255], [100, 20, 20, 255], [0, 0, 0, 0]];
let mut d: Vec<u8> = px.concat();
fx("video.ntscColors", json!({})).apply(&mut d, w, h, &space(w, h));
let luma = |p: [u8; 4]| 0.299 * f64::from(p[0]) + 0.587 * f64::from(p[1]) + 0.114 * f64::from(p[2]);
for (x, &a) in px.iter().enumerate() {
let b = at(&d, w, x, 0);
if x >= 2 {
assert_eq!(b, a, "{x}: unchanged");
continue;
}
let spread = |p: [u8; 4]| p[..3].iter().max().unwrap() - p[..3].iter().min().unwrap();
assert!(spread(b) < spread(a), "{x}: less saturated {b:?}");
assert!((luma(a) - luma(b)).abs() < 3.0, "{x}: as bright {a:?} {b:?}");
}
}
#[test]
fn texture_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("texture.craquelure", json!({})), PixelFx::Craquelure { spacing: 15.0, depth: 6.0, brightness: 9.0 });
assert_eq!(
fx("texture.craquelure", json!({"crackSpacing": 1e9, "crackDepth": -1, "crackBrightness": "x"})),
PixelFx::Craquelure { spacing: 100.0, depth: 0.0, brightness: 9.0 }
);
assert_eq!(fx("texture.grain", json!({})), PixelFx::Grain { intensity: 40.0, contrast: 50.0, kind: Grain::Regular });
assert_eq!(
fx("texture.grain", json!({"intensity": 1e300, "contrast": -5, "grainType": "SPECKLE"})),
PixelFx::Grain { intensity: 100.0, contrast: 0.0, kind: Grain::Speckle }
);
assert_eq!(fx("texture.mosaicTiles", json!({})), PixelFx::MosaicTiles { tile: 12.0, grout: 3.0, lighten: 9.0 });
assert_eq!(
fx("texture.mosaicTiles", json!({"tileSize": 0, "groutWidth": 99, "lightenGrout": 11})),
PixelFx::MosaicTiles { tile: 2.0, grout: 15.0, lighten: 10.0 }
);
assert_eq!(fx("texture.patchwork", json!({})), PixelFx::Patchwork { square: 4.0, relief: 8.0 });
assert_eq!(fx("texture.patchwork", json!({"squareSize": -1, "relief": 26})), PixelFx::Patchwork { square: 0.0, relief: 25.0 });
assert_eq!(fx("texture.stainedGlass", json!({})), PixelFx::StainedGlass { cell: 10.0, border: 4.0, light: 3.0 });
assert_eq!(
fx("texture.stainedGlass", json!({"cellSize": 1, "borderThickness": 21, "lightIntensity": "1e999"})),
PixelFx::StainedGlass { cell: 2.0, border: 20.0, light: 3.0 }
);
let canvas = PixelFx::Texturizer { texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false };
assert_eq!(fx("texture.texturizer", json!({})), canvas);
assert_eq!(
fx("texture.texturizer", json!({"texture": "Brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true})),
PixelFx::Texturizer { texture: Texture::Brick, scaling: 0.5, relief: 50.0, light: Light::BottomRight, invert: true }
);
assert_eq!(
fx("texture.texturizer", json!({"texture": 3, "lightDirection": "up", "scaling": 1e9})),
PixelFx::Texturizer { texture: Texture::Canvas, scaling: 2.0, relief: 4.0, light: Light::Top, invert: false }
);
assert_eq!(fx("texture.grain", json!({"grainType": ["x"]})), fx("texture.grain", json!({})));
// Every menu value is documented in the catalogue and parses to a choice of its own.
for (id, key, table) in [
("texture.grain", "grainType", GRAIN_TYPES.as_slice()),
("texture.texturizer", "texture", TEXTURES.as_slice()),
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
] {
let doc = crate::effect_info(id).unwrap().params;
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
for (i, (_, v)) in table.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}");
assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}");
}
}
// Panes and squares reach beyond the object; the surfaces stay on it.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
assert_eq!(fx("texture.stainedGlass", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("texture.patchwork", json!({"squareSize": 0})).outset(b), 1.0);
for id in ["texture.craquelure", "texture.grain", "texture.mosaicTiles", "texture.texturizer"] {
assert_eq!(fx(id, json!({})).outset(b), 0.0, "{id}");
}
assert_eq!(fx("texture.grain", json!({})).reach(), Some(0.0));
assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(), Some(6.0));
}
/// The pixels of `d` (`w` wide) in the square `lo..hi` on both axes.
fn square(d: &[u8], w: usize, lo: usize, hi: usize) -> Vec<[u8; 4]> {
(lo..hi).flat_map(|y| (lo..hi).map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect()
}
/// A `w`-square raster of one colour.
fn flat(w: usize, c: [u8; 4]) -> Vec<u8> {
image(w, w, |_, _| c)
}
#[test]
fn craquelure_cracks_plates_and_contours() {
let w = 96;
let tan = [180, 150, 110, 255];
let mut d = flat(w, tan);
fx("texture.craquelure", json!({"crackBrightness": 10})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let dark = px.iter().filter(|p| p[0] < 120).count() as f64 / px.len() as f64;
assert!((0.03..0.4).contains(&dark), "a network of dark cracks: {dark}");
assert!(px.iter().all(|p| p[3] == 255 && p[0] >= p[1] && p[1] >= p[2]), "the colour stays, darkened or lit");
assert!(px.iter().any(|p| p[0] > 182), "plate edges catch the light");
// Without depth there are no cracks and no relief; brightness lights the surface.
let mut flat_lit = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 10})).apply(&mut flat_lit, w, w, &space(w, w));
assert_eq!(flat_lit, flat(w, tan));
let mut dim = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 0})).apply(&mut dim, w, w, &space(w, w));
assert_eq!(at(&dim, w, 40, 40), [90, 75, 55, 255]);
// A dark half and a light half: a crack follows the contour between them, straight down (a
// dark line between lighter pixels in nearly every row), where one tone has none.
let craquelure = fx("texture.craquelure", json!({"crackSpacing": 100, "crackBrightness": 10}));
let rows_cracked = |src: Vec<u8>| {
let mut d = src;
craquelure.apply(&mut d, w, w, &space(w, w));
let v = |x: usize, y: usize| i32::from(at(&d, w, x, y)[0]);
(20..76).filter(|y| (26..35).any(|x| v(x, *y) + 8 < v(x - 2, *y) && v(x, *y) + 8 < v(x + 2, *y))).count()
};
let (contour, plain) = (rows_cracked(image(w, w, |x, _| grey(if x < 30 { 60 } else { 200 }))), rows_cracked(flat(w, grey(200))));
assert!(contour >= 48 && contour >= plain + 25, "{contour} rows cracked along the contour, {plain} without one");
}
#[test]
fn grain_types_each_add_their_own_noise() {
let w = 48;
let mid = grey(128);
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, kind) in GRAIN_TYPES {
let mut d = flat(w, mid);
fx("texture.grain", json!({ "grainType": kind })).apply(&mut d, w, w, &space(w, w));
assert_ne!(d, flat(w, mid), "{kind} adds grain");
assert!(d.as_chunks::<4>().0.iter().all(|p| p[3] == 255), "{kind}: coverage stays");
assert!(!seen.contains(&d), "{kind}: a grain of its own");
seen.push(d);
}
// Regular grain is 1-point grains of every colour around the image's own.
let mut d = flat(w, mid);
fx("texture.grain", json!({})).apply(&mut d, w, w, &space(w, w));
let px = d.as_chunks::<4>().0;
let mean = px.iter().map(|p| f64::from(p[0])).sum::<f64>() / px.len() as f64;
assert!((mean - 128.0).abs() < 8.0, "{mean}");
assert!(px.iter().any(|p| p[0] != p[1]), "coloured grains");
// Horizontal grain runs in streaks along the rows.
let mut d = flat(w, mid);
fx("texture.grain", json!({"grainType": "horizontal", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
let along = (0..w - 1).map(|x| u32::from(at(&d, w, x, 10)[0].abs_diff(at(&d, w, x + 1, 10)[0]))).sum::<u32>();
let across = (0..w - 1).map(|y| u32::from(at(&d, w, 10, y)[0].abs_diff(at(&d, w, 10, y + 1)[0]))).sum::<u32>();
assert!(along * 3 < across, "{along} along, {across} across");
// No intensity at neutral contrast leaves the image; contrast spreads its tones.
let ramp = image(w, w, |x, _| grey((x * 5) as u8));
let mut d = ramp.clone();
fx("texture.grain", json!({"intensity": 0})).apply(&mut d, w, w, &space(w, w));
assert_eq!(d, ramp);
fx("texture.grain", json!({"intensity": 0, "contrast": 100})).apply(&mut d, w, w, &space(w, w));
assert!(at(&d, w, 2, 0)[0] < at(&ramp, w, 2, 0)[0] && at(&d, w, 45, 0)[0] > at(&ramp, w, 45, 0)[0]);
// Sprinkles are the background colour, white.
let mut d = flat(w, [0, 0, 160, 255]);
fx("texture.grain", json!({"grainType": "sprinkles", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
assert!(d.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[2] > 240));
}
#[test]
fn mosaic_tiles_lay_tiles_in_light_grout() {
let w = 96;
let blue = [40, 60, 160, 255];
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[0] > 170).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout), "light grout between the tiles: {grout}");
assert!(px.iter().filter(|p| **p == blue).count() > px.len() / 10, "tile faces keep the colour");
// Dark grout without lightening.
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({"lightenGrout": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[2] < 100).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout) && px.iter().all(|p| p[0] < 170), "dark grout: {grout}");
// Transparency around the object stays.
let mut d = image(w, w, |x, _| if x < 48 { blue } else { [0; 4] });
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| (48..w).all(|x| at(&d, w, x, y) == [0; 4])));
}
#[test]
fn patchwork_fills_squares_with_one_colour_in_relief() {
let w = 64;
let src = image(w, w, |x, y| [(x * 4) as u8, (y * 4) as u8, 90, 255]);
let mut d = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
// Squares of 8 points (the object's centre is a corner), each one colour.
for (i, j) in [(1, 1), (2, 5), (6, 3)] {
let first = at(&d, w, 8 * i, 8 * j);
assert!((0..8).all(|y| (0..8).all(|x| at(&d, w, 8 * i + x, 8 * j + y) == first)), "square {i} {j}");
}
assert_ne!(at(&d, w, 8, 8), at(&d, w, 16, 8));
// In relief, the squares' sides catch the light from the top left and fall into shade.
let mut lit = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 25})).apply(&mut lit, w, w, &space(w, w));
let (face, top, bottom) = (at(&lit, w, 28, 28)[1], at(&lit, w, 28, 24)[1], at(&lit, w, 28, 31)[1]);
assert!(top > face && bottom < face, "{top} {face} {bottom}");
// Squares around the object's edge are drawn whole.
let mut d = image(w, w, |x, _| if x < 30 { [200, 0, 0, 255] } else { [0; 4] });
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| at(&d, w, 31, y)[3] == 255 && at(&d, w, 33, y)[3] == 0));
}
#[test]
fn stained_glass_leads_panes_and_lights_the_centre() {
let w = 96;
let src = image(w, w, |x, y| [(40 + x) as u8, (40 + y) as u8, 120, 255]);
let mut d = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let lead = px.iter().filter(|p| p[0] == 0 && p[1] == 0 && p[2] == 0).count() as f64 / px.len() as f64;
assert!((0.1..0.6).contains(&lead), "black lead: {lead}");
let same = (16..80).flat_map(|y| (16..79).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) == at(&d, w, x + 1, *y)).count();
assert!(same * 5 > 2 * 64 * 63, "single-coloured panes: {same}");
assert!(px.iter().all(|p| p[3] == 255));
// The light shines through the middle, not the corners.
let mut lit = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 10})).apply(&mut lit, w, w, &space(w, w));
let brighter = |x: usize, y: usize| i32::from(at(&lit, w, x, y)[2]) - i32::from(at(&d, w, x, y)[2]);
let centre = (44..52).flat_map(|y| (44..52).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
let corner = (0..6).flat_map(|y| (0..6).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
assert!(centre > 40 && corner * 3 < centre, "{centre} at the centre, {corner} in the corner");
// Panes beyond the object stay clear; lead outlines it.
let mut d = image(w, w, |x, y| if (24..72).contains(&x) && (24..72).contains(&y) { [200, 40, 40, 255] } else { [0; 4] });
fx("texture.stainedGlass", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..6).all(|y| (0..w).all(|x| at(&d, w, x, y) == [0; 4])));
assert!(d.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
#[test]
fn texturizer_lights_each_surface_from_its_side() {
let w = 64;
let mid = grey(150);
let run = |p: serde_json::Value| {
let mut d = flat(w, mid);
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
d
};
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, texture) in TEXTURES {
let d = run(json!({ "texture": texture, "relief": 20 }));
assert_ne!(d, flat(w, mid), "{texture}");
assert!(!seen.contains(&d), "{texture}: a surface of its own");
// Inverted, the lit slopes are the shaded ones, and the other way round.
let inv = run(json!({ "texture": texture, "relief": 20, "invert": true }));
let shift = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| i32::from(p[0]) - 150).collect::<Vec<_>>();
let (a, b) = (shift(&d), shift(&inv));
let opposite = a.iter().zip(&b).filter(|(a, b)| a.abs() > 4 && b.abs() > 4 && a.signum() != b.signum()).count();
let lit_or_shaded = a.iter().filter(|a| a.abs() > 4).count();
assert!(lit_or_shaded > 100 && opposite * 10 > lit_or_shaded * 8, "{texture}: {opposite} of {lit_or_shaded}");
// From the other side as well.
assert_ne!(run(json!({ "texture": texture, "relief": 20, "lightDirection": "bottom" })), d, "{texture}");
// No relief, no change.
assert_eq!(run(json!({ "texture": texture, "relief": 0 })), flat(w, mid), "{texture}");
seen.push(d);
}
// Scaling enlarges the surface: 200 % at 2 points a pixel draws as 100 % at 1.
let mut big = flat(w, mid);
let coarse = PixelSpace { to_doc: Affine::scale(2.0), px: 2.0, center: Point::new(64.0, 64.0), ..space(w, w) };
fx("texture.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 20})).apply(&mut big, w, w, &coarse);
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
}
/// The Texture filters' patterns lie in document space around the object's centre: the same at
/// twice the resolution, and moving with the object.
#[test]
fn texture_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (48, 48);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("texture.craquelure", json!({"crackSpacing": 8})),
("texture.grain", json!({"grainType": "enlarged"})),
("texture.mosaicTiles", json!({"tileSize": 8, "groutWidth": 2})),
("texture.patchwork", json!({"squareSize": 6})),
("texture.stainedGlass", json!({"cellSize": 8, "borderThickness": 2})),
("texture.texturizer", json!({"texture": "sandstone", "relief": 10})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 12.0, "{id}: {diff}");
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
#[test]
fn distort_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("distort.diffuseGlow", json!({})), PixelFx::DiffuseGlow { graininess: 6.0, glow: 10.0, clear: 15.0 });
assert_eq!(
fx("distort.diffuseGlow", json!({"graininess": 1e9, "glowAmount": -3, "clearAmount": "x"})),
PixelFx::DiffuseGlow { graininess: 10.0, glow: 0.0, clear: 15.0 }
);
assert_eq!(
fx("distort.glass", json!({})),
PixelFx::Glass { distortion: 5.0, smoothness: 3.0, texture: GlassTexture::Frosted, scaling: 1.0, invert: false }
);
assert_eq!(
fx("distort.glass", json!({"distortion": 1e308, "smoothness": 0, "texture": "TINYLENS", "scaling": 1, "invert": true})),
PixelFx::Glass { distortion: 20.0, smoothness: 1.0, texture: GlassTexture::TinyLens, scaling: 0.5, invert: true }
);
assert_eq!(fx("distort.glass", json!({"texture": "bubbles"})), fx("distort.glass", json!({})));
assert_eq!(fx("distort.oceanRipple", json!({})), PixelFx::OceanRipple { size: 9.0, magnitude: 9.0 });
assert_eq!(fx("distort.oceanRipple", json!({"rippleSize": 99, "rippleMagnitude": -1})), PixelFx::OceanRipple { size: 15.0, magnitude: 0.0 });
// Shifted content reaches past the bounds by the longest shift, and comes from as far.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
let glass = fx("distort.glass", json!({"distortion": 20}));
assert_eq!((glass.outset(b), glass.reach()), (10.0, Some(10.0)));
let ripple = fx("distort.oceanRipple", json!({}));
assert!((ripple.outset(b) - 0.03 * 9.0 * 11.0).abs() < 1e-9 && ripple.reach() == Some(ripple.outset(b)));
assert_eq!(fx("distort.diffuseGlow", json!({})).outset(b), 0.0);
}
/// Vertical stripes 5 points wide (out of step with every glass surface), alternately light and
/// dark, over the whole raster.
fn stripes(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, _| if (x / 5) % 2 == 0 { [230, 200, 60, 255] } else { [20, 40, 120, 255] })
}
#[test]
fn glass_bends_the_image_by_its_texture() {
let (w, h) = (48, 40);
let src = stripes(w, h);
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
d
};
// No distortion, no change.
assert_eq!(run(json!({"distortion": 0})), src);
let mut seen = vec![];
for (_, texture) in GLASS_TEXTURES {
let bent = run(json!({"texture": texture, "distortion": 12}));
assert_ne!(bent, src, "{texture}");
assert!(!seen.contains(&bent), "{texture} differs from the other textures");
assert_ne!(run(json!({"texture": texture, "distortion": 12, "invert": true})), bent, "{texture} inverted");
// A colour moves; none is invented: every pixel blends two neighbouring stripes at most
// (those within a shift of the raster's edges also take in the transparency beyond it).
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
let p = at(&bent, w, x, y);
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "{texture} at ({x}, {y}): {p:?}");
}
seen.push(bent);
}
// Smoother glass bends the stripes differently (more gently).
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
}
/// The stored form of a `w` × `h` texture whose straight RGBA pixels `f` gives.
fn stored(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> String {
LoadedTexture::from_rgba(w as u32, h as u32, &image(w, h, f)).and_then(|t| t.to_stored()).unwrap()
}
#[test]
fn a_loaded_texture_is_its_gray_over_middle_gray() {
let t = stored(3, 1, |x, _| [[255, 255, 255, 255], [0, 0, 0, 0], [255, 0, 0, 255]][x]);
let loaded = texture(&t).unwrap();
assert_eq!(loaded.size(), (3, 1));
assert_eq!(texture(&t), Some(loaded.clone()), "read again from the cache");
// A white pixel is 1 high, a transparent one is middle gray and a red one is its luma. Heights
// between pixel centers are interpolated, and the image repeats: x = -1 is the first pixel's
// center, and x = 2 is the same pixel again.
let h = |x: f64| loaded.height(Point::new(x, 0.0));
assert_eq!(h(-1.0), 1.0);
assert_eq!(h(0.0), 128.0 / 255.0);
assert_eq!(h(1.0), 76.0 / 255.0);
assert_eq!(h(2.0), 1.0);
assert_eq!(h(-0.5), 0.5 * (1.0 + 128.0 / 255.0));
assert!(LoadedTexture::from_rgba(0, 4, &[]).is_none() && LoadedTexture::from_rgba(2, 2, &[0; 12]).is_none());
assert!(texture("not base64!").is_none() && texture("aGVsbG8=").is_none(), "base64 of a PNG only");
}
#[test]
fn glass_bends_the_image_by_a_loaded_texture() {
let (w, h) = (48, 40);
let src = stripes(w, h);
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
d
};
// Rings around the texture's center, 9 × 7 pixels, repeated.
let rings = stored(9, 7, |x, y| grey((((x as f64 - 4.0).hypot(y as f64 - 3.0) * 60.0) as u32 % 256) as u8));
let loaded = json!({"texture": "loaded", "textureImage": rings, "distortion": 12});
let bent = run(loaded.clone());
assert_ne!(bent, src);
for (_, texture) in GLASS_TEXTURES {
assert_ne!(bent, run(json!({"texture": texture, "distortion": 12})), "differs from {texture}");
}
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "invert": true})), bent, "inverted");
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "scaling": 200})), bent, "scaled");
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
let p = at(&bent, w, x, y);
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "at ({x}, {y}): {p:?}");
}
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 0})), src, "no distortion, no change");
// A flat texture bends nothing, whatever its gray; so does a transparent one (middle gray).
for flat in [grey(0), grey(200), [0, 0, 0, 0]] {
assert_eq!(run(json!({"texture": "loaded", "textureImage": stored(5, 5, |_, _| flat), "distortion": 20})), src, "{flat:?}");
}
// The image is kept but not used with a built-in texture; without one, "loaded" is Frosted.
assert_eq!(run(json!({"texture": "blocks", "textureImage": rings, "distortion": 12})), run(json!({"texture": "blocks", "distortion": 12})));
assert_eq!(fx("distort.glass", json!({"texture": "loaded"})), fx("distort.glass", json!({})));
assert_eq!(fx("distort.glass", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("distort.glass", json!({})));
assert_eq!(
fx("distort.glass", loaded),
PixelFx::Glass { distortion: 12.0, smoothness: 3.0, texture: GlassTexture::Loaded(texture(&rings).unwrap()), scaling: 1.0, invert: false }
);
}
#[test]
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
let (w, h) = (64, 32);
// The left half white, the right half transparent.
let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] });
let mut d = src.clone();
fx("distort.oceanRipple", json!({"rippleMagnitude": 0})).apply(&mut d, w, h, &space(w, h));
assert_eq!(d, src, "no magnitude, no ripples");
let f = fx("distort.oceanRipple", json!({"rippleSize": 6, "rippleMagnitude": 20}));
let most = f.reach().unwrap();
let mut d = src.clone();
f.apply(&mut d, w, h, &space(w, h));
assert_ne!(d, src);
// Away from the white's edge, and from the raster's (beyond which is transparency), nothing
// changes.
let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0;
for y in (0..h).filter(|y| inside(*y, h)) {
for x in (0..w).filter(|x| inside(*x, w)) {
let to_edge = (x as f64 + 0.5 - 32.0).abs();
if to_edge > most + 1.0 {
assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y}) is {to_edge} from the edge, past {most}");
}
}
}
// The edge wanders: some rows reach farther right than others.
let ends: Vec<usize> = (0..h).map(|y| (0..w).filter(|x| at(&d, w, *x, y)[3] > 127).count()).collect();
assert!(ends.iter().min() != ends.iter().max(), "{ends:?}");
}
#[test]
fn diffuse_glow_whitens_the_highlights_and_sprinkles_grain() {
let (w, h) = (48, 48);
// Dark on the left, light on the right, a transparent margin round them.
let src = image(w, h, |x, y| {
if !(4..44).contains(&x) || !(4..44).contains(&y) {
[0; 4]
} else if x < 24 {
[30, 30, 60, 255]
} else {
[200, 190, 170, 255]
}
});
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.diffuseGlow", p).apply(&mut d, w, h, &space(w, h));
d
};
let clean = run(json!({"graininess": 0}));
// Transparency stays; the shadows stay clear; the highlights glow.
for (a, b) in clean.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(a[3], b[3]);
}
assert_eq!(at(&clean, w, 10, 24), at(&src, w, 10, 24));
let (lit, before) = (at(&clean, w, 36, 24), at(&src, w, 36, 24));
assert!((0..3).all(|c| lit[c] > before[c]), "{lit:?} brighter than {before:?}");
// More Clear Amount leaves more of the image clear of glow.
assert!(at(&run(json!({"graininess": 0, "clearAmount": 20})), w, 36, 24)[0] < lit[0]);
assert_eq!(run(json!({"graininess": 0, "glowAmount": 0})), src);
// Grain: white specks, in the shadows too.
let grainy = run(json!({"graininess": 10}));
let specks = (4..24).flat_map(|x| (4..44).map(move |y| (x, y))).filter(|(x, y)| at(&grainy, w, *x, *y)[0] > 100).count();
assert!(specks > 10, "{specks} white specks in the shadows");
}
#[test]
fn distort_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (48, 48);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("distort.diffuseGlow", json!({"clearAmount": 5})),
("distort.glass", json!({"texture": "blocks", "distortion": 8})),
("distort.oceanRipple", json!({"rippleMagnitude": 12})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 12.0, "{id}: {diff}");
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
/// The Brush Strokes effect ids.
fn brush_strokes() -> impl Iterator<Item = &'static str> {
PIXEL_EFFECTS.into_iter().filter(|id| id.starts_with("brushStrokes."))
}
#[test]
fn brush_strokes_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("brushStrokes.accentedEdges", json!({})), PixelFx::AccentedEdges { width: 2.0, brightness: 38.0, smoothness: 5.0 });
assert_eq!(
fx("brushStrokes.accentedEdges", json!({"edgeWidth": 99, "edgeBrightness": -1, "smoothness": "x"})),
PixelFx::AccentedEdges { width: 14.0, brightness: 0.0, smoothness: 5.0 }
);
assert_eq!(fx("brushStrokes.angledStrokes", json!({})), PixelFx::AngledStrokes { balance: 50.0, length: 15.0, sharpness: 3.0 });
assert_eq!(
fx("brushStrokes.angledStrokes", json!({"directionBalance": 1e308, "strokeLength": 0, "sharpness": 11})),
PixelFx::AngledStrokes { balance: 100.0, length: 3.0, sharpness: 10.0 }
);
assert_eq!(fx("brushStrokes.crosshatch", json!({})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 });
assert_eq!(
fx("brushStrokes.crosshatch", json!({"strokeLength": 51, "sharpness": -1, "strength": 2.6})),
PixelFx::Crosshatch { length: 50.0, sharpness: 0.0, strength: 3 }
);
assert_eq!(fx("brushStrokes.crosshatch", json!({"strength": -7})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 });
assert_eq!(fx("brushStrokes.darkStrokes", json!({})), PixelFx::DarkStrokes { balance: 5.0, black: 6.0, white: 2.0 });
assert_eq!(
fx("brushStrokes.darkStrokes", json!({"balance": 11, "blackIntensity": -1, "whiteIntensity": 1e9})),
PixelFx::DarkStrokes { balance: 10.0, black: 0.0, white: 10.0 }
);
assert_eq!(fx("brushStrokes.inkOutlines", json!({})), PixelFx::InkOutlines { length: 4.0, dark: 20.0, light: 10.0 });
assert_eq!(
fx("brushStrokes.inkOutlines", json!({"strokeLength": 0, "darkIntensity": 51, "lightIntensity": -5})),
PixelFx::InkOutlines { length: 1.0, dark: 50.0, light: 0.0 }
);
assert_eq!(fx("brushStrokes.spatter", json!({})), PixelFx::Spatter { radius: 10.0, smoothness: 5.0 });
assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 26, "smoothness": 0})), PixelFx::Spatter { radius: 25.0, smoothness: 1.0 });
assert_eq!(
fx("brushStrokes.sprayedStrokes", json!({})),
PixelFx::SprayedStrokes { length: 12.0, radius: 7.0, direction: StrokeDirection::RightDiagonal }
);
assert_eq!(
fx("brushStrokes.sprayedStrokes", json!({"strokeLength": -1, "sprayRadius": 1e308, "strokeDirection": "VERTICAL"})),
PixelFx::SprayedStrokes { length: 0.0, radius: 25.0, direction: StrokeDirection::Vertical }
);
assert_eq!(fx("brushStrokes.sprayedStrokes", json!({"strokeDirection": "diagonal"})), fx("brushStrokes.sprayedStrokes", json!({})));
assert_eq!(fx("brushStrokes.sumiE", json!({})), PixelFx::SumiE { width: 10.0, pressure: 2.0, contrast: 16.0 });
assert_eq!(
fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})),
PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 }
);
// Outsets: the strokes and the shifts reach out of the object as far as into it; Accented
// Edges and Sumi-e keep the object's shape.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
let reach = brushstrokes::stroke_reach;
assert!((reach(20.0) - (0.75 * 20.0 + 1.5 / 15.0 * 20.0 + 1.0)).abs() < 1e-9);
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).outset(b), 0.0);
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0));
assert_eq!(fx("brushStrokes.angledStrokes", json!({})).outset(b), reach(15.0));
assert_eq!(fx("brushStrokes.crosshatch", json!({})).outset(b), reach(9.0) + 0.9);
assert_eq!(fx("brushStrokes.darkStrokes", json!({})).outset(b), reach(9.0));
assert_eq!(fx("brushStrokes.inkOutlines", json!({"strokeLength": 50})).outset(b), reach(50.0));
let spatter = fx("brushStrokes.spatter", json!({}));
assert_eq!((spatter.outset(b), spatter.reach()), (4.0, Some(4.0)));
assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 0})).outset(b), 0.0);
assert_eq!(fx("brushStrokes.sprayedStrokes", json!({})).outset(b), reach(12.0) + 0.3 * 7.0);
assert_eq!(fx("brushStrokes.sumiE", json!({})).outset(b), 0.0);
for id in brush_strokes() {
let f = fx(id, json!({}));
assert!(f.reach().is_some_and(|r| r >= f.outset(b)), "{id}");
}
}
/// A light square in a darker ground with a ramp across it, over the whole raster: edges,
/// shadows and highlights for the strokes to work on.
fn scene(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, y| if (12..36).contains(&x) && (12..36).contains(&y) { [235, 220, 190, 255] } else { [(30 + x) as u8, 40, 90, 255] })
}
/// `src` (`w` × `h`) through effect `id` with parameters `p`.
fn run_fx(src: &[u8], w: usize, h: usize, id: &str, p: serde_json::Value) -> Vec<u8> {
let mut d = src.to_vec();
fx(id, p).apply(&mut d, w, h, &space(w, h));
d
}
#[test]
fn brush_strokes_each_repaint_the_art_their_own_way() {
let (w, h) = (48, 48);
let src = scene(w, h);
let mut seen: Vec<(&str, Vec<u8>)> = vec![];
for id in brush_strokes() {
let d = run_fx(&src, w, h, id, json!({}));
assert_ne!(d, src, "{id} changes the art");
for (other, o) in &seen {
assert_ne!(&d, o, "{id} differs from {other}");
}
seen.push((id, d));
}
assert_eq!(seen.len(), 8);
// Each stroke direction paints its own way.
let mut ways: Vec<Vec<u8>> = vec![];
for (_, way) in STROKE_DIRECTIONS {
let d = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"strokeDirection": way}));
assert!(!ways.contains(&d), "{way} differs from the other directions");
ways.push(d);
}
// Every option shows.
for (id, a, b) in [
("brushStrokes.accentedEdges", json!({"edgeWidth": 1}), json!({"edgeWidth": 14})),
("brushStrokes.accentedEdges", json!({"smoothness": 1}), json!({"smoothness": 15})),
("brushStrokes.angledStrokes", json!({"directionBalance": 0}), json!({"directionBalance": 100})),
("brushStrokes.angledStrokes", json!({"strokeLength": 3}), json!({"strokeLength": 50})),
("brushStrokes.angledStrokes", json!({"sharpness": 0}), json!({"sharpness": 10})),
("brushStrokes.crosshatch", json!({"strokeLength": 3}), json!({"strokeLength": 50})),
("brushStrokes.crosshatch", json!({"sharpness": 0}), json!({"sharpness": 20})),
("brushStrokes.crosshatch", json!({"strength": 1}), json!({"strength": 3})),
("brushStrokes.darkStrokes", json!({"balance": 0}), json!({"balance": 10})),
("brushStrokes.darkStrokes", json!({"blackIntensity": 0}), json!({"blackIntensity": 10})),
("brushStrokes.darkStrokes", json!({"whiteIntensity": 0}), json!({"whiteIntensity": 10})),
("brushStrokes.inkOutlines", json!({"strokeLength": 1}), json!({"strokeLength": 50})),
("brushStrokes.inkOutlines", json!({"darkIntensity": 0}), json!({"darkIntensity": 50})),
("brushStrokes.inkOutlines", json!({"lightIntensity": 0}), json!({"lightIntensity": 50})),
("brushStrokes.spatter", json!({"sprayRadius": 5}), json!({"sprayRadius": 25})),
("brushStrokes.spatter", json!({"smoothness": 1}), json!({"smoothness": 15})),
("brushStrokes.sprayedStrokes", json!({"strokeLength": 0}), json!({"strokeLength": 20})),
("brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}), json!({"sprayRadius": 25})),
("brushStrokes.sumiE", json!({"strokeWidth": 3}), json!({"strokeWidth": 15})),
("brushStrokes.sumiE", json!({"strokePressure": 0}), json!({"strokePressure": 15})),
("brushStrokes.sumiE", json!({"contrast": 0}), json!({"contrast": 40})),
] {
assert_ne!(run_fx(&src, w, h, id, a.clone()), run_fx(&src, w, h, id, b.clone()), "{id}: {a} and {b}");
}
}
/// The mean of the colour channels over columns `xs` and rows `ys` (0..255).
fn mean_tone(d: &[u8], w: usize, xs: std::ops::Range<usize>, ys: std::ops::Range<usize>) -> f64 {
let px: Vec<[u8; 4]> = ys.flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect();
px.iter().map(|p| f64::from(p[0]) + f64::from(p[1]) + f64::from(p[2])).sum::<f64>() / (3.0 * px.len().max(1) as f64)
}
/// Dark on the left, light on the right, opaque over the whole raster.
fn halves(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, _| if x < w / 2 { [50, 60, 100, 255] } else { [210, 200, 180, 255] })
}
#[test]
fn accented_edges_draw_chalk_or_ink_on_the_edges_only() {
let (w, h) = (48, 48);
// The halves inside a transparent margin.
let both = halves(w, h);
let src = image(w, h, |x, y| if (4..44).contains(&x) && (4..44).contains(&y) { at(&both, w, x, y) } else { [0; 4] });
let chalk = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 50}));
let ink = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 0}));
let sum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]);
// Along the edge between the halves: white chalk on the dark side, black ink on the light.
assert!(sum(at(&chalk, w, 23, 24)) > sum(at(&src, w, 23, 24)) + 150, "{:?}", at(&chalk, w, 23, 24));
assert!(sum(at(&ink, w, 24, 24)) + 150 < sum(at(&src, w, 24, 24)), "{:?}", at(&ink, w, 24, 24));
// Away from the edges nothing changes, and the object keeps its shape.
for (x, y) in [(13, 24), (34, 24), (13, 13)] {
assert_eq!(at(&chalk, w, x, y), at(&src, w, x, y), "({x}, {y})");
}
for (a, b) in chalk.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(a[3], b[3]);
}
}
#[test]
fn dark_and_ink_strokes_darken_the_shadows_and_sumi_e_inks_them_black() {
let (w, h) = (64, 48);
let src = halves(w, h);
let (shadow, light) = (8..24, 40..56);
let tone = |d: &[u8], xs: std::ops::Range<usize>| mean_tone(d, w, xs, 8..40);
let dark = run_fx(&src, w, h, "brushStrokes.darkStrokes", json!({}));
assert!(tone(&dark, shadow.clone()) < tone(&src, shadow.clone()) - 20.0, "dark strokes in the shadows");
assert!(tone(&dark, light.clone()) > tone(&src, light.clone()) + 5.0, "white strokes in the light");
let ink = run_fx(&src, w, h, "brushStrokes.inkOutlines", json!({}));
assert!(tone(&ink, shadow.clone()) < tone(&src, shadow.clone()) - 10.0, "ink in the shadows");
// The outline along the edge is darker than the light side around it.
assert!(tone(&ink, 32..34) + 40.0 < tone(&ink, light.clone()), "an ink outline along the edge");
let sumi = run_fx(&src, w, h, "brushStrokes.sumiE", json!({}));
assert!(tone(&sumi, shadow.clone()) < 0.4 * tone(&src, shadow.clone()), "rich blacks: {}", tone(&sumi, shadow.clone()));
assert!(tone(&sumi, 50..56) > 150.0, "the light stays light: {}", tone(&sumi, 50..56));
}
#[test]
fn sumi_e_keeps_the_shape_and_the_hues() {
let (w, h) = (48, 48);
// A strong yellow square and a dark blue one in a transparent margin.
let src = image(w, h, |x, y| match (x, y) {
(8..24, 8..40) => [230, 200, 40, 255],
(24..40, 8..40) => [30, 40, 120, 255],
_ => [0; 4],
});
let d = run_fx(&src, w, h, "brushStrokes.sumiE", json!({"contrast": 40, "strokePressure": 15}));
// Transparency stays transparent and the coverage stays as it was: no fringe.
for (a, b) in d.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(a[3], b[3]);
if b[3] == 0 {
assert_eq!(*a, [0; 4]);
}
}
// The yellow stays yellow, no more saturated than it was.
let sat = |p: [u8; 4]| {
let (hi, lo) = (p[..3].iter().max().copied().unwrap(), p[..3].iter().min().copied().unwrap());
f64::from(hi - lo) / f64::from(hi.max(1))
};
let y = at(&d, w, 12, 24);
assert!(y[0] > y[2] + 80 && y[1] > y[2] + 80, "{y:?}");
assert!(sat(y) <= sat([230, 200, 40, 255]) + 0.02, "{y:?}");
// The dark blue is inked darker, still blue.
let b = at(&d, w, 34, 24);
assert!(b[2] < 120 && b[2] >= b[0] && b[2] >= b[1], "{b:?}");
}
#[test]
fn crosshatch_hatches_flat_colour_and_keeps_its_tone() {
let (w, h) = (48, 48);
let src = image(w, h, |_, _| grey(128));
let spread = |d: &[u8]| {
let m = mean_tone(d, w, 8..40, 8..40);
let var = (8..40).flat_map(|x| (8..40).map(move |y| (x, y))).map(|(x, y)| (f64::from(at(d, w, x, y)[0]) - m).powi(2)).sum::<f64>() / 1024.0;
(m, var.sqrt())
};
let (one, three) =
(run_fx(&src, w, h, "brushStrokes.crosshatch", json!({})), run_fx(&src, w, h, "brushStrokes.crosshatch", json!({"strength": 3})));
let ((m1, s1), (_, s3)) = (spread(&one), spread(&three));
assert!((m1 - 128.0).abs() < 25.0 && s1 > 3.0, "{m1} ± {s1}");
assert!(s3 > s1, "more passes, more hatching: {s3} vs {s1}");
}
#[test]
fn stroke_directions_smear_along_themselves() {
let (w, h) = (48, 48);
// A white line two pixels thick across black.
let src = image(w, h, |_, y| if (23..25).contains(&y) { grey(255) } else { grey(0) });
let row = |d: &[u8], y: usize| mean_tone(d, w, 12..36, y..y + 1);
let along = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "horizontal"}));
let across = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "vertical"}));
assert!(row(&along, 20) < 1.0 && row(&along, 24) > 250.0, "strokes along the line keep it");
assert!(row(&across, 20) > 15.0 && row(&across, 24) < 200.0, "strokes across it spread it: {} {}", row(&across, 20), row(&across, 24));
// Diagonal strokes spread it too, along the diagonal.
let diagonal = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}));
assert!(row(&diagonal, 20) > 5.0);
}
#[test]
fn spatter_scatters_content_no_farther_than_its_radius() {
let (w, h) = (64, 32);
// The left half white, the right half transparent.
let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] });
assert_eq!(run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 0})), src, "no radius, no spatter");
let f = fx("brushStrokes.spatter", json!({"sprayRadius": 15}));
let most = f.reach().unwrap();
let d = run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 15}));
assert_ne!(d, src);
let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0;
for y in (0..h).filter(|y| inside(*y, h)) {
for x in (0..w).filter(|x| inside(*x, w) && (*x as f64 + 0.5 - 32.0).abs() > most + 1.0) {
assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y})");
}
}
// The edge is spattered: white specks past it and holes before it.
let past = (33..36).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] > 127).count();
let holes = (28..31).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] < 128).count();
assert!(past > 0 && holes > 0, "{past} specks past the edge, {holes} holes before it");
}
#[test]
fn brush_strokes_look_no_farther_than_their_reach() {
let (w, h) = (200, 24);
let base = image(w, h, |x, y| [(x * 7 % 256) as u8, (y * 11 % 256) as u8, 120, 255]);
let cases = brush_strokes().map(|id| (id, json!({}))).chain([
("brushStrokes.accentedEdges", json!({"edgeWidth": 14, "smoothness": 15})),
("brushStrokes.angledStrokes", json!({"strokeLength": 50})),
("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})),
("brushStrokes.inkOutlines", json!({"strokeLength": 50})),
("brushStrokes.spatter", json!({"sprayRadius": 25, "smoothness": 15})),
("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})),
("brushStrokes.sumiE", json!({"strokeWidth": 15})),
]);
for (id, p) in cases {
let reach = fx(id, p.clone()).reach().unwrap();
// Everything from `cut` on differs; the pixels left of 20 can't tell.
let cut = 20 + reach.ceil() as usize + 1;
assert!(cut < w, "{id}");
let other = image(w, h, |x, y| if x >= cut { [255 - (x * 5 % 256) as u8, 30, (y * 9 % 256) as u8, 200] } else { at(&base, w, x, y) });
let (a, b) = (run_fx(&base, w, h, id, p.clone()), run_fx(&other, w, h, id, p.clone()));
for (x, y) in (0..20).flat_map(|x| (0..h).map(move |y| (x, y))) {
let (pa, pb) = (at(&a, w, x, y), at(&b, w, x, y));
assert!(pa.iter().zip(pb).all(|(u, v)| u.abs_diff(v) <= 2), "{id} {p}: ({x}, {y}) {pa:?} vs {pb:?}, reach {reach}");
}
}
}
#[test]
fn brush_strokes_follow_the_object_at_any_resolution() {
let (w, h) = (48, 48);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
};
for id in brush_strokes() {
let f = fx(id, json!({}));
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 12.0, "{id}: {diff}");
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}