mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
G2: Effect › Sketch › Chalk & Charcoal raster filter
Adds sketch.chalkCharcoal to the pixel-effect pipeline so Effect › Sketch exposes chalk highlights and charcoal strokes with public-doc-style parameters and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Cursor
parent
19e3144d27
commit
1f2007eb46
@@ -467,6 +467,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.chalkCharcoal",
|
||||
"Chalk & Charcoal…",
|
||||
SKETCH,
|
||||
"{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.",
|
||||
json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and 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 › Halftone Pattern, Stylize ›
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal and
|
||||
//! Halftone Pattern, 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.
|
||||
@@ -39,7 +40,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; 32] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 33] = [
|
||||
"artistic.cutout",
|
||||
"artistic.filmGrain",
|
||||
"artistic.neonGlow",
|
||||
@@ -62,6 +63,7 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.chalkCharcoal",
|
||||
"sketch.halftonePattern",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
@@ -139,6 +141,9 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Sketch › Chalk & Charcoal: chalk highlights in light areas and charcoal in dark ones;
|
||||
/// `chalk` and `charcoal` (0..20) set how much, `pressure` (0..5) the stroke weight.
|
||||
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
|
||||
/// 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 },
|
||||
@@ -311,6 +316,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"sketch.chalkCharcoal" => PixelFx::ChalkCharcoal {
|
||||
chalk: num(p, "chalkArea", 6.0).clamp(0.0, 20.0),
|
||||
charcoal: num(p, "charcoalArea", 6.0).clamp(0.0, 20.0),
|
||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
|
||||
},
|
||||
"sketch.halftonePattern" => PixelFx::HalftonePattern {
|
||||
size: num(p, "size", 1.0).clamp(1.0, 12.0),
|
||||
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
|
||||
@@ -392,7 +402,7 @@ impl PixelFx {
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::ChalkCharcoal { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::Cutout { .. }
|
||||
@@ -439,6 +449,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::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
// Cutout's palette comes from the whole object.
|
||||
PixelFx::Cutout { .. } => None,
|
||||
@@ -493,6 +504,7 @@ impl PixelFx {
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
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,12 +1,37 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern). Original filters built from public
|
||||
//! behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
|
||||
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
|
||||
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
|
||||
//! black and white) and the public Photoshop Elements Artistic filters guide. Visual
|
||||
//! approximations, not proprietary reproductions.
|
||||
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, value_noise};
|
||||
use super::texture::{Tones, luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
const SALT_CHALK: u64 = 216;
|
||||
const EDGE_SOFT: f64 = 0.8;
|
||||
const TONE_SOFT: f64 = 1.0;
|
||||
|
||||
/// How far (document units) Chalk & Charcoal samples softened tones and edges.
|
||||
pub(super) const CHALK_CHARCOAL_REACH: f64 = 3.0 * TONE_SOFT + 1.0 + 3.0 * EDGE_SOFT;
|
||||
|
||||
fn ramp(t: f64) -> f64 {
|
||||
t.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn tone_edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let s = sigma / place.px;
|
||||
blur_plane(&mut tone, w, h, s);
|
||||
blur_plane(&mut cover, w, h, s);
|
||||
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
|
||||
let norm = if norm.is_finite() { norm } else { 0.0 };
|
||||
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
|
||||
}
|
||||
|
||||
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
|
||||
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
|
||||
@@ -69,3 +94,46 @@ pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &P
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal: light areas get chalky highlights and dark areas charcoal strokes and grain;
|
||||
/// `chalk` and `charcoal` (0..20) set how strongly, `pressure` (0..5) the stroke weight. Black
|
||||
/// chalk and charcoal on white paper, coverage kept.
|
||||
pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, chalk: f64, charcoal: f64, pressure: f64) {
|
||||
if !(chalk.is_finite() && charcoal.is_finite() && pressure.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let scale = (pressure.clamp(0.0, 5.0) / 5.0).clamp(0.0, 1.0);
|
||||
let chalk_amt = (0.05 * chalk.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
let charcoal_amt = (0.05 * charcoal.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
if chalk_amt <= 0.0 && charcoal_amt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
|
||||
let edges = tone_edges(px, w, h, &place, EDGE_SOFT);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let tone = tones.get(x, y);
|
||||
let dark = (1.0 - tone).clamp(0.0, 1.0);
|
||||
let light = tone.clamp(0.0, 1.0);
|
||||
let edge = f64::from(edges.get(y * w + x).copied().unwrap_or(0.0));
|
||||
let charcoal_stroke = value_noise(SALT_CHALK + 1, q.x * 1.1, q.y * 0.12);
|
||||
let charcoal_grain = value_noise(SALT_CHALK, q.x * 0.35, q.y * 0.35);
|
||||
let charcoal_mask = ramp((dark - 0.3) / 0.22)
|
||||
* charcoal_amt
|
||||
* (0.35 + 0.65 * edge)
|
||||
* (0.45 + 0.55 * ramp(charcoal_stroke * 2.2 - 0.55))
|
||||
* (0.6 + 0.4 * charcoal_grain);
|
||||
let chalk_grain = value_noise(SALT_CHALK + 2, q.x * 0.45 + 7.0, q.y * 0.45);
|
||||
let chalk_mask = ramp((light - 0.5) / 0.22) * chalk_amt * (0.55 + 0.45 * chalk_grain);
|
||||
let down = (0.9 * charcoal_mask).clamp(0.0, 1.0) as f32;
|
||||
let up = (0.8 * chalk_mask).clamp(0.0, 1.0) as f32;
|
||||
let mut grey = f32::from(luma(straight(*p)));
|
||||
grey *= 1.0 - down;
|
||||
grey += (1.0 - grey) * up;
|
||||
*p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1998,6 +1998,50 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn chalk_charcoal(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.chalkCharcoal", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(
|
||||
fx("sketch.chalkCharcoal", json!({})),
|
||||
PixelFx::ChalkCharcoal { chalk: 6.0, charcoal: 6.0, pressure: 2.0 }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("sketch.chalkCharcoal", json!({"chalkArea": 1e9, "charcoalArea": -2, "strokePressure": 99})),
|
||||
PixelFx::ChalkCharcoal { chalk: 20.0, charcoal: 0.0, pressure: 5.0 }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.chalkCharcoal").unwrap().params;
|
||||
assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}");
|
||||
let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert!(f.reach().unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
let out = chalk_charcoal(json!({"chalkArea": 12, "charcoalArea": 12, "strokePressure": 4}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "coverage kept");
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(p[0], p[1]);
|
||||
assert_eq!(p[1], p[2]);
|
||||
}
|
||||
assert_ne!(out, src);
|
||||
}
|
||||
|
||||
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
|
||||
@@ -171,18 +171,19 @@ fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {
|
||||
assert!(at("Sharpen") < at("Texture") && at("Texture") < at("Video"));
|
||||
}
|
||||
|
||||
/// Effect › Sketch, between Sharpen and the raster Stylize: Halftone Pattern opens its dialog.
|
||||
/// Effect › Sketch, between Sharpen and the raster Stylize: Chalk & Charcoal and Halftone Pattern open their dialogs.
|
||||
#[test]
|
||||
fn effect_menu_lists_the_sketch_filters_between_sharpen_and_stylize() {
|
||||
let app = app();
|
||||
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, ["Halftone Pattern…"]);
|
||||
assert_eq!(labels, ["Chalk & Charcoal…", "Halftone Pattern…"]);
|
||||
assert_eq!(sketch[0].command.as_deref(), Some("effect.dialog"));
|
||||
assert_eq!(sketch[0].params, json!({"effect": "sketch.halftonePattern"}));
|
||||
assert_eq!(sketch[0].params, json!({"effect": "sketch.chalkCharcoal"}));
|
||||
assert_eq!(sketch[1].params, json!({"effect": "sketch.halftonePattern"}));
|
||||
let at = |label: &str| entries.iter().position(|e| e.path[0] == "Effect" && e.label == label).unwrap();
|
||||
assert!(at("Unsharp Mask…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Glowing Edges…"));
|
||||
assert!(at("Unsharp Mask…") < at("Chalk & Charcoal…") && at("Chalk & Charcoal…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Glowing Edges…"));
|
||||
}
|
||||
|
||||
/// Effect › Stylize under Raster Effects, between Sharpen and Texture: Glowing Edges opens its
|
||||
|
||||
Reference in New Issue
Block a user