diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index db81037f5..ddf01dc53 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -467,6 +467,13 @@ pub fn effect_catalog() -> Vec { "{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…", diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index ee25c9ca6..186fb9457 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -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 { 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), diff --git a/crates/effects/src/pixel/sketch.rs b/crates/effects/src/pixel/sketch.rs index 7f9f7cd04..616367c0e 100644 --- a/crates/effects/src/pixel/sketch.rs +++ b/crates/effects/src/pixel/sketch.rs @@ -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: //! (Sketch effects redraw in -//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones) -//! and (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 { + let mut tone: Vec = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect(); + let mut cover: Vec = 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); + }); +} diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 361c50733..0286ba0ce 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -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 { + 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::>().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 { let mut out = src.to_vec(); diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index f7d0930c9..8bb432359 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -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