Merge pull request #3034 from nrq6993-a11y/stream/vc-feat3-g2-artistic-plastic-wrap

G2: Effect › Artistic › Plastic Wrap raster filter
This commit is contained in:
BFlat
2026-10-20 03:14:22 +07:00
committed by GitHub
6 changed files with 144 additions and 12 deletions
+7
View File
@@ -363,6 +363,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{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…",
+76 -1
View File
@@ -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:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
@@ -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<f32> = 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)
+19 -7
View File
@@ -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<PixelFx> {
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)
+29 -1
View File
@@ -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::<i32>();
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);
+12 -2
View File
@@ -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
+1 -1
View File
@@ -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();