G2: Effect › Artistic › Dry Brush raster filter

Squashed from #3026 onto main.
This commit is contained in:
bflatastic
2026-10-21 23:38:02 +07:00
parent 571ac32362
commit 0a3628f62f
31 changed files with 256 additions and 15 deletions
+7
View File
@@ -354,6 +354,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}),
),
r(
"artistic.dryBrush",
"Dry Brush…",
ARTISTIC,
"{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}),
),
r(
"artistic.filmGrain",
"Film Grain…",
+138 -5
View File
@@ -1,16 +1,20 @@
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges). Original filters built from
//! Effect › Artistic (Cutout, Dry Brush, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and
//! Watercolor). Original filters built from
//! public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
//! These are visual approximations, not reproductions of a proprietary algorithm.
use super::brushstrokes::stroke_reach;
use super::edges::sobel;
use super::pixelate::{Place, cell, rand};
const SALT_PLASTIC: u64 = 512;
use super::pixelate::{Place, cell, rand, value_noise};
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, blur_plane};
use vectorcraft_geom::Point;
use vectorcraft_geom::{Point, Vec2};
const SALT_DRY: u64 = 264;
const MARGIN: f64 = 1.0;
const SALT_PLASTIC: u64 = 512;
const SALT_FILM: u64 = 256;
@@ -314,6 +318,21 @@ pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
});
}
/// Stroke length (document units) for Dry Brush at Brush Size `size` (1..10).
fn dry_brush_length(size: f64) -> f64 {
2.0 + 2.8 * (size.clamp(1.0, 10.0) - 1.0)
}
/// How much Dry Brush softens before smearing at Brush Detail `detail` (0..10; 10 keeps the most detail).
fn dry_brush_sigma(detail: f64) -> f64 {
0.22 * (10.0 - detail.clamp(0.0, 10.0))
}
/// How far (document units) Dry Brush reads from a pixel.
pub(super) fn dry_brush_reach(size: f64, detail: f64) -> f64 {
stroke_reach(dry_brush_length(size)) + 3.0 * dry_brush_sigma(detail) + MARGIN
}
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
pub(super) fn glow_spread(size: f64) -> f64 {
0.25 * size.abs().min(24.0)
@@ -343,3 +362,117 @@ pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
}
/// The tone at `(x, y)` in `tone`, `w` × `h`, or 0 outside.
fn tone_at(tone: &[f32], w: usize, h: usize, x: isize, y: isize) -> f32 {
if x < 0 || y < 0 {
return 0.0;
}
let (x, y) = (x as usize, y as usize);
if x >= w || y >= h {
return 0.0;
}
tone[y * w + x]
}
/// A unit vector along the image gradient's tangent at `(x, y)`, or horizontal when flat.
fn dry_brush_dir(tone: &[f32], w: usize, h: usize, x: usize, y: usize) -> Vec2 {
let gx = 0.5 * (tone_at(tone, w, h, x as isize + 1, y as isize) - tone_at(tone, w, h, x as isize - 1, y as isize));
let gy = 0.5 * (tone_at(tone, w, h, x as isize, y as isize + 1) - tone_at(tone, w, h, x as isize, y as isize - 1));
let v = Vec2::new(-f64::from(gy), f64::from(gx));
let len = v.hypot();
if len > 1e-6 { v / len } else { Vec2::new(1.0, 0.0) }
}
/// Premultiplied RGBA (0..1) at document `q`, bilinearly from `src`.
fn sample_pm(src: &[[f32; 4]], w: usize, h: usize, place: &Place, q: Point) -> [f32; 4] {
let p = place.to_px * q;
if !(p.x.is_finite() && p.y.is_finite()) {
return [0.0; 4];
}
let (x, y) = (p.x - 0.5, p.y - 0.5);
let (i, j) = (x.floor() as isize, y.floor() as isize);
let (fx, fy) = (x - i as f64, y - j as f64);
let mut acc = [0.0f32; 4];
for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] {
let Some(row) = usize::try_from(row).ok().filter(|r| *r < h) else { continue };
for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] {
let Some(col) = usize::try_from(col).ok().filter(|c| *c < w) else { continue };
let k = (wx * wy) as f32;
if let Some(p) = src.get(row * w + col) {
for (a, v) in acc.iter_mut().zip(p) {
*a += *v * k;
}
}
}
}
acc
}
/// Dry Brush: the object repainted with short strokes along its form, edges darkened. `size`
/// (1..10) sets the stroke length, `detail` (0..10) how much is softened first (10 keeps the most
/// detail), and `texture` (1..3) how strongly edges and bristle grain contrast. Coverage is kept.
pub(super) fn dry_brush(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, detail: f64, texture: f64) {
if !([size, detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let len = dry_brush_length(size);
let sigma = dry_brush_sigma(detail);
let texture = texture.clamp(1.0, 3.0);
let edge_gain = (0.25 + 0.2 * (texture - 1.0)) as f32;
let bristle = (0.04 + 0.05 * (texture - 1.0)) as f32;
let (mut colour, alpha) = planes(px);
if sigma > 0.0 {
soften(&mut colour, &alpha, w, h, sigma);
}
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
let tone: Vec<f32> = (0..w * h)
.map(|k| {
let c = at(k);
let a = alpha.get(k).copied().unwrap_or(0.0);
(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) * a
})
.collect();
let edges = sobel(&tone, w, h);
let src: Vec<[f32; 4]> = (0..w * h)
.map(|k| {
let c = at(k);
let a = alpha.get(k).copied().unwrap_or(0.0);
[c[0] * a, c[1] * a, c[2] * a, a]
})
.collect();
let Some(place) = Place::new(space, w, h) else { return };
const SAMPLES: usize = 5;
let mut out = px.to_vec();
for (k, p) in out.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let (x, y) = (k % w, k / w);
let q = place.at(x, y);
let dir = dry_brush_dir(&tone, w, h, x, y);
let mut acc = [0.0f32; 4];
for i in 0..SAMPLES {
let t = (i as f64 / (SAMPLES - 1) as f64 - 0.5) * len;
let s = sample_pm(&src, w, h, &place, q + dir * t);
for (a, v) in acc.iter_mut().zip(s) {
*a += v;
}
}
for a in &mut acc {
*a /= SAMPLES as f32;
}
let a = acc[3].clamp(0.0, 1.0);
let c = if a > 1e-6 {
[0, 1, 2].map(|i| (acc[i] / a).clamp(0.0, 1.0))
} else {
straight(*p)
};
let edge = edges[k].min(1.0);
let dark = (edge_gain * edge).clamp(0.0, 0.75);
let grain = bristle * (2.0 * value_noise(SALT_DRY, q.x / (5.0 * space.px), q.y / (5.0 * space.px)) - 1.0) as f32;
let c = c.map(|v| (v * (1.0 - dark) + grain).clamp(0.0, 1.0));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
px.copy_from_slice(&out);
}
+17 -4
View File
@@ -1,9 +1,9 @@
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap and
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Dry Brush, Film Grain, Neon Glow, Plastic Wrap and
//! Poster Edges, Smudge Stick, Sponge and Watercolor, 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, Note Paper, Plaster and
//! Reticulation, Stylize ›
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster,
//! Reticulation and Stamp, 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.
@@ -43,8 +43,9 @@ 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; 47] = [
pub const PIXEL_EFFECTS: [&str; 48] = [
"artistic.cutout",
"artistic.dryBrush",
"artistic.filmGrain",
"artistic.neonGlow",
"artistic.plasticWrap",
@@ -100,6 +101,10 @@ pub enum PixelFx {
/// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their
/// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say.
Cutout { levels: f64, simplicity: f64, fidelity: f64 },
/// Artistic › Dry Brush: short strokes along the form with darkened edges; `size` (1..10) is the
/// brush length, `detail` (0..10) how much detail is kept, `texture` (1..3) the edge and bristle
/// contrast.
DryBrush { size: f64, detail: f64, texture: f64 },
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
@@ -292,6 +297,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0),
},
"artistic.dryBrush" => PixelFx::DryBrush {
size: num(p, "brushSize", 2.0).clamp(1.0, 10.0),
detail: num(p, "brushDetail", 10.0).clamp(0.0, 10.0),
texture: num(p, "texture", 1.0).round().clamp(1.0, 3.0),
},
"artistic.neonGlow" => PixelFx::NeonGlow {
size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0),
brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0),
@@ -540,6 +550,7 @@ impl PixelFx {
PixelFx::SmudgeStick { .. } | PixelFx::Sponge { .. } | PixelFx::Watercolor { .. } => 0.0,
PixelFx::FilmGrain { .. }
| PixelFx::Cutout { .. }
| PixelFx::DryBrush { .. }
| PixelFx::NeonGlow { .. }
| PixelFx::PlasticWrap { .. }
| PixelFx::PosterEdges { .. }
@@ -600,6 +611,7 @@ impl PixelFx {
| PixelFx::HalftonePattern { .. }
| PixelFx::NotePaper { .. }
| PixelFx::Reticulation { .. } => Some(0.0),
PixelFx::DryBrush { size, detail, .. } => Some(artistic::dry_brush_reach(size, detail)),
PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)),
PixelFx::WaterPaper { fiber_length, .. } => {
Some(sketch::water_paper_shift(fiber_length) + 3.0 * (0.06 * fiber_length.clamp(3.0, 50.0)).max(0.35))
@@ -632,6 +644,7 @@ impl PixelFx {
let to_px = |len: f64| len / space.px.max(1e-9);
match *self {
PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
PixelFx::DryBrush { size, detail, texture } => artistic::dry_brush(px, w, h, space, size, detail, texture),
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
PixelFx::SmudgeStick { stroke, highlight, intensity } => paint::smudge_stick(px, w, h, space, stroke, highlight, intensity),
PixelFx::Sponge { brush, definition, smoothness } => paint::sponge(px, w, h, space, brush, definition, smoothness),
+2 -1
View File
@@ -1,4 +1,5 @@
//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Note Paper, Plaster, Reticulation and Water Paper; Graphic Pen lives in
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Halftone Pattern, Note Paper, Photocopy, Plaster,
//! Reticulation, Stamp and Water Paper; 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; Water Paper flows colours on damp fibrous paper) and the public Photoshop Elements Artistic filters guide. Visual
+25 -1
View File
@@ -2401,6 +2401,11 @@ fn artistic_params_take_defaults_and_stay_in_range() {
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 }
);
assert_eq!(fx("artistic.dryBrush", json!({})), PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 });
assert_eq!(
fx("artistic.dryBrush", json!({"brushSize": 1e308, "brushDetail": 0, "texture": -2})),
PixelFx::DryBrush { size: 10.0, detail: 0.0, texture: 1.0 }
);
assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 });
assert_eq!(
fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})),
@@ -2418,7 +2423,7 @@ fn artistic_params_take_defaults_and_stay_in_range() {
fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})),
PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 }
);
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] {
for id in ["artistic.cutout", "artistic.dryBrush", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] {
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
}
}
@@ -2435,6 +2440,7 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
let colour = [0.3, 0.65, 1.0];
let filters = [
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 },
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
@@ -2548,6 +2554,24 @@ fn poster_edges_thickness_widens_the_lines() {
assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0));
}
#[test]
fn dry_brush_repaints_and_preserves_coverage() {
let (w, h) = (48, 24);
let src = ramp(w, h);
let mut out = src.clone();
fx("artistic.dryBrush", json!({})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
for (a, b) in src.as_chunks::<4>().0.iter().zip(out.as_chunks::<4>().0) {
assert_eq!(a[3], b[3], "coverage preserved");
}
let mut clear = vec![0u8; w * h * 4];
let before = clear.clone();
fx("artistic.dryBrush", json!({})).apply(&mut clear, w, h, &space(w, h));
assert_eq!(clear, before);
let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach();
assert!(reach.is_some_and(|r| r > 0.0));
}
#[test]
fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() {
let (w, h) = (40, 20);