mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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:
@@ -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…",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user