mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
Effect › Blur › Radial Blur (spin/zoom, draft/good/best) and Smart Blur (Normal mode), Effect › Sharpen › Unsharp Mask: Photoshop-style filters in vectorcraft_effects::pixel, run by the renderer on the object's pixels in stack order with Gaussian Blur and Feather, cached while panning; lengths in points, bounded work per pixel. PDF, Expand Appearance and SVG export write the effected objects as images. Dialogs with preview (method/quality dropdowns, fields in documented order), menu entries, translations, tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
10 KiB
Rust
232 lines
10 KiB
Rust
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).
|
||
fn space(w: usize, h: usize) -> PixelSpace {
|
||
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0) }
|
||
}
|
||
|
||
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,
|
||
other => panic!("{id}: {other:?}"),
|
||
}
|
||
}
|
||
|
||
fn grey(v: u8) -> [u8; 4] {
|
||
[v, v, v, 255]
|
||
}
|
||
|
||
#[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 });
|
||
assert_eq!(
|
||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
|
||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
|
||
);
|
||
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 }
|
||
);
|
||
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.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[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 };
|
||
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.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||
gaussian_rgba(&mut d, w, h, 2.0);
|
||
let after: u32 = d.chunks_exact(4).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) };
|
||
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})),
|
||
];
|
||
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());
|
||
}
|
||
}
|