diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 87ca5ad9d..588979de2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -503,6 +503,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.plaster", + "Plaster…", + SKETCH, + "{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}), + ), r( "sketch.reticulation", "Reticulation…", diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index ec03ca463..031297ce0 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -2,7 +2,7 @@ //! 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 › Chalk & Charcoal, Graphic Pen, Halftone Pattern and +//! Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic Pen, Halftone Pattern, Plaster and //! Reticulation, Stylize › //! Glowing Edges, Texture //! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video › @@ -41,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; 37] = [ +pub const PIXEL_EFFECTS: [&str; 38] = [ "artistic.cutout", "artistic.filmGrain", "artistic.neonGlow", @@ -69,6 +69,7 @@ pub const PIXEL_EFFECTS: [&str; 37] = [ "sketch.chrome", "sketch.graphicPen", "sketch.halftonePattern", + "sketch.plaster", "sketch.reticulation", "stylize.glowingEdges", "texture.craquelure", @@ -160,6 +161,10 @@ 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 › Plaster: relief plaster lit from `light`; dark areas raised, light recessed; + /// `imageBalance` (1..50) shifts more of the image lower at higher values; `smoothness` (1..15) + /// softens the relief. + Plaster { image_balance: f64, smoothness: f64, light: Light }, /// 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 }, @@ -353,6 +358,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.plaster" => PixelFx::Plaster { + image_balance: num(p, "imageBalance", 20.0).clamp(1.0, 50.0), + smoothness: num(p, "smoothness", 8.0).clamp(1.0, 15.0), + light: Light::parse(text(p, "lightDirection", "top")), + }, "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), @@ -440,6 +450,7 @@ impl PixelFx { | PixelFx::ChalkCharcoal { .. } | PixelFx::Chrome { .. } | PixelFx::HalftonePattern { .. } + | PixelFx::Plaster { .. } | PixelFx::Reticulation { .. } => 0.0, PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0), PixelFx::FilmGrain { .. } @@ -493,6 +504,7 @@ impl PixelFx { PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH), PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)), PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => Some(0.0), + PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)), // Cutout's palette comes from the whole object. PixelFx::Cutout { .. } => None, // The softened tones around the pixel. @@ -552,6 +564,7 @@ impl PixelFx { PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness), 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::Plaster { image_balance, smoothness, light } => sketch::plaster(px, w, h, space, image_balance, smoothness, light), 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), diff --git a/crates/effects/src/pixel/sketch.rs b/crates/effects/src/pixel/sketch.rs index 6b3ce6133..588d1ca80 100644 --- a/crates/effects/src/pixel/sketch.rs +++ b/crates/effects/src/pixel/sketch.rs @@ -1,4 +1,4 @@ -//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern and Reticulation; Graphic Pen lives in +//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Plaster 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 @@ -10,7 +10,7 @@ use vectorcraft_geom::Vec2; use super::edges::sobel; use super::pixelate::{Place, cell, rand, value_noise}; -use super::texture::{Tones, luma, premultiply, smooth_noise, straight}; +use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight}; use super::{PixelSpace, blur_plane, pick}; const SALT_CHALK: u64 = 216; @@ -203,6 +203,71 @@ pub(super) fn chrome(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, }); } +/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above. +fn lit(c: [f32; 3], s: f64) -> [f32; 3] { + let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 }; + c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s }) +} + +/// How a surface whose height rises along `slope` faces a light from `toward`. +fn facing(slope: Vec2, toward: Vec2) -> f64 { + -slope.dot(toward) +} + +/// The σ (document units) of Plaster's height blur at `smoothness` (1..15). +fn plaster_sigma_pt(smoothness: f64) -> f64 { + let smoothness = smoothness.clamp(1.0, 15.0); + 0.15 + 0.85 * (smoothness - 1.0) / 14.0 +} + +/// How far (document units) Plaster's softened relief reaches at `smoothness` (1..15). +pub(super) fn plaster_reach(smoothness: f64) -> f64 { + if !smoothness.is_finite() { + return 3.0 * plaster_sigma_pt(8.0) + 0.5; + } + 3.0 * plaster_sigma_pt(smoothness) + 0.5 +} + +/// Plaster: the object molded in relief plaster and lit from `light`. Dark areas are raised and +/// light areas recessed; `imageBalance` (1..50) shifts more of the image lower at higher values; +/// `smoothness` (1..15) softens the relief. Coverage is kept. +pub(super) fn plaster(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, light: Light) { + if !(image_balance.is_finite() && smoothness.is_finite() && space.px.is_finite() && space.px > 0.0) || w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) { + return; + } + let image_balance = image_balance.clamp(1.0, 50.0); + let smoothness = smoothness.clamp(1.0, 15.0); + let bias = (image_balance - 1.0) / 49.0; + let mut height: Vec = px + .iter() + .map(|p| { + let a = f32::from(p[3]) / 255.0; + let dark = (1.0 - luma(straight(*p))) * a; + (dark - bias as f32 * 0.65).clamp(0.0, 1.0) + }) + .collect(); + blur_plane(&mut height, w, h, plaster_sigma_pt(smoothness) / space.px); + let Some(place) = Place::new(space, w, h) else { return }; + let step = (0.35f64).max(0.5 * place.px); + let strength = 0.55; + let toward = light.toward(); + let at = |x: usize, y: usize, dx: i32, dy: i32| { + let (xx, yy) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize); + height[yy * w + xx] + }; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let h = height[y * w + x]; + let (left, right, up, down) = (at(x, y, -1, 0), at(x, y, 1, 0), at(x, y, 0, -1), at(x, y, 0, 1)); + let slope = Vec2::new(f64::from(right - left), f64::from(down - up)) / (2.0 * step); + let ridge = f64::from(h - 0.25 * (left + right + up + down)) / step; + let shade = strength * (facing(slope, toward) + ridge); + *p = premultiply(lit(straight(*p), shade), 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 diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 707dea4f9..87d9e420a 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -2150,6 +2150,44 @@ fn halftone_pattern_type_changes_the_pattern() { } } +/// Plaster of `p` over `w` × `h` pixels of `src`, laid out by `s`. +fn plaster(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec { + let mut out = src.to_vec(); + fx("sketch.plaster", p).apply(&mut out, w, h, s); + out +} + +#[test] +fn plaster_params_take_defaults_and_stay_in_range() { + use super::texture::Light; + assert_eq!( + fx("sketch.plaster", json!({})), + PixelFx::Plaster { image_balance: 20.0, smoothness: 8.0, light: Light::Top } + ); + assert_eq!( + fx("sketch.plaster", json!({"imageBalance": 0, "smoothness": 1e9, "lightDirection": "bottomRight"})), + PixelFx::Plaster { image_balance: 1.0, smoothness: 15.0, light: Light::BottomRight } + ); + assert_eq!(fx("sketch.plaster", json!({"lightDirection": "nonsense"})), fx("sketch.plaster", json!({}))); + let doc = crate::effect_info("sketch.plaster").unwrap().params; + assert!(doc.contains("\"bottomRight\""), "{doc}"); + let f = fx("sketch.plaster", json!({"smoothness": 24})); + assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0))); +} + +/// Plaster changes the image and different light directions give different results. +#[test] +fn plaster_light_direction_shifts_shading() { + let (w, h) = (48, 32); + let src = image(w, h, |x, y| grey((16 + (x + y) % 200) as u8)); + let s = space(w, h); + let base = plaster(json!({"imageBalance": 20, "smoothness": 6, "lightDirection": "top"}), &src, w, h, &s); + assert_ne!(base, src, "plaster changes opaque pixels"); + let left = plaster(json!({"lightDirection": "left"}), &src, w, h, &s); + let right = plaster(json!({"lightDirection": "right"}), &src, w, h, &s); + assert_ne!(left, right, "light direction changes the relief shading"); +} + #[test] fn halftone_contrast_sharpens_the_screen() { let (w, h) = (64, 32); diff --git a/crates/effects/src/pixel/texture.rs b/crates/effects/src/pixel/texture.rs index c3ef214d7..faa0b536a 100644 --- a/crates/effects/src/pixel/texture.rs +++ b/crates/effects/src/pixel/texture.rs @@ -128,7 +128,7 @@ impl Light { } /// The unit vector towards the light (document axes, y down). - fn toward(self) -> Vec2 { + pub(super) fn toward(self) -> Vec2 { let d = FRAC_1_SQRT_2; match self { Light::Bottom => Vec2::new(0.0, 1.0), diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index 31fdcf908..4580ab013 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -49,11 +49,12 @@ const PIXELATE: [(&str, &str); 4] = [("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")]; /// Effect › Sketch, at their defaults. -const SKETCH: [(&str, &str); 5] = [ +const SKETCH: [(&str, &str); 6] = [ ("sketch.chalkCharcoal", "{}"), ("sketch.chrome", "{}"), ("sketch.graphicPen", "{}"), ("sketch.halftonePattern", "{}"), + ("sketch.plaster", "{}"), ("sketch.reticulation", "{}"), ]; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index d746dfdbe..c8b5be4e6 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -285,6 +285,7 @@ fn choices(effect: &str, key: &str) -> Option { ("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES), ("sketch.graphicPen", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS), ("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES), + ("sketch.plaster", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS), ("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES), ("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES), ("texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS), diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 823b8cf6d..a3bcce45b 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -211,6 +211,7 @@ fn raster_filter_dialogs_preview_and_apply() { ("pixelate.mezzotint", "type", "longLines"), ("pixelate.pointillize", "cellSize", "12"), ("sketch.halftonePattern", "patternType", "line"), + ("sketch.plaster", "lightDirection", "bottomLeft"), ("texture.craquelure", "crackDepth", "9"), ("texture.grain", "grainType", "stippled"), ("texture.mosaicTiles", "tileSize", "20"), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index b5d90df35..0b4aff0fb 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -178,17 +178,19 @@ 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…", "Chrome…", "Graphic Pen…", "Halftone Pattern…", "Reticulation…"]); + assert_eq!(labels, ["Chalk & Charcoal…", "Chrome…", "Graphic Pen…", "Halftone Pattern…", "Plaster…", "Reticulation…"]); assert!(sketch.iter().all(|e| e.command.as_deref() == Some("effect.dialog"))); assert_eq!(sketch[0].params, json!({"effect": "sketch.chalkCharcoal"})); assert_eq!(sketch[1].params, json!({"effect": "sketch.chrome"})); assert_eq!(sketch[2].params, json!({"effect": "sketch.graphicPen"})); assert_eq!(sketch[3].params, json!({"effect": "sketch.halftonePattern"})); - assert_eq!(sketch[4].params, json!({"effect": "sketch.reticulation"})); + assert_eq!(sketch[4].params, json!({"effect": "sketch.plaster"})); + assert_eq!(sketch[5].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("Chrome…")); assert!(at("Chrome…") < at("Graphic Pen…") && at("Graphic Pen…") < at("Halftone Pattern…")); - assert!(at("Halftone Pattern…") < at("Reticulation…") && at("Reticulation…") < at("Glowing Edges…")); + assert!(at("Halftone Pattern…") < at("Plaster…") && at("Plaster…") < at("Reticulation…")); + assert!(at("Reticulation…") < at("Glowing Edges…")); } /// Effect › Stylize under Raster Effects, between Sharpen and Texture: Glowing Edges opens its