G2: Effect › Sketch › Reticulation raster filter

Squashed from #2976 onto Chalk & Charcoal and Graphic Pen.
This commit is contained in:
nrq6993-a11y
2026-10-20 02:54:06 +07:00
committed by bflatastic
parent 11adb72d44
commit 917aa29dd5
7 changed files with 108 additions and 9 deletions
+7
View File
@@ -489,6 +489,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
),
r(
"sketch.reticulation",
"Reticulation…",
SKETCH,
"{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}),
),
r(
"stylize.glowingEdges",
"Glowing Edges…",
+15 -4
View File
@@ -2,7 +2,8 @@
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Graphic Pen, Ink Outlines, Spatter,
//! Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic/n//! Pen and Halftone Pattern, Stylize ›
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic
//! Pen, Halftone Pattern and Reticulation, Stylize ›
//! Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
@@ -40,7 +41,7 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 34] = [
pub const PIXEL_EFFECTS: [&str; 35] = [
"artistic.cutout",
"artistic.filmGrain",
"artistic.neonGlow",
@@ -66,6 +67,7 @@ pub const PIXEL_EFFECTS: [&str; 34] = [
"sketch.chalkCharcoal",
"sketch.graphicPen",
"sketch.halftonePattern",
"sketch.reticulation",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
@@ -151,6 +153,9 @@ pub enum PixelFx {
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
/// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen;
/// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain.
Reticulation { density: f64, foreground: f64, background: f64 },
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
@@ -335,6 +340,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
},
"sketch.reticulation" => PixelFx::Reticulation {
density: num(p, "density", 12.0).clamp(0.0, 50.0),
foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0),
background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0),
},
"stylize.glowingEdges" => PixelFx::GlowingEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
@@ -413,7 +423,7 @@ impl PixelFx {
// Content shifted outwards reaches past the bounds by as much as the shift.
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } | PixelFx::ChalkCharcoal { .. } | PixelFx::HalftonePattern { .. } => 0.0,
PixelFx::UnsharpMask { .. } | PixelFx::ChalkCharcoal { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::FilmGrain { .. }
| PixelFx::Cutout { .. }
@@ -463,7 +473,7 @@ impl PixelFx {
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => Some(0.0),
// Cutout's palette comes from the whole object.
PixelFx::Cutout { .. } => None,
// The softened tones around the pixel.
@@ -520,6 +530,7 @@ impl PixelFx {
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background),
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
+36 -2
View File
@@ -1,4 +1,5 @@
//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern; Graphic Pen lives in [`super::brushstrokes`]).\n//! Original filters built from public behaviour documentation:
//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern and Reticulation; Graphic Pen lives in
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
//! black and white) and the public Photoshop Elements Artistic filters guide. Visual
//! approximations, not proprietary reproductions.
@@ -6,7 +7,7 @@
use std::f64::consts::TAU;
use super::edges::sobel;
use super::pixelate::{Place, value_noise};
use super::pixelate::{Place, cell, rand, value_noise};
use super::texture::{Tones, luma, premultiply, straight};
use super::{PixelSpace, blur_plane, pick};
@@ -136,3 +137,36 @@ pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pix
*p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0);
});
}
/// Reticulation: the object redrawn in black ink on white paper through a high-contrast film-grain
/// screen. `density` (0..50) sets how fine the grain is; `foreground` and `background` (0..50 each)
/// pull shadows toward black and highlights toward white before the grain decides ink. Coverage is
/// kept.
pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, density: f64, foreground: f64, background: f64) {
if !(density.is_finite() && foreground.is_finite() && background.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
// Finer grain at higher density: cells from about 11 pt down to 1 pt.
let cell_size = 1.0 + (50.0 - density.clamp(0.0, 50.0)) * 0.2;
let fg = foreground.clamp(0.0, 50.0) / 50.0;
let bg = background.clamp(0.0, 50.0) / 50.0;
let spread = 0.12 + 0.38 * (density.clamp(0.0, 50.0) / 50.0);
const SALT: u64 = 0x5245_7469;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
let tone = (dark + fg * (1.0 - dark) * 0.65 - bg * dark * 0.65).clamp(0.0, 1.0);
let coarse = value_noise(SALT, q.x / cell_size, q.y / cell_size);
let fine = match (cell(q.x), cell(q.y)) {
(Some(i), Some(j)) => rand(SALT + 1, i, j, 2),
_ => 0.5,
};
let grain = 0.55 * coarse + 0.45 * fine;
let ink = if tone + (grain - 0.5) * spread > 0.5 { 1.0 } else { 0.0 };
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
});
}
+44
View File
@@ -2193,6 +2193,50 @@ fn halftone_draws_in_black_and_white_only() {
}
}
/// Reticulation of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn reticulation(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.reticulation", p).apply(&mut out, w, h, s);
out
}
#[test]
fn reticulation_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.reticulation", json!({})), PixelFx::Reticulation { density: 12.0, foreground: 6.0, background: 6.0 });
assert_eq!(
fx("sketch.reticulation", json!({"density": -5, "foregroundLevel": 99, "backgroundLevel": -1})),
PixelFx::Reticulation { density: 0.0, foreground: 50.0, background: 0.0 }
);
let f = fx("sketch.reticulation", json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
}
#[test]
fn reticulation_draws_in_black_and_white_only() {
let (w, h) = (48, 48);
let src = image(w, h, |x, y| {
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
let a: u16 = if y < 24 { 255 } else { 128 };
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
});
let out = reticulation(json!({}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{p:?} from {q:?}");
}
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
}
#[test]
fn reticulation_density_changes_the_grain() {
let (w, h) = (64, 64);
let src = image(w, h, |_, _| grey(128));
let low = reticulation(json!({"density": 5}), &src, w, h, &space(w, h));
let high = reticulation(json!({"density": 45}), &src, w, h, &space(w, h));
assert_ne!(low, high);
}
/// The distinct colours of the covered pixels.
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
+1 -1
View File
@@ -44,7 +44,7 @@ const PIXELATE: [(&str, &str); 4] =
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
/// Effect › Sketch, at their defaults.
const SKETCH: [(&str, &str); 3] = [("sketch.chalkCharcoal", "{}"), ("sketch.graphicPen", "{}"), ("sketch.halftonePattern", "{}")];
const SKETCH: [(&str, &str); 4] = [("sketch.chalkCharcoal", "{}"), ("sketch.graphicPen", "{}"), ("sketch.halftonePattern", "{}"), ("sketch.reticulation", "{}")];
/// Effect › Texture, at their defaults.
const TEXTURE: [(&str, &str); 6] = [
+1
View File
@@ -336,6 +336,7 @@ fn structured_junk() {
("effect.apply", json!({"effect": "artistic.posterEdges", "params": {"edgeThickness": 1e308, "edgeIntensity": [], "posterization": -7}})),
("effect.apply", json!({"effect": "sketch.graphicPen", "params": {"strokeLength": -1e308, "lightDarkBalance": "NaN", "strokeDirection": 3}})),
("effect.apply", json!({"effect": "sketch.halftonePattern", "params": {"size": -1e308, "contrast": "NaN", "patternType": 3}})),
("effect.apply", json!({"effect": "sketch.reticulation", "params": {"density": "NaN", "foregroundLevel": -1e308, "backgroundLevel": 1e308}})),
("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})),
("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})),
("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})),
+4 -2
View File
@@ -178,14 +178,16 @@ fn effect_menu_lists_the_sketch_filters_between_sharpen_and_stylize() {
let entries = menus::menu_entries(&app);
let sketch: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Sketch"]).collect();
let labels: Vec<&str> = sketch.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Chalk & Charcoal…", "Graphic Pen…", "Halftone Pattern…"]);
assert_eq!(labels, ["Chalk & Charcoal…", "Graphic Pen…", "Halftone Pattern…", "Reticulation…"]);
assert_eq!(sketch[0].command.as_deref(), Some("effect.dialog"));
assert_eq!(sketch[0].params, json!({"effect": "sketch.chalkCharcoal"}));
assert_eq!(sketch[1].params, json!({"effect": "sketch.graphicPen"}));
assert_eq!(sketch[2].params, json!({"effect": "sketch.halftonePattern"}));
assert_eq!(sketch[3].params, json!({"effect": "sketch.reticulation"}));
let at = |label: &str| entries.iter().position(|e| e.path[0] == "Effect" && e.label == label).unwrap();
assert!(at("Unsharp Mask…") < at("Chalk & Charcoal…") && at("Chalk & Charcoal…") < at("Graphic Pen…"));
assert!(at("Graphic Pen…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Glowing Edges…"));
assert!(at("Graphic Pen…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Reticulation…"));
assert!(at("Reticulation…") < at("Glowing Edges…"));
}
/// Effect › Stylize under Raster Effects, between Sharpen and Texture: Glowing Edges opens its