mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Merge pull request #960 from storytold/feat/texture-effects
Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
This commit is contained in:
+9
-7
File diff suppressed because one or more lines are too long
@@ -130,6 +130,10 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
"pixelate.colorHalftone" => always(&["maxRadius"]),
|
||||
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
|
||||
"texture.craquelure" => always(&["crackSpacing"]),
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -144,6 +148,7 @@ const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const TEXTURE: &[&str] = &["Effect", "Texture"];
|
||||
const VIDEO: &[&str] = &["Effect", "Video"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
@@ -332,6 +337,48 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas",
|
||||
json!({"cellSize": 5.0}),
|
||||
),
|
||||
r(
|
||||
"texture.craquelure",
|
||||
"Craquelure…",
|
||||
TEXTURE,
|
||||
"{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones",
|
||||
json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}),
|
||||
),
|
||||
r(
|
||||
"texture.grain",
|
||||
"Grain…",
|
||||
TEXTURE,
|
||||
"{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white",
|
||||
json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}),
|
||||
),
|
||||
r(
|
||||
"texture.mosaicTiles",
|
||||
"Mosaic Tiles…",
|
||||
TEXTURE,
|
||||
"{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them",
|
||||
json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}),
|
||||
),
|
||||
r(
|
||||
"texture.patchwork",
|
||||
"Patchwork…",
|
||||
TEXTURE,
|
||||
"{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left",
|
||||
json!({"squareSize": 4.0, "relief": 8.0}),
|
||||
),
|
||||
r(
|
||||
"texture.stainedGlass",
|
||||
"Stained Glass…",
|
||||
TEXTURE,
|
||||
"{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart",
|
||||
json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}),
|
||||
),
|
||||
r(
|
||||
"texture.texturizer",
|
||||
"Texturizer…",
|
||||
TEXTURE,
|
||||
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief",
|
||||
json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
|
||||
),
|
||||
r(
|
||||
"video.deinterlace",
|
||||
"De-Interlace…",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
|
||||
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing
|
||||
//! Edges, and Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and
|
||||
//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//!
|
||||
//! - Every distance is in document units (points) and becomes pixels through the raster's
|
||||
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
|
||||
@@ -13,17 +14,19 @@ mod blur;
|
||||
mod edges;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{num, text};
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
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; 10] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 16] = [
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"pixelate.colorHalftone",
|
||||
@@ -32,6 +35,12 @@ pub const PIXEL_EFFECTS: [&str; 10] = [
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
"texture.mosaicTiles",
|
||||
"texture.patchwork",
|
||||
"texture.stainedGlass",
|
||||
"texture.texturizer",
|
||||
"video.deinterlace",
|
||||
"video.ntscColors",
|
||||
];
|
||||
@@ -62,6 +71,24 @@ pub enum PixelFx {
|
||||
/// Pixelate › Pointillize: random dots about `cell` across of the object's colours on a white
|
||||
/// canvas.
|
||||
Pointillize { cell: f64 },
|
||||
/// Texture › Craquelure: cracked relief plaster, plates about `spacing` apart, cracks as deep
|
||||
/// as `depth` (0..10), lit as brightly as `brightness` (0..10).
|
||||
Craquelure { spacing: f64, depth: f64, brightness: f64 },
|
||||
/// Texture › Grain: noise of `kind` as strong as `intensity` (0..100), the image's contrast
|
||||
/// set by `contrast` (0..100, 50 leaves it).
|
||||
Grain { intensity: f64, contrast: f64, kind: Grain },
|
||||
/// Texture › Mosaic Tiles: irregular tiles about `tile` across with sunken grout `grout` / 2
|
||||
/// wide (1..15), lightened by `lighten` (0..10).
|
||||
MosaicTiles { tile: f64, grout: f64, lighten: f64 },
|
||||
/// Texture › Patchwork: squares `square` across of the colour around their centres, raised by
|
||||
/// up to `relief` (0..25).
|
||||
Patchwork { square: f64, relief: f64 },
|
||||
/// Texture › Stained Glass: panes about `cell` across leaded `border` / 2 wide (1..20) in
|
||||
/// black, lit at the centre by `light` (0..10).
|
||||
StainedGlass { cell: f64, border: f64, light: f64 },
|
||||
/// Texture › Texturizer: a `texture` surface scaled by `scaling` (0.5..2), in relief as strong
|
||||
/// as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
|
||||
Texturizer { texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||
/// Video › De-Interlace: the odd (or `even`) field lines made again from the others, by
|
||||
/// duplication or, with `interpolate`, by averaging.
|
||||
DeInterlace { even: bool, interpolate: bool },
|
||||
@@ -100,6 +127,9 @@ pub struct PixelSpace {
|
||||
/// The height of a line of the document's raster grid (document units: 72 / Document Raster
|
||||
/// Effects Settings › Resolution), the fields De-Interlace works on.
|
||||
pub line: f64,
|
||||
/// How far the corners of the object's bounds lie from its centre (document units): Stained
|
||||
/// Glass's light fades out over it.
|
||||
pub extent: f64,
|
||||
}
|
||||
|
||||
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
|
||||
@@ -137,6 +167,36 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
"pixelate.crystallize" => PixelFx::Crystallize { cell: num(p, "cellSize", 10.0).clamp(3.0, 300.0) },
|
||||
"pixelate.mezzotint" => PixelFx::Mezzotint { kind: Mezzotint::parse(text(p, "type", "fineDots")) },
|
||||
"pixelate.pointillize" => PixelFx::Pointillize { cell: num(p, "cellSize", 5.0).clamp(3.0, 300.0) },
|
||||
"texture.craquelure" => PixelFx::Craquelure {
|
||||
spacing: num(p, "crackSpacing", 15.0).clamp(2.0, 100.0),
|
||||
depth: num(p, "crackDepth", 6.0).clamp(0.0, 10.0),
|
||||
brightness: num(p, "crackBrightness", 9.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.grain" => PixelFx::Grain {
|
||||
intensity: num(p, "intensity", 40.0).clamp(0.0, 100.0),
|
||||
contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0),
|
||||
kind: Grain::parse(text(p, "grainType", "regular")),
|
||||
},
|
||||
"texture.mosaicTiles" => PixelFx::MosaicTiles {
|
||||
tile: num(p, "tileSize", 12.0).clamp(2.0, 100.0),
|
||||
grout: num(p, "groutWidth", 3.0).clamp(1.0, 15.0),
|
||||
lighten: num(p, "lightenGrout", 9.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.patchwork" => {
|
||||
PixelFx::Patchwork { square: num(p, "squareSize", 4.0).clamp(0.0, 10.0), relief: num(p, "relief", 8.0).clamp(0.0, 25.0) }
|
||||
}
|
||||
"texture.stainedGlass" => PixelFx::StainedGlass {
|
||||
cell: num(p, "cellSize", 10.0).clamp(2.0, 50.0),
|
||||
border: num(p, "borderThickness", 4.0).clamp(1.0, 20.0),
|
||||
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"texture.texturizer" => PixelFx::Texturizer {
|
||||
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
"video.deinterlace" => PixelFx::DeInterlace {
|
||||
even: text(p, "eliminate", "odd").eq_ignore_ascii_case("even"),
|
||||
interpolate: text(p, "create", "duplication").eq_ignore_ascii_case("interpolation"),
|
||||
@@ -159,9 +219,18 @@ impl PixelFx {
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::ColorHalftone { .. } | PixelFx::Mezzotint { .. } | PixelFx::DeInterlace { .. } | PixelFx::NtscColors => 0.0,
|
||||
// A crystal's or dot's point inside the object reaches out by up to its cell.
|
||||
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } => 1.5 * cell,
|
||||
PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::Craquelure { .. }
|
||||
| PixelFx::Grain { .. }
|
||||
| PixelFx::MosaicTiles { .. }
|
||||
| PixelFx::Texturizer { .. }
|
||||
| PixelFx::DeInterlace { .. }
|
||||
| PixelFx::NtscColors => 0.0,
|
||||
// A crystal's, dot's or pane's point inside the object reaches out by up to its cell.
|
||||
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } | PixelFx::StainedGlass { cell, .. } => 1.5 * cell,
|
||||
// A square whose centre is on the object is drawn whole.
|
||||
PixelFx::Patchwork { square, .. } => square.max(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +248,12 @@ impl PixelFx {
|
||||
PixelFx::Mezzotint { .. } => Some(0.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
PixelFx::Craquelure { spacing, .. } => Some(3.0 * texture::CONTOUR_SOFTNESS * spacing),
|
||||
PixelFx::Grain { .. } | PixelFx::MosaicTiles { .. } | PixelFx::Texturizer { .. } => Some(0.0),
|
||||
// The square's or pane's point, and the softened colour around it.
|
||||
PixelFx::Patchwork { square, .. } => Some(2.0 * square.max(1.0)),
|
||||
PixelFx::StainedGlass { cell, .. } => Some(2.5 * cell),
|
||||
// The lines above and below.
|
||||
PixelFx::DeInterlace { .. } => Some(video::MAX_LINE),
|
||||
PixelFx::NtscColors => Some(0.0),
|
||||
@@ -199,12 +274,26 @@ impl PixelFx {
|
||||
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
|
||||
PixelFx::Mezzotint { kind } => pixelate::mezzotint(px, w, h, space, kind),
|
||||
PixelFx::Pointillize { cell } => pixelate::pointillize(px, w, h, space, cell),
|
||||
PixelFx::Craquelure { spacing, depth, brightness } => texture::craquelure(px, w, h, space, spacing, depth, brightness),
|
||||
PixelFx::Grain { intensity, contrast, kind } => texture::grain(px, w, h, space, intensity, contrast, kind),
|
||||
PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten),
|
||||
PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief),
|
||||
PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light),
|
||||
PixelFx::Texturizer { texture, scaling, relief, light, invert } => {
|
||||
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
|
||||
}
|
||||
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
|
||||
PixelFx::NtscColors => video::ntsc(px),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
|
||||
/// otherwise.
|
||||
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
|
||||
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
|
||||
}
|
||||
|
||||
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
|
||||
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::OnceLock;
|
||||
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::{Channels, PixelSpace, gaussian, to16};
|
||||
use super::{Channels, PixelSpace, Px16, gaussian, pick, to16};
|
||||
use crate::util::noise;
|
||||
|
||||
/// Mezzotint's patterns, in its Type menu's order: (label, parameter value).
|
||||
@@ -62,7 +62,7 @@ impl Mezzotint {
|
||||
|
||||
/// The pattern of Type `value` (a [`MEZZOTINT_TYPES`] value, any case); Fine Dots otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
MEZZOTINT_TYPES.iter().zip(MEZZOTINT_KINDS).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(Self::FINE_DOTS, |(_, kind)| kind)
|
||||
pick(&MEZZOTINT_TYPES, &MEZZOTINT_KINDS, value, Self::FINE_DOTS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,48 +70,48 @@ impl Mezzotint {
|
||||
const SEED: u64 = 0x5049_5845_4C41_5445;
|
||||
|
||||
/// A random number in [0, 1) for cell (`i`, `j`) of pattern `salt`; `k` picks one of several.
|
||||
fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
|
||||
pub(super) fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
|
||||
0.5 * (noise(SEED ^ salt, i as u64, j as u64, k) + 1.0)
|
||||
}
|
||||
|
||||
/// The cell holding coordinate `v` (in cells); `None` far beyond any raster (or not finite).
|
||||
fn cell(v: f64) -> Option<i64> {
|
||||
pub(super) fn cell(v: f64) -> Option<i64> {
|
||||
let f = v.floor();
|
||||
(f.abs() < 1e15).then_some(f as i64)
|
||||
}
|
||||
|
||||
/// Where a raster's pixels lie relative to the object's centre, in document units.
|
||||
struct Place {
|
||||
pub(super) struct Place {
|
||||
/// Pixel coordinates → document coordinates relative to the centre.
|
||||
to_rel: Affine,
|
||||
pub(super) to_rel: Affine,
|
||||
/// The other way.
|
||||
to_px: Affine,
|
||||
pub(super) to_px: Affine,
|
||||
/// A pixel's size in document units.
|
||||
px: f64,
|
||||
pub(super) px: f64,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
|
||||
pub(super) fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
|
||||
let to_rel = Affine::translate(-space.center.to_vec2()) * space.to_doc;
|
||||
let to_px = to_rel.inverse();
|
||||
(to_rel.is_finite() && to_px.is_finite() && space.px.is_finite() && space.px > 0.0).then_some(Self { to_rel, to_px, px: space.px, w, h })
|
||||
}
|
||||
|
||||
/// The centre of pixel (`x`, `y`).
|
||||
fn at(&self, x: usize, y: usize) -> Point {
|
||||
pub(super) fn at(&self, x: usize, y: usize) -> Point {
|
||||
self.to_rel * Point::new(x as f64 + 0.5, y as f64 + 0.5)
|
||||
}
|
||||
|
||||
/// The index of the pixel holding point `p`; `None` outside the raster.
|
||||
fn index(&self, p: Point) -> Option<usize> {
|
||||
pub(super) fn index(&self, p: Point) -> Option<usize> {
|
||||
let q = self.to_px * p;
|
||||
(q.x >= 0.0 && q.y >= 0.0 && q.x < self.w as f64 && q.y < self.h as f64).then(|| q.y as usize * self.w + q.x as usize)
|
||||
}
|
||||
|
||||
/// Run `f(x, y, pixel)` on every pixel.
|
||||
fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
|
||||
pub(super) fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
|
||||
for (y, row) in px.chunks_exact_mut(self.w.max(1)).enumerate().take(self.h) {
|
||||
for (x, p) in row.iter_mut().enumerate() {
|
||||
f(x, y, p);
|
||||
@@ -122,7 +122,7 @@ impl Place {
|
||||
|
||||
/// The cells around a pixel's cell, in the order they can hold its nearest random point: the
|
||||
/// cell itself, its 8 neighbours, then the ring around them.
|
||||
const AROUND: [(i64, i64); 25] = [
|
||||
pub(super) const AROUND: [(i64, i64); 25] = [
|
||||
(0, 0),
|
||||
(-1, 0),
|
||||
(1, 0),
|
||||
@@ -152,18 +152,18 @@ const AROUND: [(i64, i64); 25] = [
|
||||
|
||||
/// What the cells [`AROUND`] one cell hold, made when first asked for and kept while consecutive
|
||||
/// pixels stay in that cell.
|
||||
struct Around<T> {
|
||||
pub(super) struct Around<T> {
|
||||
cell: Option<(i64, i64)>,
|
||||
items: [Option<T>; 25],
|
||||
}
|
||||
|
||||
impl<T: Copy> Around<T> {
|
||||
fn new() -> Self {
|
||||
pub(super) fn new() -> Self {
|
||||
Self { cell: None, items: [None; 25] }
|
||||
}
|
||||
|
||||
/// Item `k` of [`AROUND`] cell (`i`, `j`): `make(cell i', j')`.
|
||||
fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
|
||||
pub(super) fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
|
||||
if self.cell != Some((i, j)) {
|
||||
self.cell = Some((i, j));
|
||||
self.items = [None; 25];
|
||||
@@ -175,10 +175,26 @@ impl<T: Copy> Around<T> {
|
||||
}
|
||||
|
||||
/// The random point of cell (`i`, `j`) of pattern `salt`, in cells.
|
||||
fn point_in(salt: u64, i: i64, j: i64) -> Point {
|
||||
pub(super) fn point_in(salt: u64, i: i64, j: i64) -> Point {
|
||||
Point::new(i as f64 + rand(salt, i, j, 0), j as f64 + rand(salt, i, j, 1))
|
||||
}
|
||||
|
||||
/// How far cell `c` lies from coordinate `t` (in cells) along one axis.
|
||||
pub(super) fn gap(c: i64, t: f64) -> f64 {
|
||||
(c as f64 - t).max(t - c as f64 - 1.0).max(0.0)
|
||||
}
|
||||
|
||||
/// Smooth random values in [0, 1) of pattern `salt` at whole coordinates, blended between them.
|
||||
pub(super) fn value_noise(salt: u64, x: f64, y: f64) -> f64 {
|
||||
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
|
||||
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
|
||||
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
|
||||
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
|
||||
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
|
||||
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
|
||||
top + (bottom - top) * fy
|
||||
}
|
||||
|
||||
/// Crystallize: every pixel takes the colour at the nearest of one random point per `cell` ×
|
||||
/// `cell` square (a Voronoi diagram over a jittered grid): polygon crystals of solid colour.
|
||||
pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64) {
|
||||
@@ -188,8 +204,6 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
|
||||
}
|
||||
let src = px.to_vec();
|
||||
let mut around = Around::new();
|
||||
// How far cell `c` lies from coordinate `t` (in cells) along one axis.
|
||||
let gap = |c: i64, t: f64| (c as f64 - t).max(t - c as f64 - 1.0).max(0.0);
|
||||
place.each(px, |x, y, out| {
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / cell_size, q.y / cell_size);
|
||||
@@ -215,6 +229,34 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
|
||||
});
|
||||
}
|
||||
|
||||
/// The object's colours softened by a Gaussian of `sigma` pixels, with its coverage as it is: what
|
||||
/// the cell filters (Pointillize, Patchwork, Stained Glass) fill their cells with.
|
||||
pub(super) struct Soft {
|
||||
colour: Vec<Px16>,
|
||||
alpha: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Soft {
|
||||
pub(super) fn new(px: &[[u8; 4]], w: usize, sigma: f64) -> Self {
|
||||
let mut colour = to16(px);
|
||||
gaussian(&mut colour, w, sigma);
|
||||
Self { colour, alpha: px.iter().map(|p| p[3]).collect() }
|
||||
}
|
||||
|
||||
/// The colour around pixel `k` (premultiplied, 0..1), as opaque as the object is at `k`; clear
|
||||
/// outside the raster.
|
||||
pub(super) fn at(&self, k: Option<usize>) -> [f32; 4] {
|
||||
let a = k.and_then(|k| self.alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
|
||||
match k.and_then(|k| self.colour.get(k)) {
|
||||
Some(p) if p[3] > 0 => {
|
||||
let s = a / f32::from(p[3]);
|
||||
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
|
||||
}
|
||||
_ => [0.0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of Pointillize's dots: where (in cells), its radius (document units), its stacking order
|
||||
/// and its colour (premultiplied, 0..1).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -237,20 +279,10 @@ pub(super) fn pointillize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
|
||||
}
|
||||
// Each dot takes the colour around its centre, not of a single pixel there, and is as opaque
|
||||
// as the object at its centre (whole dots on the object, none beside it).
|
||||
let alpha: Vec<u8> = px.iter().map(|p| p[3]).collect();
|
||||
let mut soft = to16(px);
|
||||
gaussian(&mut soft, w, cell_size / place.px / 4.0);
|
||||
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
|
||||
let dot = |i: i64, j: i64| {
|
||||
let at = point_in(2, i, j);
|
||||
let k = place.index(Point::new(at.x * cell_size, at.y * cell_size));
|
||||
let a = k.and_then(|k| alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
|
||||
let colour = match k.and_then(|k| soft.get(k)) {
|
||||
Some(p) if p[3] > 0 => {
|
||||
let s = a / f32::from(p[3]);
|
||||
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
|
||||
}
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
let colour = soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
|
||||
Dot { at, radius: (DOT_RADIUS.0 + DOT_RADIUS.1 * rand(2, i, j, 2)) * cell_size, z: rand(2, i, j, 3), colour }
|
||||
};
|
||||
let mut around = Around::new();
|
||||
@@ -499,19 +531,6 @@ pub(super) fn mezzotint(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
|
||||
/// Mezzotint's threshold in [0, 1) at point `q` (document units around the centre) of pattern
|
||||
/// `salt`.
|
||||
fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
|
||||
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
|
||||
// Random values at whole coordinates, blended smoothly between them along x (and y).
|
||||
let blend = |x: f64, y: f64, along_y: bool| {
|
||||
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
|
||||
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
|
||||
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
|
||||
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
|
||||
if !along_y {
|
||||
return top;
|
||||
}
|
||||
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
|
||||
top + (bottom - top) * fy
|
||||
};
|
||||
let grain = |size: f64| match (cell(q.x / size), cell(q.y / size)) {
|
||||
(Some(i), Some(j)) => rand(salt, i, j, 1),
|
||||
_ => 0.5,
|
||||
@@ -520,12 +539,10 @@ fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
|
||||
Mezzotint::Dots { size, smooth: false, .. } => grain(f64::from(size)),
|
||||
Mezzotint::Dots { size, grainy, .. } => {
|
||||
let s = f64::from(size);
|
||||
let blob = blend(q.x / s, q.y / s, true);
|
||||
let blob = value_noise(salt, q.x / s, q.y / s);
|
||||
if grainy { 0.5 * (blob + grain(1.0)) } else { blob }
|
||||
}
|
||||
Mezzotint::Lines { length, width } => {
|
||||
let row = (q.y / f64::from(width)).floor();
|
||||
blend(q.x / f64::from(length), row, false)
|
||||
}
|
||||
// Blended along x only: whole rows.
|
||||
Mezzotint::Lines { length, width } => value_noise(salt, q.x / f64::from(length), (q.y / f64::from(width)).floor()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,17 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left).
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0), channels: Channels::Rgb, line: 1.0 }
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(w / 2.0, h / 2.0),
|
||||
channels: Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.5 * w.hypot(h),
|
||||
}
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
@@ -224,8 +232,14 @@ fn lengths_are_document_units() {
|
||||
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
|
||||
let run = |scale: usize| {
|
||||
let mut d = edge(scale);
|
||||
let s =
|
||||
PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO, channels: Channels::Rgb, line: 1.0 };
|
||||
let s = PixelSpace {
|
||||
to_doc: Affine::scale(1.0 / scale as f64),
|
||||
px: 1.0 / scale as f64,
|
||||
center: Point::ZERO,
|
||||
channels: Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: 0.0,
|
||||
};
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
|
||||
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
|
||||
};
|
||||
@@ -254,7 +268,7 @@ fn gaussian_and_plane_blurs_keep_their_mass() {
|
||||
fn pixel_timings() {
|
||||
let (w, h) = (2000, 2000);
|
||||
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
|
||||
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0 };
|
||||
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0, extent: 0.0 };
|
||||
let cases = [
|
||||
("blur.radial", json!({"quality": "draft"})),
|
||||
("blur.radial", json!({"quality": "good"})),
|
||||
@@ -268,6 +282,14 @@ fn pixel_timings() {
|
||||
("pixelate.mezzotint", json!({"type": "grainyDots"})),
|
||||
("pixelate.pointillize", json!({"cellSize": 3})),
|
||||
("pixelate.pointillize", json!({"cellSize": 300})),
|
||||
("texture.craquelure", json!({"crackSpacing": 2})),
|
||||
("texture.craquelure", json!({"crackSpacing": 100})),
|
||||
("texture.grain", json!({"grainType": "clumped"})),
|
||||
("texture.mosaicTiles", json!({"tileSize": 2})),
|
||||
("texture.patchwork", json!({"squareSize": 0})),
|
||||
("texture.stainedGlass", json!({"cellSize": 2})),
|
||||
("texture.texturizer", json!({"texture": "sandstone", "scaling": 50})),
|
||||
("texture.texturizer", json!({"texture": "burlap"})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let mut d = src.clone();
|
||||
@@ -525,3 +547,282 @@ fn ntsc_colors_tame_only_colours_too_strong_for_the_signal() {
|
||||
assert!((luma(a) - luma(b)).abs() < 3.0, "{x}: as bright {a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("texture.craquelure", json!({})), PixelFx::Craquelure { spacing: 15.0, depth: 6.0, brightness: 9.0 });
|
||||
assert_eq!(
|
||||
fx("texture.craquelure", json!({"crackSpacing": 1e9, "crackDepth": -1, "crackBrightness": "x"})),
|
||||
PixelFx::Craquelure { spacing: 100.0, depth: 0.0, brightness: 9.0 }
|
||||
);
|
||||
assert_eq!(fx("texture.grain", json!({})), PixelFx::Grain { intensity: 40.0, contrast: 50.0, kind: Grain::Regular });
|
||||
assert_eq!(
|
||||
fx("texture.grain", json!({"intensity": 1e300, "contrast": -5, "grainType": "SPECKLE"})),
|
||||
PixelFx::Grain { intensity: 100.0, contrast: 0.0, kind: Grain::Speckle }
|
||||
);
|
||||
assert_eq!(fx("texture.mosaicTiles", json!({})), PixelFx::MosaicTiles { tile: 12.0, grout: 3.0, lighten: 9.0 });
|
||||
assert_eq!(
|
||||
fx("texture.mosaicTiles", json!({"tileSize": 0, "groutWidth": 99, "lightenGrout": 11})),
|
||||
PixelFx::MosaicTiles { tile: 2.0, grout: 15.0, lighten: 10.0 }
|
||||
);
|
||||
assert_eq!(fx("texture.patchwork", json!({})), PixelFx::Patchwork { square: 4.0, relief: 8.0 });
|
||||
assert_eq!(fx("texture.patchwork", json!({"squareSize": -1, "relief": 26})), PixelFx::Patchwork { square: 0.0, relief: 25.0 });
|
||||
assert_eq!(fx("texture.stainedGlass", json!({})), PixelFx::StainedGlass { cell: 10.0, border: 4.0, light: 3.0 });
|
||||
assert_eq!(
|
||||
fx("texture.stainedGlass", json!({"cellSize": 1, "borderThickness": 21, "lightIntensity": "1e999"})),
|
||||
PixelFx::StainedGlass { cell: 2.0, border: 20.0, light: 3.0 }
|
||||
);
|
||||
let canvas = PixelFx::Texturizer { texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false };
|
||||
assert_eq!(fx("texture.texturizer", json!({})), canvas);
|
||||
assert_eq!(
|
||||
fx("texture.texturizer", json!({"texture": "Brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true})),
|
||||
PixelFx::Texturizer { texture: Texture::Brick, scaling: 0.5, relief: 50.0, light: Light::BottomRight, invert: true }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("texture.texturizer", json!({"texture": 3, "lightDirection": "up", "scaling": 1e9})),
|
||||
PixelFx::Texturizer { texture: Texture::Canvas, scaling: 2.0, relief: 4.0, light: Light::Top, invert: false }
|
||||
);
|
||||
assert_eq!(fx("texture.grain", json!({"grainType": ["x"]})), fx("texture.grain", json!({})));
|
||||
// Every menu value is documented in the catalogue and parses to a choice of its own.
|
||||
for (id, key, table) in [
|
||||
("texture.grain", "grainType", GRAIN_TYPES.as_slice()),
|
||||
("texture.texturizer", "texture", TEXTURES.as_slice()),
|
||||
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||
] {
|
||||
let doc = crate::effect_info(id).unwrap().params;
|
||||
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
|
||||
for (i, (_, v)) in table.iter().enumerate() {
|
||||
assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}");
|
||||
assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}");
|
||||
}
|
||||
}
|
||||
// Panes and squares reach beyond the object; the surfaces stay on it.
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
assert_eq!(fx("texture.stainedGlass", json!({"cellSize": 20})).outset(b), 30.0);
|
||||
assert_eq!(fx("texture.patchwork", json!({"squareSize": 0})).outset(b), 1.0);
|
||||
for id in ["texture.craquelure", "texture.grain", "texture.mosaicTiles", "texture.texturizer"] {
|
||||
assert_eq!(fx(id, json!({})).outset(b), 0.0, "{id}");
|
||||
}
|
||||
assert_eq!(fx("texture.grain", json!({})).reach(), Some(0.0));
|
||||
assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(), Some(6.0));
|
||||
}
|
||||
|
||||
/// The pixels of `d` (`w` wide) in the square `lo..hi` on both axes.
|
||||
fn square(d: &[u8], w: usize, lo: usize, hi: usize) -> Vec<[u8; 4]> {
|
||||
(lo..hi).flat_map(|y| (lo..hi).map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect()
|
||||
}
|
||||
|
||||
/// A `w`-square raster of one colour.
|
||||
fn flat(w: usize, c: [u8; 4]) -> Vec<u8> {
|
||||
image(w, w, |_, _| c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craquelure_cracks_plates_and_contours() {
|
||||
let w = 96;
|
||||
let tan = [180, 150, 110, 255];
|
||||
let mut d = flat(w, tan);
|
||||
fx("texture.craquelure", json!({"crackBrightness": 10})).apply(&mut d, w, w, &space(w, w));
|
||||
let px = square(&d, w, 16, 80);
|
||||
let dark = px.iter().filter(|p| p[0] < 120).count() as f64 / px.len() as f64;
|
||||
assert!((0.03..0.4).contains(&dark), "a network of dark cracks: {dark}");
|
||||
assert!(px.iter().all(|p| p[3] == 255 && p[0] >= p[1] && p[1] >= p[2]), "the colour stays, darkened or lit");
|
||||
assert!(px.iter().any(|p| p[0] > 182), "plate edges catch the light");
|
||||
// Without depth there are no cracks and no relief; brightness lights the surface.
|
||||
let mut flat_lit = flat(w, tan);
|
||||
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 10})).apply(&mut flat_lit, w, w, &space(w, w));
|
||||
assert_eq!(flat_lit, flat(w, tan));
|
||||
let mut dim = flat(w, tan);
|
||||
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 0})).apply(&mut dim, w, w, &space(w, w));
|
||||
assert_eq!(at(&dim, w, 40, 40), [90, 75, 55, 255]);
|
||||
// A dark half and a light half: a crack follows the contour between them, straight down (a
|
||||
// dark line between lighter pixels in nearly every row), where one tone has none.
|
||||
let craquelure = fx("texture.craquelure", json!({"crackSpacing": 100, "crackBrightness": 10}));
|
||||
let rows_cracked = |src: Vec<u8>| {
|
||||
let mut d = src;
|
||||
craquelure.apply(&mut d, w, w, &space(w, w));
|
||||
let v = |x: usize, y: usize| i32::from(at(&d, w, x, y)[0]);
|
||||
(20..76).filter(|y| (26..35).any(|x| v(x, *y) + 8 < v(x - 2, *y) && v(x, *y) + 8 < v(x + 2, *y))).count()
|
||||
};
|
||||
let (contour, plain) = (rows_cracked(image(w, w, |x, _| grey(if x < 30 { 60 } else { 200 }))), rows_cracked(flat(w, grey(200))));
|
||||
assert!(contour >= 48 && contour >= plain + 25, "{contour} rows cracked along the contour, {plain} without one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grain_types_each_add_their_own_noise() {
|
||||
let w = 48;
|
||||
let mid = grey(128);
|
||||
let mut seen: Vec<Vec<u8>> = Vec::new();
|
||||
for (_, kind) in GRAIN_TYPES {
|
||||
let mut d = flat(w, mid);
|
||||
fx("texture.grain", json!({ "grainType": kind })).apply(&mut d, w, w, &space(w, w));
|
||||
assert_ne!(d, flat(w, mid), "{kind} adds grain");
|
||||
assert!(d.as_chunks::<4>().0.iter().all(|p| p[3] == 255), "{kind}: coverage stays");
|
||||
assert!(!seen.contains(&d), "{kind}: a grain of its own");
|
||||
seen.push(d);
|
||||
}
|
||||
// Regular grain is 1-point grains of every colour around the image's own.
|
||||
let mut d = flat(w, mid);
|
||||
fx("texture.grain", json!({})).apply(&mut d, w, w, &space(w, w));
|
||||
let px = d.as_chunks::<4>().0;
|
||||
let mean = px.iter().map(|p| f64::from(p[0])).sum::<f64>() / px.len() as f64;
|
||||
assert!((mean - 128.0).abs() < 8.0, "{mean}");
|
||||
assert!(px.iter().any(|p| p[0] != p[1]), "coloured grains");
|
||||
// Horizontal grain runs in streaks along the rows.
|
||||
let mut d = flat(w, mid);
|
||||
fx("texture.grain", json!({"grainType": "horizontal", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
|
||||
let along = (0..w - 1).map(|x| u32::from(at(&d, w, x, 10)[0].abs_diff(at(&d, w, x + 1, 10)[0]))).sum::<u32>();
|
||||
let across = (0..w - 1).map(|y| u32::from(at(&d, w, 10, y)[0].abs_diff(at(&d, w, 10, y + 1)[0]))).sum::<u32>();
|
||||
assert!(along * 3 < across, "{along} along, {across} across");
|
||||
// No intensity at neutral contrast leaves the image; contrast spreads its tones.
|
||||
let ramp = image(w, w, |x, _| grey((x * 5) as u8));
|
||||
let mut d = ramp.clone();
|
||||
fx("texture.grain", json!({"intensity": 0})).apply(&mut d, w, w, &space(w, w));
|
||||
assert_eq!(d, ramp);
|
||||
fx("texture.grain", json!({"intensity": 0, "contrast": 100})).apply(&mut d, w, w, &space(w, w));
|
||||
assert!(at(&d, w, 2, 0)[0] < at(&ramp, w, 2, 0)[0] && at(&d, w, 45, 0)[0] > at(&ramp, w, 45, 0)[0]);
|
||||
// Sprinkles are the background colour, white.
|
||||
let mut d = flat(w, [0, 0, 160, 255]);
|
||||
fx("texture.grain", json!({"grainType": "sprinkles", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
|
||||
assert!(d.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[2] > 240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mosaic_tiles_lay_tiles_in_light_grout() {
|
||||
let w = 96;
|
||||
let blue = [40, 60, 160, 255];
|
||||
let mut d = flat(w, blue);
|
||||
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
|
||||
let px = square(&d, w, 8, 88);
|
||||
let grout = px.iter().filter(|p| p[0] > 170).count() as f64 / px.len() as f64;
|
||||
assert!((0.08..0.5).contains(&grout), "light grout between the tiles: {grout}");
|
||||
assert!(px.iter().filter(|p| **p == blue).count() > px.len() / 10, "tile faces keep the colour");
|
||||
// Dark grout without lightening.
|
||||
let mut d = flat(w, blue);
|
||||
fx("texture.mosaicTiles", json!({"lightenGrout": 0})).apply(&mut d, w, w, &space(w, w));
|
||||
let px = square(&d, w, 8, 88);
|
||||
let grout = px.iter().filter(|p| p[2] < 100).count() as f64 / px.len() as f64;
|
||||
assert!((0.08..0.5).contains(&grout) && px.iter().all(|p| p[0] < 170), "dark grout: {grout}");
|
||||
// Transparency around the object stays.
|
||||
let mut d = image(w, w, |x, _| if x < 48 { blue } else { [0; 4] });
|
||||
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
|
||||
assert!((0..w).all(|y| (48..w).all(|x| at(&d, w, x, y) == [0; 4])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patchwork_fills_squares_with_one_colour_in_relief() {
|
||||
let w = 64;
|
||||
let src = image(w, w, |x, y| [(x * 4) as u8, (y * 4) as u8, 90, 255]);
|
||||
let mut d = src.clone();
|
||||
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
|
||||
// Squares of 8 points (the object's centre is a corner), each one colour.
|
||||
for (i, j) in [(1, 1), (2, 5), (6, 3)] {
|
||||
let first = at(&d, w, 8 * i, 8 * j);
|
||||
assert!((0..8).all(|y| (0..8).all(|x| at(&d, w, 8 * i + x, 8 * j + y) == first)), "square {i} {j}");
|
||||
}
|
||||
assert_ne!(at(&d, w, 8, 8), at(&d, w, 16, 8));
|
||||
// In relief, the squares' sides catch the light from the top left and fall into shade.
|
||||
let mut lit = src.clone();
|
||||
fx("texture.patchwork", json!({"squareSize": 8, "relief": 25})).apply(&mut lit, w, w, &space(w, w));
|
||||
let (face, top, bottom) = (at(&lit, w, 28, 28)[1], at(&lit, w, 28, 24)[1], at(&lit, w, 28, 31)[1]);
|
||||
assert!(top > face && bottom < face, "{top} {face} {bottom}");
|
||||
// Squares around the object's edge are drawn whole.
|
||||
let mut d = image(w, w, |x, _| if x < 30 { [200, 0, 0, 255] } else { [0; 4] });
|
||||
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
|
||||
assert!((0..w).all(|y| at(&d, w, 31, y)[3] == 255 && at(&d, w, 33, y)[3] == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stained_glass_leads_panes_and_lights_the_centre() {
|
||||
let w = 96;
|
||||
let src = image(w, w, |x, y| [(40 + x) as u8, (40 + y) as u8, 120, 255]);
|
||||
let mut d = src.clone();
|
||||
fx("texture.stainedGlass", json!({"lightIntensity": 0})).apply(&mut d, w, w, &space(w, w));
|
||||
let px = square(&d, w, 16, 80);
|
||||
let lead = px.iter().filter(|p| p[0] == 0 && p[1] == 0 && p[2] == 0).count() as f64 / px.len() as f64;
|
||||
assert!((0.1..0.6).contains(&lead), "black lead: {lead}");
|
||||
let same = (16..80).flat_map(|y| (16..79).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) == at(&d, w, x + 1, *y)).count();
|
||||
assert!(same * 5 > 2 * 64 * 63, "single-coloured panes: {same}");
|
||||
assert!(px.iter().all(|p| p[3] == 255));
|
||||
// The light shines through the middle, not the corners.
|
||||
let mut lit = src.clone();
|
||||
fx("texture.stainedGlass", json!({"lightIntensity": 10})).apply(&mut lit, w, w, &space(w, w));
|
||||
let brighter = |x: usize, y: usize| i32::from(at(&lit, w, x, y)[2]) - i32::from(at(&d, w, x, y)[2]);
|
||||
let centre = (44..52).flat_map(|y| (44..52).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
|
||||
let corner = (0..6).flat_map(|y| (0..6).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
|
||||
assert!(centre > 40 && corner * 3 < centre, "{centre} at the centre, {corner} in the corner");
|
||||
// Panes beyond the object stay clear; lead outlines it.
|
||||
let mut d = image(w, w, |x, y| if (24..72).contains(&x) && (24..72).contains(&y) { [200, 40, 40, 255] } else { [0; 4] });
|
||||
fx("texture.stainedGlass", json!({})).apply(&mut d, w, w, &space(w, w));
|
||||
assert!((0..6).all(|y| (0..w).all(|x| at(&d, w, x, y) == [0; 4])));
|
||||
assert!(d.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texturizer_lights_each_surface_from_its_side() {
|
||||
let w = 64;
|
||||
let mid = grey(150);
|
||||
let run = |p: serde_json::Value| {
|
||||
let mut d = flat(w, mid);
|
||||
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
|
||||
d
|
||||
};
|
||||
let mut seen: Vec<Vec<u8>> = Vec::new();
|
||||
for (_, texture) in TEXTURES {
|
||||
let d = run(json!({ "texture": texture, "relief": 20 }));
|
||||
assert_ne!(d, flat(w, mid), "{texture}");
|
||||
assert!(!seen.contains(&d), "{texture}: a surface of its own");
|
||||
// Inverted, the lit slopes are the shaded ones, and the other way round.
|
||||
let inv = run(json!({ "texture": texture, "relief": 20, "invert": true }));
|
||||
let shift = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| i32::from(p[0]) - 150).collect::<Vec<_>>();
|
||||
let (a, b) = (shift(&d), shift(&inv));
|
||||
let opposite = a.iter().zip(&b).filter(|(a, b)| a.abs() > 4 && b.abs() > 4 && a.signum() != b.signum()).count();
|
||||
let lit_or_shaded = a.iter().filter(|a| a.abs() > 4).count();
|
||||
assert!(lit_or_shaded > 100 && opposite * 10 > lit_or_shaded * 8, "{texture}: {opposite} of {lit_or_shaded}");
|
||||
// From the other side as well.
|
||||
assert_ne!(run(json!({ "texture": texture, "relief": 20, "lightDirection": "bottom" })), d, "{texture}");
|
||||
// No relief, no change.
|
||||
assert_eq!(run(json!({ "texture": texture, "relief": 0 })), flat(w, mid), "{texture}");
|
||||
seen.push(d);
|
||||
}
|
||||
// Scaling enlarges the surface: 200 % at 2 points a pixel draws as 100 % at 1.
|
||||
let mut big = flat(w, mid);
|
||||
let coarse = PixelSpace { to_doc: Affine::scale(2.0), px: 2.0, center: Point::new(64.0, 64.0), ..space(w, w) };
|
||||
fx("texture.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 20})).apply(&mut big, w, w, &coarse);
|
||||
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
|
||||
}
|
||||
|
||||
/// The Texture filters' patterns lie in document space around the object's centre: the same at
|
||||
/// twice the resolution, and moving with the object.
|
||||
#[test]
|
||||
fn texture_patterns_follow_the_object_at_any_resolution() {
|
||||
let (w, h) = (48, 48);
|
||||
let art = |x: f64, y: f64| -> [u8; 4] {
|
||||
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
|
||||
};
|
||||
for (id, p) in [
|
||||
("texture.craquelure", json!({"crackSpacing": 8})),
|
||||
("texture.grain", json!({"grainType": "enlarged"})),
|
||||
("texture.mosaicTiles", json!({"tileSize": 8, "groutWidth": 2})),
|
||||
("texture.patchwork", json!({"squareSize": 6})),
|
||||
("texture.stainedGlass", json!({"cellSize": 8, "borderThickness": 2})),
|
||||
("texture.texturizer", json!({"texture": "sandstone", "relief": 10})),
|
||||
] {
|
||||
let f = fx(id, p);
|
||||
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
f.apply(&mut one, w, h, &space(w, h));
|
||||
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
|
||||
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
|
||||
f.apply(&mut two, 2 * w, 2 * h, &fine);
|
||||
let down = image(w, h, |x, y| {
|
||||
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
|
||||
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
|
||||
});
|
||||
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
|
||||
assert!(diff < 12.0, "{id}: {diff}");
|
||||
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
|
||||
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
f.apply(&mut shifted, w, h, &moved);
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
//! Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer.
|
||||
//!
|
||||
//! Like the Pixelate filters' patterns, theirs (cracks, grain, tiles, squares, panes and the
|
||||
//! Texturizer's surfaces, all generated here, no image files) lie in document space around the
|
||||
//! object's centre: the same at any zoom or resolution, moving with the object, and the same on
|
||||
//! every redraw (hash noise of the pattern's cell indices). Surfaces in relief are height fields
|
||||
//! whose slopes, taken in document units, are lit from one side.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, PI};
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
/// Grain's types, in its Grain Type menu's order: (label, parameter value).
|
||||
pub const GRAIN_TYPES: [(&str, &str); 10] = [
|
||||
("Regular", "regular"),
|
||||
("Soft", "soft"),
|
||||
("Sprinkles", "sprinkles"),
|
||||
("Clumped", "clumped"),
|
||||
("Contrasty", "contrasty"),
|
||||
("Enlarged", "enlarged"),
|
||||
("Stippled", "stippled"),
|
||||
("Horizontal", "horizontal"),
|
||||
("Vertical", "vertical"),
|
||||
("Speckle", "speckle"),
|
||||
];
|
||||
|
||||
/// A Grain type ([`GRAIN_TYPES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Grain {
|
||||
Regular,
|
||||
Soft,
|
||||
Sprinkles,
|
||||
Clumped,
|
||||
Contrasty,
|
||||
Enlarged,
|
||||
Stippled,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Speckle,
|
||||
}
|
||||
|
||||
impl Grain {
|
||||
const ALL: [Grain; 10] = [
|
||||
Grain::Regular,
|
||||
Grain::Soft,
|
||||
Grain::Sprinkles,
|
||||
Grain::Clumped,
|
||||
Grain::Contrasty,
|
||||
Grain::Enlarged,
|
||||
Grain::Stippled,
|
||||
Grain::Horizontal,
|
||||
Grain::Vertical,
|
||||
Grain::Speckle,
|
||||
];
|
||||
|
||||
/// The type of Grain Type `value` (a [`GRAIN_TYPES`] value, any case); Regular otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&GRAIN_TYPES, &Self::ALL, value, Grain::Regular)
|
||||
}
|
||||
}
|
||||
|
||||
/// Texturizer's textures, in its Texture menu's order: (label, parameter value).
|
||||
pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")];
|
||||
|
||||
/// A Texturizer surface ([`TEXTURES`]), made in code.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Texture {
|
||||
Brick,
|
||||
Burlap,
|
||||
Canvas,
|
||||
Sandstone,
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
const ALL: [Texture; 4] = [Texture::Brick, Texture::Burlap, Texture::Canvas, Texture::Sandstone];
|
||||
|
||||
/// The texture of `value` (a [`TEXTURES`] value, any case); Canvas otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
fn height(self, p: Point) -> f64 {
|
||||
match self {
|
||||
Texture::Brick => brick(p),
|
||||
Texture::Burlap => weave(p, 7.0, true),
|
||||
Texture::Canvas => weave(p, 4.0, false),
|
||||
Texture::Sandstone => sandstone(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Texturizer's light directions, in its Light menu's order: (label, parameter value).
|
||||
pub const LIGHT_DIRECTIONS: [(&str, &str); 8] = [
|
||||
("Bottom", "bottom"),
|
||||
("Bottom Left", "bottomLeft"),
|
||||
("Left", "left"),
|
||||
("Top Left", "topLeft"),
|
||||
("Top", "top"),
|
||||
("Top Right", "topRight"),
|
||||
("Right", "right"),
|
||||
("Bottom Right", "bottomRight"),
|
||||
];
|
||||
|
||||
/// Where a surface is lit from ([`LIGHT_DIRECTIONS`]), on the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Light {
|
||||
Bottom,
|
||||
BottomLeft,
|
||||
Left,
|
||||
TopLeft,
|
||||
Top,
|
||||
TopRight,
|
||||
Right,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
impl Light {
|
||||
const ALL: [Light; 8] =
|
||||
[Light::Bottom, Light::BottomLeft, Light::Left, Light::TopLeft, Light::Top, Light::TopRight, Light::Right, Light::BottomRight];
|
||||
|
||||
/// The direction of `value` (a [`LIGHT_DIRECTIONS`] value, any case); Top otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&LIGHT_DIRECTIONS, &Self::ALL, value, Light::Top)
|
||||
}
|
||||
|
||||
/// The unit vector towards the light (document axes, y down).
|
||||
fn toward(self) -> Vec2 {
|
||||
let d = FRAC_1_SQRT_2;
|
||||
match self {
|
||||
Light::Bottom => Vec2::new(0.0, 1.0),
|
||||
Light::BottomLeft => Vec2::new(-d, d),
|
||||
Light::Left => Vec2::new(-1.0, 0.0),
|
||||
Light::TopLeft => Vec2::new(-d, -d),
|
||||
Light::Top => Vec2::new(0.0, -1.0),
|
||||
Light::TopRight => Vec2::new(d, -d),
|
||||
Light::Right => Vec2::new(1.0, 0.0),
|
||||
Light::BottomRight => Vec2::new(d, d),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds of the Texture filters' patterns (Pixelate's use smaller salts).
|
||||
const SALT_CRACKS: u64 = 64;
|
||||
const SALT_GRAIN: u64 = 72;
|
||||
const SALT_TILES: u64 = 88;
|
||||
const SALT_PATCH: u64 = 96;
|
||||
const SALT_GLASS: u64 = 104;
|
||||
const SALT_SURFACE: u64 = 112;
|
||||
|
||||
/// How soft Craquelure's tones are, as a share of the crack spacing: their contours become cracks.
|
||||
pub(super) const CONTOUR_SOFTNESS: f64 = 1.0 / 6.0;
|
||||
|
||||
/// A pixel's straight colour (0..1).
|
||||
fn straight(p: [u8; 4]) -> [f32; 3] {
|
||||
let a = f32::from(p[3]);
|
||||
[p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The pixel of straight colour `c` at coverage `alpha` (0..1), premultiplied.
|
||||
fn premultiply(c: [f32; 3], alpha: f32) -> [u8; 4] {
|
||||
let a = (alpha.clamp(0.0, 1.0) * 255.0).round();
|
||||
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * a).round().min(a) as u8);
|
||||
[r, g, b, a as u8]
|
||||
}
|
||||
|
||||
/// 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` (height per length) faces a light from
|
||||
/// `toward`: 0 flat, positive facing it, negative turned away.
|
||||
fn facing(slope: Vec2, toward: Vec2) -> f64 {
|
||||
-slope.dot(toward)
|
||||
}
|
||||
|
||||
/// The share of a pixel `px` wide that lies within `d` of a line (negative `d`: beyond it).
|
||||
fn cover(d: f64, px: f64) -> f64 {
|
||||
(d / px + 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// The luma of colour `c` (Rec. 601 weights).
|
||||
fn luma(c: [f32; 3]) -> f32 {
|
||||
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
|
||||
}
|
||||
|
||||
/// The two random points nearest to (`u`, `v`) (in cells), `point(i, j)` being cell (`i`, `j`)'s,
|
||||
/// found among the cells [`AROUND`] the one holding it.
|
||||
fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
|
||||
let (i, j) = (cell(u)?, cell(v)?);
|
||||
let mut best: [(f64, Option<Point>); 2] = [(f64::INFINITY, None); 2];
|
||||
for (k, (di, dj)) in AROUND.iter().enumerate() {
|
||||
// A cell farther than the second point so far can't hold a nearer one.
|
||||
if gap(i.wrapping_add(*di), u).hypot(gap(j.wrapping_add(*dj), v)) >= best[1].0 {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = around.get((i, j), k, &point) else { continue };
|
||||
let d = (p.x - u).hypot(p.y - v);
|
||||
if d < best[0].0 {
|
||||
best = [(d, Some(p)), best[0]];
|
||||
} else if d < best[1].0 {
|
||||
best[1] = (d, Some(p));
|
||||
}
|
||||
}
|
||||
Some((best[0].1?, best[1].1?))
|
||||
}
|
||||
|
||||
/// How far (`u`, `v`) lies from the border between the cells of `near` and `next` (its nearest
|
||||
/// points, [`nearest_two`]), and the way away from it (unit), in cells.
|
||||
fn edge(u: f64, v: f64, near: Point, next: Point) -> (f64, Vec2) {
|
||||
let q = Point::new(u, v);
|
||||
let apart = near - next;
|
||||
let len = apart.hypot();
|
||||
if !(len.is_finite() && len > 1e-12) {
|
||||
return (0.0, Vec2::ZERO);
|
||||
}
|
||||
(((q - next).hypot2() - (q - near).hypot2()) / (2.0 * len), apart / len)
|
||||
}
|
||||
|
||||
/// The slope of a rise of 1 over `width` from a border at distance `d` (smooth at both ends).
|
||||
fn bevel(d: f64, width: f64) -> f64 {
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
let t = (d / width).clamp(0.0, 1.0);
|
||||
6.0 * t * (1.0 - t)
|
||||
}
|
||||
|
||||
/// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by
|
||||
/// coverage so the transparency around it doesn't darken its edges.
|
||||
struct Tones {
|
||||
v: Vec<f32>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
impl Tones {
|
||||
fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
|
||||
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect();
|
||||
let mut a: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
blur_plane(&mut l, w, h, sigma);
|
||||
blur_plane(&mut a, w, h, sigma);
|
||||
let v = l.iter().zip(&a).map(|(l, a)| if *a > 1e-4 { (l / a).clamp(0.0, 1.0) } else { 0.0 }).collect();
|
||||
Self { v, w, h }
|
||||
}
|
||||
|
||||
fn get(&self, x: usize, y: usize) -> f64 {
|
||||
let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1)));
|
||||
self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v))
|
||||
}
|
||||
|
||||
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
|
||||
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
|
||||
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
|
||||
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
|
||||
// Per pixel → per document unit: through the transpose of the document → pixel map.
|
||||
let [a, b, c, d, _, _] = place.to_px.as_coeffs();
|
||||
(self.get(x, y), Vec2::new(gx * a + gy * b, gx * c + gy * d))
|
||||
}
|
||||
}
|
||||
|
||||
/// Craquelure: the object painted on relief plaster that cracks into plates about `spacing` apart
|
||||
/// and along the contours of its tones. `depth` (0..10) sets how deep and wide the cracks are and
|
||||
/// how high the plates and the tones stand; `brightness` (0..10) how brightly the surface is lit.
|
||||
pub(super) fn craquelure(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, spacing: f64, depth: f64, brightness: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(spacing.is_finite() && spacing > 0.0) {
|
||||
return;
|
||||
}
|
||||
let depth = (depth / 10.0).clamp(0.0, 1.0);
|
||||
let light = (0.5 + 0.05 * brightness.clamp(0.0, 10.0)) as f32;
|
||||
let soft = CONTOUR_SOFTNESS * spacing;
|
||||
let tones = Tones::new(px, w, h, soft / place.px);
|
||||
let half = (0.15 + 0.6 * depth).min(0.15 * spacing);
|
||||
let rise = (3.0 * half).max(0.12 * spacing);
|
||||
let toward = Light::TopLeft.toward();
|
||||
let mut around = Around::new();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / spacing, q.y / spacing);
|
||||
// The cracks between the plates; each plate's edge bevels down into them.
|
||||
let (mut crack, mut slope) = (0.0, Vec2::ZERO);
|
||||
if let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_CRACKS, i, j)) {
|
||||
let (e, away) = edge(u, v, near, next);
|
||||
crack = cover(half - e * spacing, place.px);
|
||||
slope = away * (0.6 * bevel(e * spacing - half, rise));
|
||||
}
|
||||
// The cracks along the tones' contours (quarter, half and three-quarter tone).
|
||||
let (tone, grad) = tones.slope(x, y, &place);
|
||||
let steep = grad.hypot();
|
||||
if steep > 1e-4 {
|
||||
let level = (tone * 4.0).round().clamp(1.0, 3.0) / 4.0;
|
||||
crack = crack.max(cover(0.8 * half - (tone - level).abs() / steep, place.px));
|
||||
}
|
||||
slope += grad * (0.8 * soft);
|
||||
let c = lit(straight(*p), 0.5 * depth * facing(slope, toward));
|
||||
let dark = (1.0 - 0.85 * crack * depth) as f32;
|
||||
*p = premultiply(c.map(|v| v * light * dark), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// A random value in [-1, 1) for the `size`-point cell holding `q`; `k` picks one of several.
|
||||
fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
|
||||
match (cell(q.x / size), cell(q.y / size)) {
|
||||
(Some(i), Some(j)) => 2.0 * rand(salt, i, j, k) - 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`value_noise`] in [-1, 1) over cells `size` points wide.
|
||||
fn smooth_noise(salt: u64, q: Point, size: f64) -> f64 {
|
||||
2.0 * value_noise(salt, q.x / size, q.y / size) - 1.0
|
||||
}
|
||||
|
||||
/// Grain: noise of `kind` over the object in 1-point grains (2 for Enlarged, clumps for Clumped),
|
||||
/// as strong as `intensity` (0..100); `contrast` (0..100, 50 leaves it) sets the image's
|
||||
/// contrast. Sprinkles and Stippled use the background colour, white.
|
||||
pub(super) fn grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, intensity: f64, contrast: f64, kind: Grain) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let a = (intensity / 100.0).clamp(0.0, 1.0);
|
||||
let k = (0.5 + contrast / 100.0).clamp(0.5, 1.5) as f32;
|
||||
let s = SALT_GRAIN;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let mut c = straight(*p).map(|v| (v - 0.5) * k + 0.5);
|
||||
let chance = 0.5 * (cell_noise(s, q, 1.0, 3) + 1.0);
|
||||
match kind {
|
||||
Grain::Regular => add(&mut c, |ch| cell_noise(s, q, 1.0, ch), 0.5 * a),
|
||||
Grain::Soft => add(&mut c, |ch| smooth_noise(s + ch, q, 1.0), 0.4 * a),
|
||||
Grain::Clumped => add(&mut c, |ch| 0.65 * smooth_noise(s + ch, q, 3.0) + 0.35 * cell_noise(s, q, 1.0, ch), 0.6 * a),
|
||||
Grain::Enlarged => add(&mut c, |ch| 0.5 * (smooth_noise(s + ch, q, 2.0) + cell_noise(s, q, 2.0, ch)), 0.5 * a),
|
||||
Grain::Contrasty => {
|
||||
c = c.map(|v| (v - 0.5) * 1.4 + 0.5);
|
||||
add(&mut c, |_| cell_noise(s, q, 1.0, 4), 0.6 * a);
|
||||
}
|
||||
Grain::Horizontal | Grain::Vertical => {
|
||||
// Streaks one point thick: a value per row (column) drifting along it.
|
||||
let (along, across) = if kind == Grain::Horizontal { (q.x, q.y) } else { (q.y, q.x) };
|
||||
let line = cell(across).unwrap_or(0);
|
||||
let n = 0.6 * (2.0 * rand(s, 0, line, 6) - 1.0) + 0.4 * (2.0 * value_noise(s + 7, along / 12.0, line as f64) - 1.0);
|
||||
add(&mut c, |_| n, 0.5 * a);
|
||||
}
|
||||
Grain::Sprinkles => {
|
||||
if chance < 0.35 * a {
|
||||
c = c.map(|v| v + (1.0 - v) * 0.9);
|
||||
}
|
||||
}
|
||||
Grain::Speckle => {
|
||||
if chance < 0.25 * a {
|
||||
c = c.map(|v| v * 0.25);
|
||||
} else {
|
||||
add(&mut c, |_| cell_noise(s, q, 1.0, 5), 0.1 * a);
|
||||
}
|
||||
}
|
||||
Grain::Stippled => {
|
||||
// Dots of the colour where it is dark, the background between them.
|
||||
let dots = f64::from(1.0 - luma(c).clamp(0.0, 1.0)) * (0.35 + 0.65 * a);
|
||||
c = if chance < dots { c.map(|v| v * 0.6) } else { [1.0; 3] };
|
||||
}
|
||||
}
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Add noise `n(channel)` (-1..1) times `amount` to colour `c`.
|
||||
fn add(c: &mut [f32; 3], n: impl Fn(u64) -> f64, amount: f64) {
|
||||
for (ch, v) in c.iter_mut().enumerate() {
|
||||
*v += (amount * n(ch as u64)) as f32;
|
||||
}
|
||||
}
|
||||
|
||||
/// A random point of cell (`i`, `j`) of pattern `salt` within `jitter` (0..1, a share of the
|
||||
/// cell) of its centre, in cells.
|
||||
fn jittered(salt: u64, i: i64, j: i64, jitter: f64) -> Point {
|
||||
Point::new(i as f64 + 0.5 + jitter * (rand(salt, i, j, 0) - 0.5), j as f64 + 0.5 + jitter * (rand(salt, i, j, 1) - 0.5))
|
||||
}
|
||||
|
||||
/// Mosaic Tiles: the object laid in irregular tiles about `tile` across, bevelled at their edges,
|
||||
/// with sunken grout between them half a point wide per step of `grout` (1..15), as light as
|
||||
/// `lighten` (0..10) says.
|
||||
pub(super) fn mosaic_tiles(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, tile: f64, grout: f64, lighten: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(tile.is_finite() && tile > 0.0 && grout.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let half = (0.25 * grout).clamp(0.0, 0.225 * tile);
|
||||
let rise = (1.2 * half).max(0.08 * tile);
|
||||
let lighten = (0.08 * lighten.clamp(0.0, 10.0)) as f32;
|
||||
let toward = Light::TopLeft.toward();
|
||||
let mut around = Around::new();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / tile, q.y / tile);
|
||||
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| jittered(SALT_TILES, i, j, 0.5)) else { return };
|
||||
let (e, away) = edge(u, v, near, next);
|
||||
let e = e * tile;
|
||||
let c = straight(*p);
|
||||
let face = lit(c, 0.35 * facing(away * bevel(e - half, rise), toward));
|
||||
// Sunk in shade, then lightened.
|
||||
let sunk = c.map(|v| 0.5 * v + (1.0 - 0.5 * v) * lighten);
|
||||
let g = cover(half - e, place.px) as f32;
|
||||
let mut out = face;
|
||||
for (o, s) in out.iter_mut().zip(sunk) {
|
||||
*o = *o * (1.0 - g) + s * g;
|
||||
}
|
||||
*p = premultiply(out, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Patchwork: the object in squares `square` points across, each filled with the colour around
|
||||
/// its centre and raised to a height of its own (brighter squares stand higher, each a little
|
||||
/// more or less at random), its bevelled sides lit from the top left as strongly as `relief`
|
||||
/// (0..25) says.
|
||||
pub(super) fn patchwork(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, square: f64, relief: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let side = square.max(1.0);
|
||||
if !side.is_finite() {
|
||||
return;
|
||||
}
|
||||
let soft = Soft::new(px, w, side / 3.0 / place.px);
|
||||
let relief = (relief / 25.0).clamp(0.0, 1.0);
|
||||
let rise = 0.25 * side;
|
||||
let toward = Light::TopLeft.toward();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
|
||||
let (x0, y0) = (i as f64 * side, j as f64 * side);
|
||||
let [r, g, b, alpha] = soft.at(place.index(Point::new(x0 + 0.5 * side, y0 + 0.5 * side)));
|
||||
if alpha <= 0.0 {
|
||||
*p = [0; 4];
|
||||
return;
|
||||
}
|
||||
let c = [r, g, b].map(|v| v / alpha);
|
||||
let height = relief * (0.35 + 0.65 * (0.5 * rand(SALT_PATCH, i, j, 0) + 0.5 * f64::from(luma(c))));
|
||||
// The bevels rise inwards from each side.
|
||||
let (fx, fy) = (q.x - x0, q.y - y0);
|
||||
let ramp = |d: f64| cover(rise - d, place.px);
|
||||
let slope = Vec2::new(ramp(fx) - ramp(side - fx), ramp(fy) - ramp(side - fy)) * (1.5 * height);
|
||||
*p = premultiply(lit(c, 0.9 * facing(slope, toward)), alpha);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stained Glass: the object as panes of single colours around random points about `cell_size`
|
||||
/// apart, leaded in the foreground colour (black) half a point wide per step of `border` (1..20),
|
||||
/// lit from behind at its centre as strongly as `light` (0..10) says, fading out towards its
|
||||
/// corners.
|
||||
pub(super) fn stained_glass(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64, border: f64, light: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(cell_size.is_finite() && cell_size > 0.0 && border.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
|
||||
let half = (0.25 * border).clamp(0.0, 0.25 * cell_size);
|
||||
let light = (light / 10.0).clamp(0.0, 1.0);
|
||||
let extent = space.extent;
|
||||
let mut around = Around::new();
|
||||
place.each(px, |x, y, p| {
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / cell_size, q.y / cell_size);
|
||||
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_GLASS, i, j)) else { return };
|
||||
let pane = |at: Point| soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
|
||||
let (glass, other) = (pane(near), pane(next));
|
||||
let (e, _) = edge(u, v, near, next);
|
||||
let lead = cover(half - e * cell_size, place.px) as f32;
|
||||
let glow = if extent.is_finite() && extent > 0.0 { light * (1.0 - (q.to_vec2().hypot() / extent).powi(2)).max(0.0) } else { 0.0 };
|
||||
let [r, g, b, a] = glass;
|
||||
let c = if a > 0.0 { lit([r, g, b].map(|v| v / a), 0.6 * glow) } else { [0.0; 3] };
|
||||
// The lead holds the panes on both sides of it, as opaque as the more opaque one.
|
||||
let alpha = a * (1.0 - lead) + a.max(other[3]) * lead;
|
||||
let rgb = c.map(|v| v * a * (1.0 - lead));
|
||||
*p = if alpha > 0.0 { premultiply(rgb.map(|v| v / alpha), alpha) } else { [0; 4] };
|
||||
});
|
||||
}
|
||||
|
||||
/// Brick in stretcher bond (bricks 20 × 8 points, 1.5-point mortar): each brick raised with
|
||||
/// 1-point bevels and a little higher or lower than the next, rough faced, the mortar sandy.
|
||||
fn brick(p: Point) -> f64 {
|
||||
const LENGTH: f64 = 20.0;
|
||||
const COURSE: f64 = 8.0;
|
||||
const MORTAR: f64 = 1.5;
|
||||
let Some(row) = cell(p.y / COURSE) else { return 0.0 };
|
||||
let x = p.x + if row.rem_euclid(2) == 1 { 0.5 * LENGTH } else { 0.0 };
|
||||
let Some(col) = cell(x / LENGTH) else { return 0.0 };
|
||||
let (fx, fy) = (x - col as f64 * LENGTH, p.y - row as f64 * COURSE);
|
||||
let edge = fx.min(LENGTH - fx).min(fy).min(COURSE - fy) - 0.5 * MORTAR;
|
||||
if edge <= 0.0 {
|
||||
return 0.2 * value_noise(SALT_SURFACE, p.x / 0.6, p.y / 0.6);
|
||||
}
|
||||
edge.min(1.0) + 0.4 * rand(SALT_SURFACE, col, row, 2) + 0.3 * value_noise(SALT_SURFACE + 1, p.x / 1.5, p.y / 1.5)
|
||||
}
|
||||
|
||||
/// A plain weave of threads `period` points apart, each passing over one thread and under the
|
||||
/// next; `coarse` threads (burlap) wander and are hairy, fine ones (canvas) are even.
|
||||
fn weave(p: Point, period: f64, coarse: bool) -> f64 {
|
||||
let (wander, hair) = if coarse { (0.35, 0.35) } else { (0.08, 0.12) };
|
||||
let drift = |salt: u64, t: f64| wander * (2.0 * value_noise(salt, t / (4.0 * period), 0.5) - 1.0);
|
||||
let x = p.x / period + drift(SALT_SURFACE + 3, p.y);
|
||||
let y = p.y / period + drift(SALT_SURFACE + 4, p.x);
|
||||
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.0 };
|
||||
let (fx, fy) = (x - i as f64, y - j as f64);
|
||||
// Each thread is round across and rises and falls along its length, over one crossing thread
|
||||
// and under the next; the higher of the two threads shows.
|
||||
let profile = |t: f64| (PI * t).sin().max(0.0).sqrt();
|
||||
let over = if i.wrapping_add(j).rem_euclid(2) == 0 { 0.5 } else { -0.5 };
|
||||
let down = profile(fx) * (0.5 + over * (PI * fy).sin());
|
||||
let across = profile(fy) * (0.5 - over * (PI * fx).sin());
|
||||
let thread = down.max(across);
|
||||
let fibres = hair * value_noise(SALT_SURFACE + 5, p.x / (0.25 * period), p.y / (0.25 * period));
|
||||
(thread + fibres) * period / PI
|
||||
}
|
||||
|
||||
/// Sandstone: four octaves of smooth noise (8 down to 1 point) over a fine sandy grain.
|
||||
fn sandstone(p: Point) -> f64 {
|
||||
let (mut h, mut size, mut amp) = (0.0, 8.0, 1.6);
|
||||
for octave in 0..4 {
|
||||
h += amp * value_noise(SALT_SURFACE + 8 + octave, p.x / size, p.y / size);
|
||||
size *= 0.5;
|
||||
amp *= 0.5;
|
||||
}
|
||||
h + 0.06 * cell_noise(SALT_SURFACE + 12, p, 0.5, 0)
|
||||
}
|
||||
|
||||
/// Texturizer: the object on a `texture` surface scaled by `scaling` (0.5..2), in relief as
|
||||
/// strong as `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
|
||||
#[allow(clippy::too_many_arguments)] // the effect's five options
|
||||
pub(super) fn texturizer(
|
||||
px: &mut [[u8; 4]],
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
texture: Texture,
|
||||
scaling: f64,
|
||||
relief: f64,
|
||||
light: Light,
|
||||
invert: bool,
|
||||
) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(scaling.is_finite() && scaling > 0.0) {
|
||||
return;
|
||||
}
|
||||
// Slopes over a quarter point of the texture, or half a pixel where pixels are larger.
|
||||
let step = (0.25f64).max(0.5 * place.px / scaling);
|
||||
let strength = 0.1 * relief.clamp(0.0, 50.0) * if invert { -1.0 } else { 1.0 };
|
||||
let toward = light.toward();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let t = Point::new(q.x / scaling, q.y / scaling);
|
||||
if !(t.x.is_finite() && t.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
|
||||
let (left, right, up, down) = (at(-step, 0.0), at(step, 0.0), at(0.0, -step), at(0.0, step));
|
||||
let slope = Vec2::new(right - left, down - up) / (2.0 * step);
|
||||
// Ridges catch some light from any side and hollows stay in shade, so the surface shows
|
||||
// whichever way it runs.
|
||||
let ridge = (at(0.0, 0.0) - 0.25 * (left + right + up + down)) / step;
|
||||
*p = premultiply(lit(straight(*p), strength * (facing(slope, toward) + ridge)), f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate ›
|
||||
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask): applied and
|
||||
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
|
||||
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
|
||||
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
|
||||
//! as images of the effected object.
|
||||
|
||||
@@ -21,6 +22,16 @@ const EFFECTS: [(&str, &str); 3] = [
|
||||
const PIXELATE: [(&str, &str); 4] =
|
||||
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
|
||||
|
||||
/// Effect › Texture, at their defaults.
|
||||
const TEXTURE: [(&str, &str); 6] = [
|
||||
("texture.craquelure", "{}"),
|
||||
("texture.grain", "{}"),
|
||||
("texture.mosaicTiles", "{}"),
|
||||
("texture.patchwork", "{}"),
|
||||
("texture.stainedGlass", "{}"),
|
||||
("texture.texturizer", "{}"),
|
||||
];
|
||||
|
||||
/// Effect › Video (NTSC Colors leaves the striped art's legal colours as they are: see
|
||||
/// `ntsc_colors_draws_saturated_yellow_safer`).
|
||||
const VIDEO: [(&str, &str); 1] = [("video.deinterlace", r#"{"create": "interpolation"}"#)];
|
||||
@@ -61,7 +72,12 @@ fn listed_applied_edited_and_undone() {
|
||||
let menus: Vec<Value> =
|
||||
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
|
||||
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
|
||||
for (group, menu) in [(PIXELATE.as_slice(), "Pixelate"), (VIDEO.as_slice(), "Video"), ([("video.ntscColors", "{}")].as_slice(), "Video")] {
|
||||
for (group, menu) in [
|
||||
(PIXELATE.as_slice(), "Pixelate"),
|
||||
(TEXTURE.as_slice(), "Texture"),
|
||||
(VIDEO.as_slice(), "Video"),
|
||||
([("video.ntscColors", "{}")].as_slice(), "Video"),
|
||||
] {
|
||||
for (id, _) in group {
|
||||
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap_or_else(|| panic!("{id} listed"));
|
||||
assert_eq!((&e["raster"], &e["menu"]), (&json!(true), &json!(["Effect", menu])), "{id}");
|
||||
@@ -86,7 +102,7 @@ fn listed_applied_edited_and_undone() {
|
||||
|
||||
#[test]
|
||||
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(VIDEO) {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
let (mut s, g) = striped();
|
||||
let plain = render(&s);
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
@@ -115,7 +131,7 @@ fn spin_blur_reaches_past_the_corners() {
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_the_effected_object_as_an_image() {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(VIDEO) {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
|
||||
let (mut s, g) = striped();
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
|
||||
@@ -296,6 +296,15 @@ fn structured_junk() {
|
||||
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
|
||||
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
|
||||
("effect.apply", json!({"effect": "pixelate.pointillize", "params": {"cellSize": "NaN"}})),
|
||||
("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}})),
|
||||
("effect.apply", json!({"effect": "texture.patchwork", "params": {"squareSize": 1e308, "relief": -1e308}})),
|
||||
("effect.apply", json!({"effect": "texture.stainedGlass", "params": {"cellSize": 0, "borderThickness": 1e308, "lightIntensity": "x"}})),
|
||||
(
|
||||
"effect.apply",
|
||||
json!({"effect": "texture.texturizer", "params": {"texture": {}, "scaling": -1e308, "relief": 1e308, "lightDirection": 3, "invert": "yes"}}),
|
||||
),
|
||||
("effect.apply", json!({"effect": "video.deinterlace", "params": {"eliminate": 7, "create": ["x"]}})),
|
||||
("effect.apply", json!({"effect": "video.ntscColors", "params": {"junk": 1e308}})),
|
||||
("effect.apply", json!({"effect": "no.such.effect"})),
|
||||
|
||||
@@ -603,7 +603,8 @@ impl Renderer {
|
||||
};
|
||||
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
|
||||
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
||||
let space = effects::PixelSpace { to_doc, px, center: content.reach.center(), channels, line: 72.0 / ppi };
|
||||
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
|
||||
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
|
||||
let (wz, hz) = (wu as usize, hu as usize);
|
||||
for fx in chain {
|
||||
match fx {
|
||||
|
||||
@@ -160,6 +160,9 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
|
||||
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
|
||||
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
|
||||
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
|
||||
("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),
|
||||
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
|
||||
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
|
||||
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
|
||||
|
||||
@@ -232,10 +232,13 @@ pub(super) fn param_fields(
|
||||
continue;
|
||||
}
|
||||
if let Some(options) = choices(&k) {
|
||||
// In the "effect" context: some languages read the plain Soft, Horizontal and
|
||||
// Vertical (Grain's types) as type settings.
|
||||
let shown = |label: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "effect", label);
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| *l);
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect();
|
||||
if let Some((_, value)) = crate::widgets::dropdown(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| shown(l));
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| shown(l)).collect();
|
||||
if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(k, json!(value));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -176,6 +176,12 @@ fn raster_filter_dialogs_preview_and_apply() {
|
||||
("pixelate.crystallize", "cellSize", "20"),
|
||||
("pixelate.mezzotint", "type", "longLines"),
|
||||
("pixelate.pointillize", "cellSize", "12"),
|
||||
("texture.craquelure", "crackDepth", "9"),
|
||||
("texture.grain", "grainType", "stippled"),
|
||||
("texture.mosaicTiles", "tileSize", "20"),
|
||||
("texture.patchwork", "relief", "20"),
|
||||
("texture.stainedGlass", "borderThickness", "2"),
|
||||
("texture.texturizer", "lightDirection", "bottomLeft"),
|
||||
] {
|
||||
let mut app = app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
@@ -203,6 +209,10 @@ fn raster_filter_dialogs_preview_and_apply() {
|
||||
assert_eq!(super::form::humanize("maxRadius"), "Max. Radius:");
|
||||
assert_eq!(super::form::humanize("channel3"), "Channel 3:");
|
||||
assert_eq!(super::form::humanize("cellSize"), "Cell Size:");
|
||||
assert_eq!(super::form::humanize("lightDirection"), "Light Direction:");
|
||||
let doc = vectorcraft_effects::effect_info("texture.texturizer").unwrap().params;
|
||||
let ranks: Vec<usize> = ["texture", "scaling", "relief", "lightDirection", "invert"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
|
||||
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[4] < usize::MAX, "{ranks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -663,3 +663,10 @@
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
CMYK CMYK
|
||||
Texture Textura
|
||||
Craquelure… Krakelování…
|
||||
Grain… Zrno…
|
||||
Mosaic Tiles… Mozaikové dlaždice…
|
||||
Patchwork… Patchwork…
|
||||
Stained Glass… Vitráž…
|
||||
Texturizer… Texturovač…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3931,3 +3931,43 @@ axis Both Ambos
|
||||
@msg Imported library {name} Biblioteca {name} importada
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture Textura
|
||||
Craquelure… Craquelado…
|
||||
Grain… Granulado…
|
||||
Mosaic Tiles… Azulejos de mosaico…
|
||||
Patchwork… Retazos…
|
||||
Stained Glass… Vidriera…
|
||||
Texturizer… Texturizador…
|
||||
Craquelure Craquelado
|
||||
Grain Granulado
|
||||
Mosaic Tiles Azulejos de mosaico
|
||||
Patchwork Retazos
|
||||
Stained Glass Vidriera
|
||||
Texturizer Texturizador
|
||||
Crack Spacing Espaciado de grietas
|
||||
Crack Depth Profundidad de grietas
|
||||
Crack Brightness Brillo de grietas
|
||||
Intensity Intensidad
|
||||
Grain Type Tipo de granulado
|
||||
Tile Size Tamaño de azulejo
|
||||
Grout Width Anchura de la junta
|
||||
Lighten Grout Aclarar junta
|
||||
Square Size Tamaño de cuadrado
|
||||
Relief Relieve
|
||||
Border Thickness Grosor del borde
|
||||
Light Intensity Intensidad de luz
|
||||
Light Direction Dirección de la luz
|
||||
Sprinkles Espolvoreado
|
||||
Clumped Agrupado
|
||||
Contrasty Contrastado
|
||||
Enlarged Ampliado
|
||||
Stippled Punteado
|
||||
Speckle Moteado
|
||||
Brick Ladrillo
|
||||
Burlap Arpillera
|
||||
Canvas Lienzo
|
||||
Sandstone Arenisca
|
||||
Bottom Left Abajo a la izquierda
|
||||
Top Left Arriba a la izquierda
|
||||
Top Right Arriba a la derecha
|
||||
Bottom Right Abajo a la derecha
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3942,3 +3942,44 @@ axis Both Les deux
|
||||
@msg Imported library {name} Bibliothèque {name} importée
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture Texture
|
||||
Craquelure… Craquelure…
|
||||
Grain… Grain…
|
||||
Mosaic Tiles… Carreaux de mosaïque…
|
||||
Patchwork… Patchwork…
|
||||
Stained Glass… Vitrail…
|
||||
Texturizer… Placage de texture…
|
||||
Craquelure Craquelure
|
||||
Grain Grain
|
||||
Mosaic Tiles Carreaux de mosaïque
|
||||
Patchwork Patchwork
|
||||
Stained Glass Vitrail
|
||||
Texturizer Placage de texture
|
||||
Crack Spacing Espacement des craquelures
|
||||
Crack Depth Profondeur des craquelures
|
||||
Crack Brightness Luminosité des craquelures
|
||||
Intensity Intensité
|
||||
Grain Type Type de grain
|
||||
Tile Size Taille des carreaux
|
||||
Grout Width Largeur des joints
|
||||
Lighten Grout Éclaircir les joints
|
||||
Square Size Taille des carrés
|
||||
Relief Relief
|
||||
Border Thickness Épaisseur de la bordure
|
||||
Light Intensity Intensité de la lumière
|
||||
Light Direction Direction de la lumière
|
||||
Sprinkles Saupoudré
|
||||
Clumped Agglutiné
|
||||
Contrasty Contrasté
|
||||
Enlarged Agrandi
|
||||
Stippled Pointillé
|
||||
Speckle Moucheté
|
||||
Brick Brique
|
||||
Burlap Toile de jute
|
||||
Canvas Toile
|
||||
Sandstone Grès
|
||||
Bottom Left En bas à gauche
|
||||
Top Left En haut à gauche
|
||||
Top Right En haut à droite
|
||||
Bottom Right En bas à droite
|
||||
effect Soft Doux
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3931,3 +3931,45 @@ axis Both Entrambi
|
||||
@msg Imported library {name} Libreria {name} importata
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture Texture
|
||||
Craquelure… Craquelé…
|
||||
Grain… Granulosità…
|
||||
Mosaic Tiles… Tessere di mosaico…
|
||||
Patchwork… Patchwork…
|
||||
Stained Glass… Vetro colorato…
|
||||
Texturizer… Applica texture…
|
||||
Craquelure Craquelé
|
||||
Grain Granulosità
|
||||
Mosaic Tiles Tessere di mosaico
|
||||
Patchwork Patchwork
|
||||
Stained Glass Vetro colorato
|
||||
Texturizer Applica texture
|
||||
Crack Spacing Spaziatura crepe
|
||||
Crack Depth Profondità crepe
|
||||
Crack Brightness Luminosità crepe
|
||||
Intensity Intensità
|
||||
Grain Type Tipo di granulosità
|
||||
Tile Size Dimensione tessere
|
||||
Grout Width Larghezza stuccatura
|
||||
Lighten Grout Schiarisci stuccatura
|
||||
Square Size Dimensione quadrati
|
||||
Relief Rilievo
|
||||
Border Thickness Spessore bordo
|
||||
Light Intensity Intensità luce
|
||||
Light Direction Direzione luce
|
||||
Sprinkles Spruzzato
|
||||
Clumped Raggruppato
|
||||
Contrasty Contrastato
|
||||
Enlarged Ingrandito
|
||||
Stippled Punteggiato
|
||||
Speckle Maculato
|
||||
Brick Mattone
|
||||
Burlap Tela di iuta
|
||||
Canvas Tela
|
||||
Sandstone Arenaria
|
||||
Bottom Left In basso a sinistra
|
||||
Top Left In alto a sinistra
|
||||
Top Right In alto a destra
|
||||
Bottom Right In basso a destra
|
||||
effect Soft Morbido
|
||||
effect Regular Normale
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3923,3 +3923,46 @@ theme Appearance 外観
|
||||
@msg Imported library {name} ライブラリ {name} を読み込みました
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture テクスチャ
|
||||
Craquelure… クラッキング…
|
||||
Grain… 粒状…
|
||||
Mosaic Tiles… モザイクタイル…
|
||||
Patchwork… パッチワーク…
|
||||
Stained Glass… ステンドグラス…
|
||||
Texturizer… テクスチャライザー…
|
||||
Craquelure クラッキング
|
||||
Grain 粒状
|
||||
Mosaic Tiles モザイクタイル
|
||||
Patchwork パッチワーク
|
||||
Stained Glass ステンドグラス
|
||||
Texturizer テクスチャライザー
|
||||
Crack Spacing 溝の間隔
|
||||
Crack Depth 溝の深さ
|
||||
Crack Brightness 溝の明るさ
|
||||
Intensity 強さ
|
||||
Grain Type 粒子の種類
|
||||
Tile Size タイルのサイズ
|
||||
Grout Width 目地の幅
|
||||
Lighten Grout 目地を明るく
|
||||
Square Size 正方形のサイズ
|
||||
Relief レリーフ
|
||||
Border Thickness 境界線の太さ
|
||||
Light Intensity 光の強さ
|
||||
Light Direction 光源の方向
|
||||
Sprinkles 散布
|
||||
Clumped 集合
|
||||
Contrasty 高コントラスト
|
||||
Enlarged 拡大
|
||||
Stippled スティップル
|
||||
Speckle 斑点
|
||||
Brick レンガ
|
||||
Burlap 麻布
|
||||
Canvas カンバス
|
||||
Sandstone 砂岩
|
||||
Bottom Left 左下
|
||||
Top Left 左上
|
||||
Top Right 右上
|
||||
Bottom Right 右下
|
||||
effect Soft ソフト
|
||||
effect Horizontal 水平
|
||||
effect Vertical 垂直
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2723,3 +2723,10 @@
|
||||
NTSC Colors Cores NTSC
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture Textura
|
||||
Craquelure… Craquelê…
|
||||
Grain… Granulação…
|
||||
Mosaic Tiles… Ladrilhos de mosaico…
|
||||
Patchwork… Colcha de retalhos…
|
||||
Stained Glass… Vitral…
|
||||
Texturizer… Texturizador…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3929,3 +3929,43 @@ axis Both Оба
|
||||
@msg Imported library {name} Импортирована библиотека {name}
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture Текстура
|
||||
Craquelure… Кракелюры…
|
||||
Grain… Зерно…
|
||||
Mosaic Tiles… Мозаичные фрагменты…
|
||||
Patchwork… Цветная плитка…
|
||||
Stained Glass… Витраж…
|
||||
Texturizer… Текстуризатор…
|
||||
Craquelure Кракелюры
|
||||
Grain Зерно
|
||||
Mosaic Tiles Мозаичные фрагменты
|
||||
Patchwork Цветная плитка
|
||||
Stained Glass Витраж
|
||||
Texturizer Текстуризатор
|
||||
Crack Spacing Интервал трещин
|
||||
Crack Depth Глубина трещин
|
||||
Crack Brightness Яркость трещин
|
||||
Intensity Интенсивность
|
||||
Grain Type Тип зерна
|
||||
Tile Size Размер фрагментов
|
||||
Grout Width Ширина швов
|
||||
Lighten Grout Осветление швов
|
||||
Square Size Размер квадратов
|
||||
Relief Рельеф
|
||||
Border Thickness Толщина границ
|
||||
Light Intensity Интенсивность света
|
||||
Light Direction Направление света
|
||||
Sprinkles Брызги
|
||||
Clumped Комки
|
||||
Contrasty Контрастное
|
||||
Enlarged Укрупнённое
|
||||
Stippled Пунктир
|
||||
Speckle Крапинки
|
||||
Brick Кирпич
|
||||
Burlap Мешковина
|
||||
Canvas Холст
|
||||
Sandstone Песчаник
|
||||
Bottom Left Снизу слева
|
||||
Top Left Сверху слева
|
||||
Top Right Сверху справа
|
||||
Bottom Right Снизу справа
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3914,3 +3914,43 @@ axis Both Обидві
|
||||
@msg Imported library {name} Імпортовано бібліотеку {name}
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture Текстура
|
||||
Craquelure… Кракелюри…
|
||||
Grain… Зерно…
|
||||
Mosaic Tiles… Мозаїчні фрагменти…
|
||||
Patchwork… Клаптикова плитка…
|
||||
Stained Glass… Вітраж…
|
||||
Texturizer… Текстуризатор…
|
||||
Craquelure Кракелюри
|
||||
Grain Зерно
|
||||
Mosaic Tiles Мозаїчні фрагменти
|
||||
Patchwork Клаптикова плитка
|
||||
Stained Glass Вітраж
|
||||
Texturizer Текстуризатор
|
||||
Crack Spacing Інтервал тріщин
|
||||
Crack Depth Глибина тріщин
|
||||
Crack Brightness Яскравість тріщин
|
||||
Intensity Інтенсивність
|
||||
Grain Type Тип зерна
|
||||
Tile Size Розмір фрагментів
|
||||
Grout Width Ширина швів
|
||||
Lighten Grout Освітлення швів
|
||||
Square Size Розмір квадратів
|
||||
Relief Рельєф
|
||||
Border Thickness Товщина меж
|
||||
Light Intensity Інтенсивність світла
|
||||
Light Direction Напрямок світла
|
||||
Sprinkles Бризки
|
||||
Clumped Грудки
|
||||
Contrasty Контрастне
|
||||
Enlarged Збільшене
|
||||
Stippled Пунктир
|
||||
Speckle Цятки
|
||||
Brick Цегла
|
||||
Burlap Мішковина
|
||||
Canvas Полотно
|
||||
Sandstone Пісковик
|
||||
Bottom Left Знизу ліворуч
|
||||
Top Left Вгорі ліворуч
|
||||
Top Right Вгорі праворуч
|
||||
Bottom Right Знизу праворуч
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -3559,3 +3559,46 @@ axis Both 两者
|
||||
Even Fields 偶数场
|
||||
Duplication 复制
|
||||
Interpolation 插值
|
||||
Texture 纹理
|
||||
Craquelure… 龟裂缝…
|
||||
Grain… 颗粒…
|
||||
Mosaic Tiles… 马赛克拼贴…
|
||||
Patchwork… 拼缀图…
|
||||
Stained Glass… 染色玻璃…
|
||||
Texturizer… 纹理化…
|
||||
Craquelure 龟裂缝
|
||||
Grain 颗粒
|
||||
Mosaic Tiles 马赛克拼贴
|
||||
Patchwork 拼缀图
|
||||
Stained Glass 染色玻璃
|
||||
Texturizer 纹理化
|
||||
Crack Spacing 裂缝间距
|
||||
Crack Depth 裂缝深度
|
||||
Crack Brightness 裂缝亮度
|
||||
Intensity 强度
|
||||
Grain Type 颗粒类型
|
||||
Tile Size 拼贴大小
|
||||
Grout Width 缝隙宽度
|
||||
Lighten Grout 加亮缝隙
|
||||
Square Size 方形大小
|
||||
Relief 凸现
|
||||
Border Thickness 边框粗细
|
||||
Light Intensity 光照强度
|
||||
Light Direction 光照方向
|
||||
Sprinkles 喷洒
|
||||
Clumped 结块
|
||||
Contrasty 强反差
|
||||
Enlarged 扩大
|
||||
Stippled 点刻
|
||||
Speckle 斑点
|
||||
Brick 砖形
|
||||
Burlap 粗麻布
|
||||
Canvas 画布
|
||||
Sandstone 砂岩
|
||||
Bottom Left 左下
|
||||
Top Left 左上
|
||||
Top Right 右上
|
||||
Bottom Right 右下
|
||||
effect Soft 柔和
|
||||
effect Horizontal 水平
|
||||
effect Vertical 垂直
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3342,3 +3342,44 @@ axis Both 兩者
|
||||
Interpolation 內插
|
||||
RGB: RGB:
|
||||
CMYK: CMYK:
|
||||
Texture 紋理
|
||||
Craquelure… 龜裂紋…
|
||||
Grain… 顆粒…
|
||||
Mosaic Tiles… 馬賽克拼貼…
|
||||
Patchwork… 拼綴圖…
|
||||
Stained Glass… 彩色玻璃…
|
||||
Texturizer… 紋理化…
|
||||
Craquelure 龜裂紋
|
||||
Grain 顆粒
|
||||
Mosaic Tiles 馬賽克拼貼
|
||||
Patchwork 拼綴圖
|
||||
Stained Glass 彩色玻璃
|
||||
Texturizer 紋理化
|
||||
Crack Spacing 裂紋間距
|
||||
Crack Depth 裂紋深度
|
||||
Crack Brightness 裂紋亮度
|
||||
Intensity 強度
|
||||
Grain Type 顆粒類型
|
||||
Tile Size 拼貼大小
|
||||
Grout Width 縫隙寬度
|
||||
Lighten Grout 加亮縫隙
|
||||
Square Size 方塊大小
|
||||
Relief 浮雕
|
||||
Border Thickness 邊框粗細
|
||||
Light Intensity 光源強度
|
||||
Light Direction 光源方向
|
||||
Sprinkles 噴灑
|
||||
Clumped 結塊
|
||||
Contrasty 高反差
|
||||
Enlarged 放大
|
||||
Stippled 點刻
|
||||
Speckle 斑點
|
||||
Brick 磚塊
|
||||
Burlap 粗麻布
|
||||
Canvas 畫布
|
||||
Sandstone 砂岩
|
||||
Bottom Left 左下
|
||||
Top Left 左上
|
||||
Top Right 右上
|
||||
Bottom Right 右下
|
||||
effect Soft 柔和
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3305,8 +3305,13 @@ fn font_items(english_names: bool) -> Vec<Item> {
|
||||
|
||||
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
|
||||
/// vector effects): (submenu, the catalogue's menu path of its effects).
|
||||
const RASTER_MENUS: [(&str, &[&str]); 4] =
|
||||
[("Blur", &["Effect", "Blur"]), ("Pixelate", &["Effect", "Pixelate"]), ("Sharpen", &["Effect", "Sharpen"]), ("Video", &["Effect", "Video"])];
|
||||
const RASTER_MENUS: [(&str, &[&str]); 5] = [
|
||||
("Blur", &["Effect", "Blur"]),
|
||||
("Pixelate", &["Effect", "Pixelate"]),
|
||||
("Sharpen", &["Effect", "Sharpen"]),
|
||||
("Texture", &["Effect", "Texture"]),
|
||||
("Video", &["Effect", "Video"]),
|
||||
];
|
||||
|
||||
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
|
||||
fn effect_menu() -> Vec<Item> {
|
||||
|
||||
@@ -78,6 +78,21 @@ fn effect_menu_lists_the_video_filters_last() {
|
||||
assert!(at("Sharpen") < at("Video"));
|
||||
}
|
||||
|
||||
/// Effect › Texture, between Sharpen and Video: each filter opens its dialog.
|
||||
#[test]
|
||||
fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {
|
||||
let app = app();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let texture: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Texture"]).collect();
|
||||
let labels: Vec<&str> = texture.iter().map(|e| e.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Craquelure…", "Grain…", "Mosaic Tiles…", "Patchwork…", "Stained Glass…", "Texturizer…"]);
|
||||
assert!(texture.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
|
||||
let raster: Vec<&str> =
|
||||
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
|
||||
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
|
||||
assert!(at("Sharpen") < at("Texture") && at("Texture") < at("Video"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_submenus_are_disabled_placeholders() {
|
||||
let app = app();
|
||||
|
||||
Reference in New Issue
Block a user