mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
G2: Effect › Sketch › Reticulation raster filter
Squashed from #2976 onto Chalk & Charcoal and Graphic Pen.
This commit is contained in:
@@ -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…",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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}})),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user