mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
G2: Effect › Sketch › Plaster raster filter
Squashed from #2990 onto Chrome.
This commit is contained in:
committed by
bflatastic
parent
d21f3e0667
commit
b560b97616
@@ -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…",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", "{}"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user