G2: Effect › Artistic › Colored Pencil raster filter

Squashed from #3028 onto main.
This commit is contained in:
bflatastic
2026-10-22 00:30:39 +07:00
parent 83a5978d6e
commit 03a2da13ba
31 changed files with 158 additions and 14 deletions
+7
View File
@@ -350,6 +350,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
r(
"artistic.coloredPencil",
"Colored Pencil…",
ARTISTIC,
"{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}),
),
r(
"artistic.cutout",
"Cutout…",
+51 -1
View File
@@ -1,4 +1,4 @@
//! Effect › Artistic (Cutout, Dry Brush, Film Grain, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster
//! Effect › Artistic (Colored Pencil, Cutout, Dry Brush, Film Grain, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster
//! Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor). Original filters built from
//! public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
@@ -71,6 +71,7 @@ impl PaintDaubBrush {
pub(super) fn paint_daub_radius(size: f64) -> f64 {
size.clamp(1.0, 50.0) * 0.48
}
const SALT_PENCIL: u64 = 304;
/// Keep the last cell's four corner values: magnified adjacent pixels often share a cell.
/// The cache changes only cost, never the field, and needs constant storage per raster.
@@ -219,6 +220,55 @@ fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
}
/// How far (document units) Colored Pencil reaches at pencil width `width`: soften at
/// `0.12 * width` and the Sobel neighbourhood around it.
pub(super) fn colored_pencil_reach(width: f64) -> f64 {
3.0 * 0.12 * width.clamp(1.0, 24.0) + 1.0
}
/// Colored Pencil: the object redrawn with coloured pencil strokes along local edges, keeping its
/// hues. `width` (1..24) sets the stroke spacing in document units; `pressure` (0..15) darkens
/// along the strokes; `paper` (0..50) lifts lighter areas toward the paper. Coverage is kept.
pub(super) fn colored_pencil(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, paper: f64) {
if !([width, pressure, paper].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let width = width.clamp(1.0, 24.0);
let pressure = (pressure / 15.0).clamp(0.0, 1.0) as f32;
let paper_lift = (paper / 50.0).clamp(0.0, 1.0) as f32;
if pressure == 0.0 && paper_lift == 0.0 {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let (mut colour, alpha) = planes(px);
soften(&mut colour, &alpha, w, h, (0.12 * width) / space.px);
let tones: Vec<f32> = (0..px.len()).map(|k| luma([colour[0][k], colour[1][k], colour[2][k]])).collect();
let mut edges = vec![0.0f32; px.len()];
for (e, s) in edges.iter_mut().zip(sobel(&tones, w, h)) {
*e = (s / 4.0).min(1.0);
}
let spacing = (0.35 * width).max(1.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let k = y * w + x;
let c = [colour[0][k], colour[1][k], colour[2][k]];
let gx = tone_at(&tones, w, h, x as isize + 1, y as isize) - tone_at(&tones, w, h, x as isize - 1, y as isize);
let gy = tone_at(&tones, w, h, x as isize, y as isize + 1) - tone_at(&tones, w, h, x as isize, y as isize - 1);
let angle = gy.atan2(gx) + std::f32::consts::FRAC_PI_2;
let (dx, dy) = (angle.cos() as f64, angle.sin() as f64);
let q = place.at(x, y);
let along = q.x * dx + q.y * dy;
let grain = value_noise(SALT_PENCIL, along / spacing, (q.x * 0.37 + q.y * 0.11) / spacing);
let stroke = (0.5 + 0.5 * (along / spacing * TAU + grain * 3.0).sin()) as f32;
let ink = pressure * (0.3 + 0.55 * stroke) * (0.55 + 0.45 * edges[k]);
let lift = paper_lift * (1.0 - luma(c)) * 0.35;
let c = c.map(|v| (v * (1.0 - ink * 0.45) + lift * (1.0 - v)).clamp(0.0, 1.0));
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
+14 -2
View File
@@ -1,4 +1,4 @@
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Dry Brush, Film Grain, Neon Glow, Paint Daubs, Plastic Wrap and
//! Photoshop-style raster effects (Effect › Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Neon Glow, Paint Daubs, Plastic Wrap and
//! Poster Edges, Rough Pastels, 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,
@@ -44,7 +44,8 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 52] = [
pub const PIXEL_EFFECTS: [&str; 53] = [
"artistic.coloredPencil",
"artistic.cutout",
"artistic.dryBrush",
"artistic.filmGrain",
@@ -103,6 +104,9 @@ pub const PIXEL_EFFECTS: [&str; 52] = [
/// are in document units.
#[derive(Clone, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Colored Pencil: coloured strokes `width` (1..24) apart, as heavy as `pressure`
/// (0..15), on paper lifted by `paper` (0..50).
ColoredPencil { width: f64, pressure: f64, paper: f64 },
/// 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 },
@@ -312,6 +316,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
};
Some(match id {
"artistic.coloredPencil" => PixelFx::ColoredPencil {
width: num(p, "pencilWidth", 5.0).clamp(1.0, 24.0),
pressure: num(p, "strokePressure", 5.0).clamp(0.0, 15.0),
paper: num(p, "paperBrightness", 25.0).clamp(0.0, 50.0),
},
"artistic.cutout" => PixelFx::Cutout {
levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0),
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
@@ -600,6 +609,7 @@ impl PixelFx {
// These three keep the object's coverage.
PixelFx::SmudgeStick { .. } | PixelFx::Sponge { .. } | PixelFx::Watercolor { .. } => 0.0,
PixelFx::FilmGrain { .. }
| PixelFx::ColoredPencil { .. }
| PixelFx::Cutout { .. }
| PixelFx::DryBrush { .. }
| PixelFx::NeonGlow { .. }
@@ -658,6 +668,7 @@ impl PixelFx {
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
PixelFx::FilmGrain { .. }
@@ -699,6 +710,7 @@ impl PixelFx {
let Some(px) = pixels(data, w, h) else { return };
let to_px = |len: f64| len / space.px.max(1e-9);
match *self {
PixelFx::ColoredPencil { width, pressure, paper } => artistic::colored_pencil(px, w, h, space, width, pressure, paper),
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),
+28 -2
View File
@@ -2421,6 +2421,12 @@ fn ramp(w: usize, h: usize) -> Vec<u8> {
#[test]
fn artistic_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("artistic.coloredPencil", json!({})), PixelFx::ColoredPencil { width: 5.0, pressure: 5.0, paper: 25.0 });
assert_eq!(
fx("artistic.coloredPencil", json!({"pencilWidth": 0, "strokePressure": 200, "paperBrightness": -5})),
PixelFx::ColoredPencil { width: 1.0, pressure: 15.0, paper: 0.0 }
);
assert_eq!(fx("artistic.coloredPencil", json!({})).reach(), Some(artistic::colored_pencil_reach(5.0)));
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
assert_eq!(
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
@@ -2484,8 +2490,15 @@ 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.dryBrush", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges", "artistic.underpainting"]
{
for id in [
"artistic.coloredPencil",
"artistic.cutout",
"artistic.dryBrush",
"artistic.neonGlow",
"artistic.plasticWrap",
"artistic.posterEdges",
"artistic.underpainting",
] {
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
}
assert_eq!(fx("artistic.paintDaubs", json!({})), PixelFx::PaintDaubs { size: 10.0, sharpness: 25.0, kind: PaintDaubBrush::Simple });
@@ -2509,6 +2522,19 @@ fn artistic_params_take_defaults_and_stay_in_range() {
assert!(fx("artistic.roughPastels", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0);
}
#[test]
fn colored_pencil_strokes_change_pixels_and_keep_coverage() {
let (w, h) = (48, 32);
let src = ramp(w, h);
let mut out = src.clone();
fx("artistic.coloredPencil", json!({"strokePressure": 12})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
let mut mild = src.clone();
fx("artistic.coloredPencil", json!({"strokePressure": 0, "paperBrightness": 0})).apply(&mut mild, w, h, &space(w, h));
assert_eq!(mild, src, "zero pressure and paper leaves the art unchanged");
}
#[test]
fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
let (w, h) = (16, 12);