mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
G2: Effect › Artistic › Colored Pencil raster filter
Squashed from #3028 onto main.
This commit is contained in:
@@ -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…",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user