From a2c764d3125472be9b6697f9296a93df0d90c4b6 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Tue, 20 Oct 2026 02:55:29 +0700 Subject: [PATCH] =?UTF-8?q?G2:=20Effect=20=E2=80=BA=20Artistic=20=E2=80=BA?= =?UTF-8?q?=20Plastic=20Wrap=20raster=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #3034 onto the Sketch filters; the Artistic label test lists it. --- crates/effects/src/lib.rs | 7 +++ crates/effects/src/pixel/artistic.rs | 77 +++++++++++++++++++++++- crates/effects/src/pixel/mod.rs | 26 +++++--- crates/effects/src/pixel/tests.rs | 30 ++++++++- crates/engine/src/tests_rasterfilters.rs | 14 ++++- crates/ui-egui/src/tests_labels.rs | 2 +- 6 files changed, 144 insertions(+), 12 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 0b8a19074..303100781 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -363,6 +363,13 @@ pub fn effect_catalog() -> Vec { "{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}), ), + r( + "artistic.plasticWrap", + "Plastic Wrap…", + ARTISTIC, + "{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", + json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}), + ), r( "artistic.posterEdges", "Poster Edges…", diff --git a/crates/effects/src/pixel/artistic.rs b/crates/effects/src/pixel/artistic.rs index f18b1709a..0d6764feb 100644 --- a/crates/effects/src/pixel/artistic.rs +++ b/crates/effects/src/pixel/artistic.rs @@ -1,4 +1,4 @@ -//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). Original filters built from +//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges). Original filters built from //! public behaviour documentation: //! and //! . @@ -6,6 +6,8 @@ use super::edges::sobel; use super::pixelate::{Place, cell, rand}; + +const SALT_PLASTIC: u64 = 512; use super::texture::{luma, premultiply, straight}; use super::{PixelSpace, blur_plane}; use vectorcraft_geom::Point; @@ -239,6 +241,79 @@ pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel } } +/// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15). +pub(super) fn plastic_reach(smoothness: f64) -> f64 { + 3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) +} + +/// Plastic Wrap: the object under shiny plastic. Its tones form a relief; `detail` (0..15) adds +/// finer wrinkles in document space; `smoothness` (1..15) softens the relief; specular highlights +/// as strong as `highlight` (0..20) are screened over it. Coverage is kept. +pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, highlight: f64, detail: f64, smoothness: f64) { + if ![highlight, detail, smoothness].iter().all(|v| v.is_finite()) || !space.px.is_finite() || space.px <= 0.0 { + return; + } + let strength = (highlight / 20.0).clamp(0.0, 1.0) as f32; + if strength == 0.0 { + return; + } + let detail_amt = (detail / 15.0).clamp(0.0, 1.0) as f32; + let sigma = (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) / space.px; + let Some(place) = Place::new(space, w, h) else { return }; + let len = px.len(); + let mut height = vec![0.0f32; len]; + let alpha: Vec = px.iter().map(|p| f32::from(p[3]) / 255.0).collect(); + let cell_scale = 8.0 + 24.0 * (1.0 - detail_amt as f64); + let mut noise = CellNoise::default(); + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let i = y * w + x; + let l = luma(straight(*p)); + let q = place.at(x, y); + let nq = Point::new(q.x / cell_scale, q.y / cell_scale); + height[i] = l + detail_amt * 0.35 * noise.at(SALT_PLASTIC, nq) as f32; + }); + for (h, a) in height.iter_mut().zip(&alpha) { + *h *= *a; + } + blur_plane(&mut height, w, h, sigma); + let mut cov = alpha.clone(); + blur_plane(&mut cov, w, h, sigma); + for (h, c) in height.iter_mut().zip(&cov) { + *h = if *c > 1e-4 { *h / *c } else { 0.0 }; + } + const LIGHT: [f32; 3] = [0.35, -0.25, 0.9]; + let inv = (LIGHT[0] * LIGHT[0] + LIGHT[1] * LIGHT[1] + LIGHT[2] * LIGHT[2]).sqrt().max(1e-6); + let light = LIGHT.map(|v| v / inv); + let scale = (8.0 * space.px).max(1e-6) as f32; + let sample = |x: isize, y: isize| -> f32 { + if x < 0 || y < 0 || x >= w as isize || y >= h as isize { + return 0.0; + } + height[y as usize * w + x as usize] + }; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let gx = sample(x as isize + 1, y as isize) - sample(x as isize - 1, y as isize); + let gy = sample(x as isize, y as isize + 1) - sample(x as isize, y as isize - 1); + let nx = -gx / scale; + let ny = -gy / scale; + let nz = 1.0; + let nlen = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6); + let ndotl = (nx / nlen * light[0] + ny / nlen * light[1] + nz / nlen * light[2]).max(0.0); + let spec = ndotl.powi(12) * strength; + if spec <= 1e-5 { + return; + } + let c = straight(*p).map(|v| 1.0 - (1.0 - v) * (1.0 - spec)); + *p = premultiply(c, f32::from(p[3]) / 255.0); + }); +} + /// How far (document units) Neon Glow's glow spreads at `size` (-24..24). pub(super) fn glow_spread(size: f64) -> f64 { 0.25 * size.abs().min(24.0) diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index a72fd9896..8a24027b9 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,9 +1,9 @@ -//! 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, 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 -//! Pen, Halftone Pattern and Reticulation, Stylize › +//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap 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 › 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. @@ -41,10 +41,11 @@ 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; 35] = [ +pub const PIXEL_EFFECTS: [&str; 36] = [ "artistic.cutout", "artistic.filmGrain", "artistic.neonGlow", + "artistic.plasticWrap", "artistic.posterEdges", "blur.radial", "blur.smart", @@ -93,6 +94,9 @@ pub enum PixelFx { /// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as /// strong as `brightness` (0..50). NeonGlow { size: f64, brightness: f64, colour: [f32; 3] }, + /// Artistic › Plastic Wrap: specular highlights as strong as `highlight` (0..20) on a relief + /// wrinkled by `detail` (0..15) and softened by `smoothness` (1..15). + PlasticWrap { highlight: f64, detail: f64, smoothness: f64 }, /// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black /// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10). PosterEdges { thickness: f64, intensity: f64, posterization: f64 }, @@ -256,6 +260,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0), intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0), }, + "artistic.plasticWrap" => PixelFx::PlasticWrap { + highlight: num(p, "highlightStrength", 20.0).clamp(0.0, 20.0), + detail: num(p, "detail", 12.0).clamp(0.0, 15.0), + smoothness: num(p, "smoothness", 15.0).clamp(1.0, 15.0), + }, "blur.radial" => PixelFx::RadialBlur { amount: num(p, "amount", 10.0).clamp(1.0, 100.0), zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"), @@ -428,6 +437,7 @@ impl PixelFx { PixelFx::FilmGrain { .. } | PixelFx::Cutout { .. } | PixelFx::NeonGlow { .. } + | PixelFx::PlasticWrap { .. } | PixelFx::PosterEdges { .. } | PixelFx::ColorHalftone { .. } | PixelFx::Mezzotint { .. } @@ -478,6 +488,7 @@ impl PixelFx { PixelFx::Cutout { .. } => None, // The softened tones around the pixel. PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)), + PixelFx::PlasticWrap { smoothness, .. } => Some(artistic::plastic_reach(smoothness)), // The edges' lines, spread round them. PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0), // The dots' points, and the softened colour around them. @@ -502,6 +513,7 @@ impl PixelFx { match *self { PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity), PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity), + PixelFx::PlasticWrap { highlight, detail, smoothness } => artistic::plastic_wrap(px, w, h, space, highlight, detail, smoothness), PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour), PixelFx::PosterEdges { thickness, intensity, posterization } => { artistic::poster_edges(px, w, h, space, thickness, intensity, posterization) diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 1c9ad11e1..1ec4f818f 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -2271,7 +2271,15 @@ fn artistic_params_take_defaults_and_stay_in_range() { fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})), PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] } ); - for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] { + assert_eq!( + fx("artistic.plasticWrap", json!({})), + PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 } + ); + assert_eq!( + 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.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] { assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape"); } } @@ -2289,6 +2297,7 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() { let filters = [ PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 }, PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour }, + PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 }, PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 }, ]; for f in &filters { @@ -2319,6 +2328,25 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() { assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}"); } +#[test] +fn plastic_wrap_adds_specular_highlights_and_keeps_coverage() { + let (w, h) = (48, 32); + let src = image(w, h, |x, y| grey((((x + y) * 5) % 200 + 30) as u8)); + let mut none = src.clone(); + fx("artistic.plasticWrap", json!({"highlightStrength": 0})).apply(&mut none, w, h, &space(w, h)); + assert_eq!(none, src); + let mut out = src.clone(); + fx("artistic.plasticWrap", json!({})).apply(&mut out, w, h, &space(w, h)); + assert!(out != src, "defaults brighten some pixels"); + assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3])); + let mut weak = src.clone(); + let mut strong = src.clone(); + fx("artistic.plasticWrap", json!({"highlightStrength": 4})).apply(&mut weak, w, h, &space(w, h)); + fx("artistic.plasticWrap", json!({"highlightStrength": 20})).apply(&mut strong, w, h, &space(w, h)); + let lift = |a: &[u8], b: &[u8]| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).map(|(o, s)| i32::from(o[0]) - i32::from(s[0])).sum::(); + assert!(lift(&strong, &src) > lift(&weak, &src), "stronger highlights lift more"); +} + #[test] fn cutout_paints_a_few_flat_colours_and_keeps_coverage() { let (w, h) = (64, 24); diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index 56b4a1c7c..9b0b0ea97 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -31,8 +31,13 @@ const BRUSH_STROKES: [(&str, &str); 8] = [ ]; /// Effect › Artistic, at their defaults. -const ARTISTIC: [(&str, &str); 4] = - [("artistic.cutout", "{}"), ("artistic.filmGrain", "{}"), ("artistic.neonGlow", "{}"), ("artistic.posterEdges", "{}")]; +const ARTISTIC: [(&str, &str); 5] = [ + ("artistic.cutout", "{}"), + ("artistic.filmGrain", "{}"), + ("artistic.neonGlow", "{}"), + ("artistic.plasticWrap", "{}"), + ("artistic.posterEdges", "{}"), +]; /// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the /// striped art's reds are dark). @@ -196,6 +201,11 @@ fn artistic_settings_survive_a_save_and_open() { json!({"edgeThickness": 4.0, "edgeIntensity": 5.0, "posterization": 1.0}), [json!({"edgeIntensity": 0.0}), json!({"posterization": 6.0})], ), + ( + "artistic.plasticWrap", + json!({"highlightStrength": 18.0, "detail": 10.0, "smoothness": 8.0}), + [json!({"highlightStrength": 5.0}), json!({"highlightStrength": 0.0})], + ), ] { let (mut s, g) = striped(); // Black and a grey beside the reds: Cutout puts the grey with black at 2 levels and apart diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 2679367da..98f23e541 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -86,7 +86,7 @@ fn effect_menu_lists_the_artistic_filters_before_blur() { let entries = menus::menu_entries(&app); let artistic: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Artistic"]).collect(); let labels: Vec<&str> = artistic.iter().map(|e| e.label.as_str()).collect(); - assert_eq!(labels, ["Cutout…", "Film Grain…", "Neon Glow…", "Poster Edges…"]); + assert_eq!(labels, ["Cutout…", "Film Grain…", "Neon Glow…", "Plastic Wrap…", "Poster Edges…"]); assert!(artistic.iter().all(|e| e.command.as_deref() == Some("effect.dialog"))); assert_eq!(artistic[2].params, json!({"effect": "artistic.neonGlow"})); let at = |name: &str| entries.iter().position(|e| e.path == ["Effect", name]).unwrap();