diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7ee3131ed..0b8a19074 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -489,6 +489,13 @@ pub fn effect_catalog() -> Vec { "{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}), ), + r( + "sketch.reticulation", + "Reticulation…", + SKETCH, + "{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}), + ), r( "stylize.glowingEdges", "Glowing Edges…", diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 74a272ecd..a72fd9896 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -2,7 +2,8 @@ //! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Graphic Pen, 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/n//! Pen and Halftone Pattern, Stylize › +//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic +//! Pen, Halftone Pattern and Reticulation, 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. @@ -40,7 +41,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; 34] = [ +pub const PIXEL_EFFECTS: [&str; 35] = [ "artistic.cutout", "artistic.filmGrain", "artistic.neonGlow", @@ -66,6 +67,7 @@ pub const PIXEL_EFFECTS: [&str; 34] = [ "sketch.chalkCharcoal", "sketch.graphicPen", "sketch.halftonePattern", + "sketch.reticulation", "stylize.glowingEdges", "texture.craquelure", "texture.grain", @@ -151,6 +153,9 @@ pub enum PixelFx { /// 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 }, + /// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen; + /// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain. + Reticulation { density: f64, foreground: f64, background: f64 }, /// Stylize › Glowing Edges: bright coloured Sobel outlines on black. GlowingEdges { width: f64, brightness: f64, smoothness: f64 }, /// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels @@ -335,6 +340,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0), kind: HalftonePattern::parse(text(p, "patternType", "dot")), }, + "sketch.reticulation" => PixelFx::Reticulation { + density: num(p, "density", 12.0).clamp(0.0, 50.0), + foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0), + background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0), + }, "stylize.glowingEdges" => PixelFx::GlowingEdges { width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0), brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0), @@ -413,7 +423,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::ChalkCharcoal { .. } | PixelFx::HalftonePattern { .. } => 0.0, + PixelFx::UnsharpMask { .. } | PixelFx::ChalkCharcoal { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => 0.0, PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0), PixelFx::FilmGrain { .. } | PixelFx::Cutout { .. } @@ -463,7 +473,7 @@ impl PixelFx { 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), + PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => Some(0.0), // Cutout's palette comes from the whole object. PixelFx::Cutout { .. } => None, // The softened tones around the pixel. @@ -520,6 +530,7 @@ impl PixelFx { PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure), PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction), PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind), + PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background), 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), PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell), diff --git a/crates/effects/src/pixel/sketch.rs b/crates/effects/src/pixel/sketch.rs index 08da64fe4..323adb5a4 100644 --- a/crates/effects/src/pixel/sketch.rs +++ b/crates/effects/src/pixel/sketch.rs @@ -1,4 +1,5 @@ -//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern; Graphic Pen lives in [`super::brushstrokes`]).\n//! Original filters built from public behaviour documentation: +//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern and Reticulation; Graphic Pen lives in +//! [`super::brushstrokes`]). Original filters built from public behaviour documentation: //! (Sketch effects redraw in //! black and white) and the public Photoshop Elements Artistic filters guide. Visual //! approximations, not proprietary reproductions. @@ -6,7 +7,7 @@ use std::f64::consts::TAU; use super::edges::sobel; -use super::pixelate::{Place, value_noise}; +use super::pixelate::{Place, cell, rand, value_noise}; use super::texture::{Tones, luma, premultiply, straight}; use super::{PixelSpace, blur_plane, pick}; @@ -136,3 +137,36 @@ pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pix *p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0); }); } + +/// Reticulation: the object redrawn in black ink on white paper through a high-contrast film-grain +/// screen. `density` (0..50) sets how fine the grain is; `foreground` and `background` (0..50 each) +/// pull shadows toward black and highlights toward white before the grain decides ink. Coverage is +/// kept. +pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, density: f64, foreground: f64, background: f64) { + if !(density.is_finite() && foreground.is_finite() && background.is_finite()) { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + // Finer grain at higher density: cells from about 11 pt down to 1 pt. + let cell_size = 1.0 + (50.0 - density.clamp(0.0, 50.0)) * 0.2; + let fg = foreground.clamp(0.0, 50.0) / 50.0; + let bg = background.clamp(0.0, 50.0) / 50.0; + let spread = 0.12 + 0.38 * (density.clamp(0.0, 50.0) / 50.0); + const SALT: u64 = 0x5245_7469; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0); + let tone = (dark + fg * (1.0 - dark) * 0.65 - bg * dark * 0.65).clamp(0.0, 1.0); + let coarse = value_noise(SALT, q.x / cell_size, q.y / cell_size); + let fine = match (cell(q.x), cell(q.y)) { + (Some(i), Some(j)) => rand(SALT + 1, i, j, 2), + _ => 0.5, + }; + let grain = 0.55 * coarse + 0.45 * fine; + let ink = if tone + (grain - 0.5) * spread > 0.5 { 1.0 } else { 0.0 }; + *p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0); + }); +} diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index cd6e31239..1c9ad11e1 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -2193,6 +2193,50 @@ fn halftone_draws_in_black_and_white_only() { } } +/// Reticulation of `p` over `w` × `h` pixels of `src`, laid out by `s`. +fn reticulation(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec { + let mut out = src.to_vec(); + fx("sketch.reticulation", p).apply(&mut out, w, h, s); + out +} + +#[test] +fn reticulation_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("sketch.reticulation", json!({})), PixelFx::Reticulation { density: 12.0, foreground: 6.0, background: 6.0 }); + assert_eq!( + fx("sketch.reticulation", json!({"density": -5, "foregroundLevel": 99, "backgroundLevel": -1})), + PixelFx::Reticulation { density: 0.0, foreground: 50.0, background: 0.0 } + ); + let f = fx("sketch.reticulation", json!({})); + assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0); + assert_eq!(f.reach(), Some(0.0)); +} + +#[test] +fn reticulation_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 = reticulation(json!({}), &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"); + assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{p:?} from {q:?}"); + } + assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper"); +} + +#[test] +fn reticulation_density_changes_the_grain() { + let (w, h) = (64, 64); + let src = image(w, h, |_, _| grey(128)); + let low = reticulation(json!({"density": 5}), &src, w, h, &space(w, h)); + let high = reticulation(json!({"density": 45}), &src, w, h, &space(w, h)); + assert_ne!(low, high); +} + /// The distinct colours of the covered pixels. fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> { d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect() diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index b0e2195b0..56b4a1c7c 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -44,7 +44,7 @@ const PIXELATE: [(&str, &str); 4] = [("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")]; /// Effect › Sketch, at their defaults. -const SKETCH: [(&str, &str); 3] = [("sketch.chalkCharcoal", "{}"), ("sketch.graphicPen", "{}"), ("sketch.halftonePattern", "{}")]; +const SKETCH: [(&str, &str); 4] = [("sketch.chalkCharcoal", "{}"), ("sketch.graphicPen", "{}"), ("sketch.halftonePattern", "{}"), ("sketch.reticulation", "{}")]; /// Effect › Texture, at their defaults. const TEXTURE: [(&str, &str); 6] = [ diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index ab0cab022..1b65b1aad 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -336,6 +336,7 @@ fn structured_junk() { ("effect.apply", json!({"effect": "artistic.posterEdges", "params": {"edgeThickness": 1e308, "edgeIntensity": [], "posterization": -7}})), ("effect.apply", json!({"effect": "sketch.graphicPen", "params": {"strokeLength": -1e308, "lightDarkBalance": "NaN", "strokeDirection": 3}})), ("effect.apply", json!({"effect": "sketch.halftonePattern", "params": {"size": -1e308, "contrast": "NaN", "patternType": 3}})), + ("effect.apply", json!({"effect": "sketch.reticulation", "params": {"density": "NaN", "foregroundLevel": -1e308, "backgroundLevel": 1e308}})), ("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})), ("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})), ("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 5d192f4df..2679367da 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -178,14 +178,16 @@ fn effect_menu_lists_the_sketch_filters_between_sharpen_and_stylize() { 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, ["Chalk & Charcoal…", "Graphic Pen…", "Halftone Pattern…"]); + assert_eq!(labels, ["Chalk & Charcoal…", "Graphic Pen…", "Halftone Pattern…", "Reticulation…"]); assert_eq!(sketch[0].command.as_deref(), Some("effect.dialog")); assert_eq!(sketch[0].params, json!({"effect": "sketch.chalkCharcoal"})); assert_eq!(sketch[1].params, json!({"effect": "sketch.graphicPen"})); assert_eq!(sketch[2].params, json!({"effect": "sketch.halftonePattern"})); + assert_eq!(sketch[3].params, json!({"effect": "sketch.reticulation"})); let at = |label: &str| entries.iter().position(|e| e.path[0] == "Effect" && e.label == label).unwrap(); assert!(at("Unsharp Mask…") < at("Chalk & Charcoal…") && at("Chalk & Charcoal…") < at("Graphic Pen…")); - assert!(at("Graphic Pen…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Glowing Edges…")); + assert!(at("Graphic Pen…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Reticulation…")); + assert!(at("Reticulation…") < at("Glowing Edges…")); } /// Effect › Stylize under Raster Effects, between Sharpen and Texture: Glowing Edges opens its