G2: Effect › Sketch › Plaster raster filter

Squashed from #2990 onto Chrome.
This commit is contained in:
MarekSpodBiedry
2026-10-20 04:52:26 +07:00
committed by bflatastic
parent d21f3e0667
commit b560b97616
9 changed files with 137 additions and 9 deletions
+7
View File
@@ -503,6 +503,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.plaster",
"Plaster…",
SKETCH,
"{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}),
),
r(
"sketch.reticulation",
"Reticulation…",
+15 -2
View File
@@ -2,7 +2,7 @@
//! Poster Edges, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
//! Crosshatch, Dark Strokes, 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 Pen, Halftone Pattern and
//! Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic Pen, Halftone Pattern, Plaster and
//! Reticulation, Stylize ›
//! Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
@@ -41,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; 37] = [
pub const PIXEL_EFFECTS: [&str; 38] = [
"artistic.cutout",
"artistic.filmGrain",
"artistic.neonGlow",
@@ -69,6 +69,7 @@ pub const PIXEL_EFFECTS: [&str; 37] = [
"sketch.chrome",
"sketch.graphicPen",
"sketch.halftonePattern",
"sketch.plaster",
"sketch.reticulation",
"stylize.glowingEdges",
"texture.craquelure",
@@ -160,6 +161,10 @@ 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 › Plaster: relief plaster lit from `light`; dark areas raised, light recessed;
/// `imageBalance` (1..50) shifts more of the image lower at higher values; `smoothness` (1..15)
/// softens the relief.
Plaster { image_balance: f64, smoothness: f64, light: Light },
/// 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 },
@@ -353,6 +358,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.plaster" => PixelFx::Plaster {
image_balance: num(p, "imageBalance", 20.0).clamp(1.0, 50.0),
smoothness: num(p, "smoothness", 8.0).clamp(1.0, 15.0),
light: Light::parse(text(p, "lightDirection", "top")),
},
"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),
@@ -440,6 +450,7 @@ impl PixelFx {
| PixelFx::ChalkCharcoal { .. }
| PixelFx::Chrome { .. }
| PixelFx::HalftonePattern { .. }
| PixelFx::Plaster { .. }
| PixelFx::Reticulation { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::FilmGrain { .. }
@@ -493,6 +504,7 @@ impl PixelFx {
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => Some(0.0),
PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)),
// Cutout's palette comes from the whole object.
PixelFx::Cutout { .. } => None,
// The softened tones around the pixel.
@@ -552,6 +564,7 @@ impl PixelFx {
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
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::Plaster { image_balance, smoothness, light } => sketch::plaster(px, w, h, space, image_balance, smoothness, light),
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),
+67 -2
View File
@@ -1,4 +1,4 @@
//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern and Reticulation; Graphic Pen lives in
//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Plaster 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
@@ -10,7 +10,7 @@ use vectorcraft_geom::Vec2;
use super::edges::sobel;
use super::pixelate::{Place, cell, rand, value_noise};
use super::texture::{Tones, luma, premultiply, smooth_noise, straight};
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, pick};
const SALT_CHALK: u64 = 216;
@@ -203,6 +203,71 @@ pub(super) fn chrome(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
});
}
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
}
/// How a surface whose height rises along `slope` faces a light from `toward`.
fn facing(slope: Vec2, toward: Vec2) -> f64 {
-slope.dot(toward)
}
/// The σ (document units) of Plaster's height blur at `smoothness` (1..15).
fn plaster_sigma_pt(smoothness: f64) -> f64 {
let smoothness = smoothness.clamp(1.0, 15.0);
0.15 + 0.85 * (smoothness - 1.0) / 14.0
}
/// How far (document units) Plaster's softened relief reaches at `smoothness` (1..15).
pub(super) fn plaster_reach(smoothness: f64) -> f64 {
if !smoothness.is_finite() {
return 3.0 * plaster_sigma_pt(8.0) + 0.5;
}
3.0 * plaster_sigma_pt(smoothness) + 0.5
}
/// Plaster: the object molded in relief plaster and lit from `light`. Dark areas are raised and
/// light areas recessed; `imageBalance` (1..50) shifts more of the image lower at higher values;
/// `smoothness` (1..15) softens the relief. Coverage is kept.
pub(super) fn plaster(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, light: Light) {
if !(image_balance.is_finite() && smoothness.is_finite() && space.px.is_finite() && space.px > 0.0) || w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) {
return;
}
let image_balance = image_balance.clamp(1.0, 50.0);
let smoothness = smoothness.clamp(1.0, 15.0);
let bias = (image_balance - 1.0) / 49.0;
let mut height: Vec<f32> = px
.iter()
.map(|p| {
let a = f32::from(p[3]) / 255.0;
let dark = (1.0 - luma(straight(*p))) * a;
(dark - bias as f32 * 0.65).clamp(0.0, 1.0)
})
.collect();
blur_plane(&mut height, w, h, plaster_sigma_pt(smoothness) / space.px);
let Some(place) = Place::new(space, w, h) else { return };
let step = (0.35f64).max(0.5 * place.px);
let strength = 0.55;
let toward = light.toward();
let at = |x: usize, y: usize, dx: i32, dy: i32| {
let (xx, yy) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
height[yy * w + xx]
};
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let h = height[y * w + x];
let (left, right, up, down) = (at(x, y, -1, 0), at(x, y, 1, 0), at(x, y, 0, -1), at(x, y, 0, 1));
let slope = Vec2::new(f64::from(right - left), f64::from(down - up)) / (2.0 * step);
let ridge = f64::from(h - 0.25 * (left + right + up + down)) / step;
let shade = strength * (facing(slope, toward) + ridge);
*p = premultiply(lit(straight(*p), shade), 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
+38
View File
@@ -2150,6 +2150,44 @@ fn halftone_pattern_type_changes_the_pattern() {
}
}
/// Plaster of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn plaster(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.plaster", p).apply(&mut out, w, h, s);
out
}
#[test]
fn plaster_params_take_defaults_and_stay_in_range() {
use super::texture::Light;
assert_eq!(
fx("sketch.plaster", json!({})),
PixelFx::Plaster { image_balance: 20.0, smoothness: 8.0, light: Light::Top }
);
assert_eq!(
fx("sketch.plaster", json!({"imageBalance": 0, "smoothness": 1e9, "lightDirection": "bottomRight"})),
PixelFx::Plaster { image_balance: 1.0, smoothness: 15.0, light: Light::BottomRight }
);
assert_eq!(fx("sketch.plaster", json!({"lightDirection": "nonsense"})), fx("sketch.plaster", json!({})));
let doc = crate::effect_info("sketch.plaster").unwrap().params;
assert!(doc.contains("\"bottomRight\""), "{doc}");
let f = fx("sketch.plaster", json!({"smoothness": 24}));
assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0)));
}
/// Plaster changes the image and different light directions give different results.
#[test]
fn plaster_light_direction_shifts_shading() {
let (w, h) = (48, 32);
let src = image(w, h, |x, y| grey((16 + (x + y) % 200) as u8));
let s = space(w, h);
let base = plaster(json!({"imageBalance": 20, "smoothness": 6, "lightDirection": "top"}), &src, w, h, &s);
assert_ne!(base, src, "plaster changes opaque pixels");
let left = plaster(json!({"lightDirection": "left"}), &src, w, h, &s);
let right = plaster(json!({"lightDirection": "right"}), &src, w, h, &s);
assert_ne!(left, right, "light direction changes the relief shading");
}
#[test]
fn halftone_contrast_sharpens_the_screen() {
let (w, h) = (64, 32);
+1 -1
View File
@@ -128,7 +128,7 @@ impl Light {
}
/// The unit vector towards the light (document axes, y down).
fn toward(self) -> Vec2 {
pub(super) fn toward(self) -> Vec2 {
let d = FRAC_1_SQRT_2;
match self {
Light::Bottom => Vec2::new(0.0, 1.0),
+2 -1
View File
@@ -49,11 +49,12 @@ const PIXELATE: [(&str, &str); 4] =
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
/// Effect › Sketch, at their defaults.
const SKETCH: [(&str, &str); 5] = [
const SKETCH: [(&str, &str); 6] = [
("sketch.chalkCharcoal", "{}"),
("sketch.chrome", "{}"),
("sketch.graphicPen", "{}"),
("sketch.halftonePattern", "{}"),
("sketch.plaster", "{}"),
("sketch.reticulation", "{}"),
];
+1
View File
@@ -285,6 +285,7 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
("sketch.graphicPen", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS),
("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES),
("sketch.plaster", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
("texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
+1
View File
@@ -211,6 +211,7 @@ fn raster_filter_dialogs_preview_and_apply() {
("pixelate.mezzotint", "type", "longLines"),
("pixelate.pointillize", "cellSize", "12"),
("sketch.halftonePattern", "patternType", "line"),
("sketch.plaster", "lightDirection", "bottomLeft"),
("texture.craquelure", "crackDepth", "9"),
("texture.grain", "grainType", "stippled"),
("texture.mosaicTiles", "tileSize", "20"),
+5 -3
View File
@@ -178,17 +178,19 @@ 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…", "Chrome…", "Graphic Pen…", "Halftone Pattern…", "Reticulation…"]);
assert_eq!(labels, ["Chalk & Charcoal…", "Chrome…", "Graphic Pen…", "Halftone Pattern…", "Plaster…", "Reticulation…"]);
assert!(sketch.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
assert_eq!(sketch[0].params, json!({"effect": "sketch.chalkCharcoal"}));
assert_eq!(sketch[1].params, json!({"effect": "sketch.chrome"}));
assert_eq!(sketch[2].params, json!({"effect": "sketch.graphicPen"}));
assert_eq!(sketch[3].params, json!({"effect": "sketch.halftonePattern"}));
assert_eq!(sketch[4].params, json!({"effect": "sketch.reticulation"}));
assert_eq!(sketch[4].params, json!({"effect": "sketch.plaster"}));
assert_eq!(sketch[5].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("Chrome…"));
assert!(at("Chrome…") < at("Graphic Pen…") && at("Graphic Pen…") < at("Halftone Pattern…"));
assert!(at("Halftone Pattern…") < at("Reticulation…") && at("Reticulation…") < at("Glowing Edges…"));
assert!(at("Halftone Pattern…") < at("Plaster…") && at("Plaster…") < at("Reticulation…"));
assert!(at("Reticulation…") < at("Glowing Edges…"));
}
/// Effect › Stylize under Raster Effects, between Sharpen and Texture: Glowing Edges opens its