Merge pull request #930 from storytold/feat/pixelate-effects
Part of gap 2: Effect › Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize)
This commit is contained in:
commit
04eb5e52c8
15
ROADMAP.md
15
ROADMAP.md
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,8 @@
|
|||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges) are described by
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
|
|
@ -27,7 +28,8 @@
|
|||
//! renderer and the exporters.
|
||||
//!
|
||||
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
|
||||
//! noise (`seed` parameter, default 0).
|
||||
//! noise (`seed` parameter, default 0); the random Pixelate filters hash their pattern's cells,
|
||||
//! anchored at the object's centre.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod adjust;
|
||||
|
|
@ -126,6 +128,8 @@ fn lengths_of(id: &str) -> Lengths {
|
|||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
"pixelate.colorHalftone" => always(&["maxRadius"]),
|
||||
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
|
|
@ -139,6 +143,7 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
|||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
|
||||
|
|
@ -298,6 +303,34 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
|||
"{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black",
|
||||
json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}),
|
||||
),
|
||||
r(
|
||||
"pixelate.colorHalftone",
|
||||
"Color Halftone…",
|
||||
PIXELATE,
|
||||
"{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell",
|
||||
json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}),
|
||||
),
|
||||
r(
|
||||
"pixelate.crystallize",
|
||||
"Crystallize…",
|
||||
PIXELATE,
|
||||
"{cellSize: pt 3..300 (10)} redraws the object as polygon crystals of solid colour around random points about cellSize apart",
|
||||
json!({"cellSize": 10.0}),
|
||||
),
|
||||
r(
|
||||
"pixelate.mezzotint",
|
||||
"Mezzotint…",
|
||||
PIXELATE,
|
||||
"{type: \"fineDots\"|\"mediumDots\"|\"grainyDots\"|\"coarseDots\"|\"shortLines\"|\"mediumLines\"|\"longLines\"|\"shortStrokes\"|\"mediumStrokes\"|\"longStrokes\" (\"fineDots\")} turns each colour channel fully on or off against a random pattern of dots, lines or strokes: fully saturated colours",
|
||||
json!({"type": "fineDots"}),
|
||||
),
|
||||
r(
|
||||
"pixelate.pointillize",
|
||||
"Pointillize…",
|
||||
PIXELATE,
|
||||
"{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas",
|
||||
json!({"cellSize": 5.0}),
|
||||
),
|
||||
];
|
||||
v.extend([
|
||||
g(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
|
||||
//! Mask, and Stylize › Glowing Edges): filters over premultiplied RGBA8 pixels.
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
|
||||
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, and Stylize › Glowing
|
||||
//! Edges): 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
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
|
||||
mod blur;
|
||||
mod edges;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
|
||||
use serde_json::Value;
|
||||
|
|
@ -17,8 +19,19 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
|||
|
||||
use crate::util::{num, text};
|
||||
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.glowingEdges"];
|
||||
pub const PIXEL_EFFECTS: [&str; 8] = [
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"pixelate.colorHalftone",
|
||||
"pixelate.crystallize",
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"stylize.glowingEdges",
|
||||
];
|
||||
|
||||
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
|
||||
/// are in document units.
|
||||
|
|
@ -36,6 +49,31 @@ pub enum PixelFx {
|
|||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
|
||||
/// 1 to 4) into dots of up to `max_radius`, whose area follows the channel's strength.
|
||||
ColorHalftone { max_radius: f64, angles: [f64; 4] },
|
||||
/// Pixelate › Crystallize: polygons of solid colour around random points about `cell` apart.
|
||||
Crystallize { cell: f64 },
|
||||
/// Pixelate › Mezzotint: every colour channel fully on or off against a random pattern.
|
||||
Mezzotint { kind: Mezzotint },
|
||||
/// Pixelate › Pointillize: random dots about `cell` across of the object's colours on a white
|
||||
/// canvas.
|
||||
Pointillize { cell: f64 },
|
||||
}
|
||||
|
||||
/// What a raster's colour channels hold, for the filters that treat them apart (Color Halftone
|
||||
/// screens each channel, Mezzotint thresholds each).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Channels {
|
||||
/// Red, green and blue light.
|
||||
#[default]
|
||||
Rgb,
|
||||
/// Screen colours of a CMYK document: treated as its cyan, magenta, yellow and black inks.
|
||||
Cmyk,
|
||||
/// The complemented C, M and Y inks (a CMYK document drawn ink plane by ink plane).
|
||||
CmyPlane,
|
||||
/// The complemented K ink, as a grey.
|
||||
KPlane,
|
||||
}
|
||||
|
||||
/// Where a raster's pixels lie in the document.
|
||||
|
|
@ -46,8 +84,11 @@ pub struct PixelSpace {
|
|||
pub to_doc: Affine,
|
||||
/// The size of a pixel in document units.
|
||||
pub px: f64,
|
||||
/// The object's centre (document coordinates): where Radial Blur blurs around.
|
||||
/// The object's centre (document coordinates): where Radial Blur blurs around, and where the
|
||||
/// Pixelate filters' patterns are anchored (so they move with the object).
|
||||
pub center: Point,
|
||||
/// What the colour channels hold.
|
||||
pub channels: Channels,
|
||||
}
|
||||
|
||||
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
|
||||
|
|
@ -77,6 +118,14 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
|||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"pixelate.colorHalftone" => PixelFx::ColorHalftone {
|
||||
max_radius: num(p, "maxRadius", 8.0).clamp(4.0, 127.0),
|
||||
angles: [("channel1", 108.0), ("channel2", 162.0), ("channel3", 90.0), ("channel4", 45.0)]
|
||||
.map(|(k, d)| num(p, k, d).clamp(-360.0, 360.0)),
|
||||
},
|
||||
"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) },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -94,6 +143,9 @@ impl PixelFx {
|
|||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::ColorHalftone { .. } | PixelFx::Mezzotint { .. } => 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +157,12 @@ impl PixelFx {
|
|||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::Mezzotint { .. } => Some(0.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +176,10 @@ impl PixelFx {
|
|||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,531 @@
|
|||
//! Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize.
|
||||
//!
|
||||
//! Their patterns (screens, crystals, grains and dots) lie in document space around the object's
|
||||
//! centre, so they keep their size at any zoom or resolution and move with the object. The random
|
||||
//! ones hash the pattern's cell indices: every redraw gives the same result.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::{Channels, PixelSpace, gaussian, to16};
|
||||
use crate::util::noise;
|
||||
|
||||
/// Mezzotint's patterns, in its Type menu's order: (label, parameter value).
|
||||
pub const MEZZOTINT_TYPES: [(&str, &str); 10] = [
|
||||
("Fine Dots", "fineDots"),
|
||||
("Medium Dots", "mediumDots"),
|
||||
("Grainy Dots", "grainyDots"),
|
||||
("Coarse Dots", "coarseDots"),
|
||||
("Short Lines", "shortLines"),
|
||||
("Medium Lines", "mediumLines"),
|
||||
("Long Lines", "longLines"),
|
||||
("Short Strokes", "shortStrokes"),
|
||||
("Medium Strokes", "mediumStrokes"),
|
||||
("Long Strokes", "longStrokes"),
|
||||
];
|
||||
|
||||
/// A Mezzotint pattern ([`MEZZOTINT_TYPES`]): random dots, or horizontal lines or strokes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mezzotint {
|
||||
/// Dots `size` points across (smooth blobs when `smooth`), plus fine grain when `grainy`.
|
||||
Dots { size: u8, smooth: bool, grainy: bool },
|
||||
/// Horizontal streaks about `length` points long and `width` points thick.
|
||||
Lines { length: u8, width: u8 },
|
||||
}
|
||||
|
||||
/// The patterns of [`MEZZOTINT_TYPES`], in the same order.
|
||||
const MEZZOTINT_KINDS: [Mezzotint; 10] = {
|
||||
const fn dots(size: u8, smooth: bool, grainy: bool) -> Mezzotint {
|
||||
Mezzotint::Dots { size, smooth, grainy }
|
||||
}
|
||||
const fn lines(length: u8, width: u8) -> Mezzotint {
|
||||
Mezzotint::Lines { length, width }
|
||||
}
|
||||
[
|
||||
dots(1, false, false),
|
||||
dots(2, true, false),
|
||||
dots(3, true, true),
|
||||
dots(4, true, false),
|
||||
lines(4, 1),
|
||||
lines(8, 1),
|
||||
lines(16, 1),
|
||||
lines(6, 2),
|
||||
lines(12, 2),
|
||||
lines(24, 2),
|
||||
]
|
||||
};
|
||||
|
||||
impl Mezzotint {
|
||||
/// Fine Dots, the first type.
|
||||
pub const FINE_DOTS: Mezzotint = Mezzotint::Dots { size: 1, smooth: false, grainy: false };
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds of the filters' noise ("PIXELATE" and a filter number).
|
||||
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 {
|
||||
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> {
|
||||
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 {
|
||||
/// Pixel coordinates → document coordinates relative to the centre.
|
||||
to_rel: Affine,
|
||||
/// The other way.
|
||||
to_px: Affine,
|
||||
/// A pixel's size in document units.
|
||||
px: f64,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
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 {
|
||||
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> {
|
||||
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])) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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] = [
|
||||
(0, 0),
|
||||
(-1, 0),
|
||||
(1, 0),
|
||||
(0, -1),
|
||||
(0, 1),
|
||||
(-1, -1),
|
||||
(1, -1),
|
||||
(-1, 1),
|
||||
(1, 1),
|
||||
(-2, -2),
|
||||
(-1, -2),
|
||||
(0, -2),
|
||||
(1, -2),
|
||||
(2, -2),
|
||||
(-2, -1),
|
||||
(2, -1),
|
||||
(-2, 0),
|
||||
(2, 0),
|
||||
(-2, 1),
|
||||
(2, 1),
|
||||
(-2, 2),
|
||||
(-1, 2),
|
||||
(0, 2),
|
||||
(1, 2),
|
||||
(2, 2),
|
||||
];
|
||||
|
||||
/// What the cells [`AROUND`] one cell hold, made when first asked for and kept while consecutive
|
||||
/// pixels stay in that cell.
|
||||
struct Around<T> {
|
||||
cell: Option<(i64, i64)>,
|
||||
items: [Option<T>; 25],
|
||||
}
|
||||
|
||||
impl<T: Copy> Around<T> {
|
||||
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> {
|
||||
if self.cell != Some((i, j)) {
|
||||
self.cell = Some((i, j));
|
||||
self.items = [None; 25];
|
||||
}
|
||||
let (di, dj) = AROUND.get(k)?;
|
||||
let slot = self.items.get_mut(k)?;
|
||||
Some(*slot.get_or_insert_with(|| make(i.wrapping_add(*di), j.wrapping_add(*dj))))
|
||||
}
|
||||
}
|
||||
|
||||
/// The random point of cell (`i`, `j`) of pattern `salt`, in cells.
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(cell_size.is_finite() && cell_size > 0.0) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
let (Some(i), Some(j)) = (cell(u), cell(v)) else { return };
|
||||
let mut best = (f64::INFINITY, None);
|
||||
for (k, (di, dj)) in AROUND.iter().enumerate() {
|
||||
// A cell farther than the best point so far can't hold a nearer one.
|
||||
let (ci, cj) = (i.wrapping_add(*di), j.wrapping_add(*dj));
|
||||
if gap(ci, u).hypot(gap(cj, v)) >= best.0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = around.get((i, j), k, |a, b| point_in(1, a, b)) {
|
||||
let d = (p.x - u).hypot(p.y - v);
|
||||
if d < best.0 {
|
||||
best = (d, Some(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = best.1 {
|
||||
let at = Point::new(p.x * cell_size, p.y * cell_size);
|
||||
*out = place.index(at).and_then(|i| src.get(i)).copied().unwrap_or([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)]
|
||||
struct Dot {
|
||||
at: Point,
|
||||
radius: f64,
|
||||
z: f64,
|
||||
colour: [f32; 4],
|
||||
}
|
||||
|
||||
/// The smallest dot radius and the spread above it, in cells.
|
||||
const DOT_RADIUS: (f64, f64) = (0.45, 0.25);
|
||||
|
||||
/// Pointillize: a dot at one random point per `cell` × `cell` square, of random size around the
|
||||
/// cell's, coloured by the (softened) object there, over a white canvas where the object is.
|
||||
pub(super) fn pointillize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
if !(cell_size.is_finite() && cell_size > 0.0) {
|
||||
return;
|
||||
}
|
||||
// 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 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],
|
||||
};
|
||||
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();
|
||||
place.each(px, |x, y, out| {
|
||||
let q = place.at(x, y);
|
||||
let (u, v) = (q.x / cell_size, q.y / cell_size);
|
||||
let (Some(i), Some(j)) = (cell(u), cell(v)) else { return };
|
||||
// The dots covering the pixel (only the 3 × 3 cells around it reach it), bottom first.
|
||||
let mut hits: [(f64, f32, [f32; 4]); 9] = [(0.0, 0.0, [0.0; 4]); 9];
|
||||
let mut n = 0;
|
||||
for k in 0..9 {
|
||||
let Some(d) = around.get((i, j), k, dot) else { continue };
|
||||
let dist = (d.at.x - u).hypot(d.at.y - v) * cell_size;
|
||||
let cover = ((d.radius - dist) / place.px + 0.5).clamp(0.0, 1.0) as f32;
|
||||
if cover > 0.0
|
||||
&& d.colour[3] > 0.0
|
||||
&& let Some(slot) = hits.get_mut(n)
|
||||
{
|
||||
*slot = (d.z, cover, d.colour);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let hits = hits.get_mut(..n).unwrap_or_default();
|
||||
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let alpha = f32::from(out[3]) / 255.0;
|
||||
let mut acc = [alpha; 4];
|
||||
for (_, cover, c) in hits.iter() {
|
||||
let keep = 1.0 - c[3] * cover;
|
||||
for (a, v) in acc.iter_mut().zip(c) {
|
||||
*a = v * cover + *a * keep;
|
||||
}
|
||||
}
|
||||
let a = (acc[3].clamp(0.0, 1.0) * 255.0).round();
|
||||
let v = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round().min(a) as u8;
|
||||
*out = [v(acc[0]), v(acc[1]), v(acc[2]), a as u8];
|
||||
});
|
||||
}
|
||||
|
||||
/// A pixel's colour as the channels the Pixelate filters treat apart (straight, 0..1; see
|
||||
/// [`Channels`]), and how many of the four there are.
|
||||
fn split(p: [u8; 4], channels: Channels) -> ([f32; 4], usize) {
|
||||
let a = f32::from(p[3]);
|
||||
let [r, g, b] = [p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 });
|
||||
match channels {
|
||||
Channels::Rgb => ([r, g, b, 0.0], 3),
|
||||
Channels::Cmyk => {
|
||||
// A light black generation, as separations print: black ink takes over in the
|
||||
// shadows, coloured inks build the midtones.
|
||||
let k = (1.0 - r.max(g).max(b)).powi(2);
|
||||
let ink = |v: f32| if k < 1.0 { ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
([ink(r), ink(g), ink(b), k], 4)
|
||||
}
|
||||
Channels::CmyPlane => ([1.0 - r, 1.0 - g, 1.0 - b, 0.0], 3),
|
||||
Channels::KPlane => ([1.0 - r, 0.0, 0.0, 0.0], 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixel of channel values `v` ([`split`]'s) at coverage `alpha` (premultiplied).
|
||||
fn join(v: [f32; 4], channels: Channels, alpha: u8) -> [u8; 4] {
|
||||
let [a, b, c, d] = v.map(|x| x.clamp(0.0, 1.0));
|
||||
let rgb = match channels {
|
||||
Channels::Rgb => [a, b, c],
|
||||
Channels::Cmyk => [(1.0 - a) * (1.0 - d), (1.0 - b) * (1.0 - d), (1.0 - c) * (1.0 - d)],
|
||||
Channels::CmyPlane => [1.0 - a, 1.0 - b, 1.0 - c],
|
||||
Channels::KPlane => [1.0 - a; 3],
|
||||
};
|
||||
let al = f32::from(alpha);
|
||||
let [r, g, b] = rgb.map(|x| (x * al).round().clamp(0.0, al) as u8);
|
||||
[r, g, b, alpha]
|
||||
}
|
||||
|
||||
/// Which of Color Halftone's four screen angles channel `i` of `channels` uses: the K plane's
|
||||
/// only channel is the fourth (black) one.
|
||||
fn angle_of(channels: Channels, i: usize) -> usize {
|
||||
if channels == Channels::KPlane { 3 } else { i }
|
||||
}
|
||||
|
||||
/// The share of a `1` × `1` square that a circle of radius `rho` around its centre covers.
|
||||
fn circle_in_square(rho: f64) -> f64 {
|
||||
let half = 0.5;
|
||||
if rho <= half {
|
||||
std::f64::consts::PI * rho * rho
|
||||
} else if rho < std::f64::consts::FRAC_1_SQRT_2 {
|
||||
// The circle less the four caps beyond the square's sides.
|
||||
let cap = rho * rho * (half / rho).acos() - half * (rho * rho - half * half).sqrt();
|
||||
std::f64::consts::PI * rho * rho - 4.0 * cap
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The radius (in cells) of the dot that covers share `v` of its cell.
|
||||
fn dot_radius(v: f64) -> f64 {
|
||||
const STEPS: usize = 256;
|
||||
static TABLE: OnceLock<[f64; STEPS + 1]> = OnceLock::new();
|
||||
let table = TABLE.get_or_init(|| {
|
||||
std::array::from_fn(|k| {
|
||||
let target = k as f64 / STEPS as f64;
|
||||
let (mut lo, mut hi) = (0.0, std::f64::consts::FRAC_1_SQRT_2);
|
||||
for _ in 0..48 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if circle_in_square(mid) < target {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
hi
|
||||
})
|
||||
});
|
||||
let x = if v.is_finite() { v.clamp(0.0, 1.0) * STEPS as f64 } else { 0.0 };
|
||||
let k = (x.floor() as usize).min(STEPS - 1);
|
||||
let (a, b) = (table.get(k).copied().unwrap_or(0.0), table.get(k + 1).copied().unwrap_or(0.0));
|
||||
a + (b - a) * (x - k as f64)
|
||||
}
|
||||
|
||||
/// One channel's halftone screen: square cells at an angle, each with the dot of its mean value.
|
||||
struct Screen {
|
||||
/// Document coordinates relative to the centre → cells.
|
||||
to_cells: Affine,
|
||||
/// The first cell's indices and the grid's size in cells.
|
||||
origin: (i64, i64),
|
||||
size: (usize, usize),
|
||||
/// Per cell: the channel's sum (× coverage) and the coverage's (then the dot's radius in
|
||||
/// document units, in `sums[k][0]`).
|
||||
sums: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// The screen at `angle`° with cells `side` wide over the raster `place` covers; `None` when
|
||||
/// it would have more than `limit` cells.
|
||||
fn new(place: &Place, angle: f64, side: f64, limit: usize) -> Option<Self> {
|
||||
let to_cells = Affine::scale(1.0 / side) * Affine::rotate(angle.to_radians());
|
||||
let corners = [(0.0, 0.0), (place.w as f64, 0.0), (0.0, place.h as f64), (place.w as f64, place.h as f64)];
|
||||
let pts = corners.map(|(x, y)| to_cells * (place.to_rel * Point::new(x, y)));
|
||||
let (lo, hi) = pts.iter().fold(((f64::INFINITY, f64::INFINITY), (f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
|
||||
((lo.0.min(p.x), lo.1.min(p.y)), (hi.0.max(p.x), hi.1.max(p.y)))
|
||||
});
|
||||
let (i0, j0, i1, j1) = (cell(lo.0)?.checked_sub(1)?, cell(lo.1)?.checked_sub(1)?, cell(hi.0)?.checked_add(1)?, cell(hi.1)?.checked_add(1)?);
|
||||
let gw = usize::try_from(i1.checked_sub(i0)?.checked_add(1)?).ok()?;
|
||||
let gh = usize::try_from(j1.checked_sub(j0)?.checked_add(1)?).ok()?;
|
||||
(gw.checked_mul(gh)? <= limit).then(|| Self { to_cells, origin: (i0, j0), size: (gw, gh), sums: vec![[0.0; 2]; gw * gh] })
|
||||
}
|
||||
|
||||
/// The index of cell (`i`, `j`) in the grid.
|
||||
fn slot(&self, i: i64, j: i64) -> Option<usize> {
|
||||
let x = usize::try_from(i.checked_sub(self.origin.0)?).ok().filter(|x| *x < self.size.0)?;
|
||||
let y = usize::try_from(j.checked_sub(self.origin.1)?).ok().filter(|y| *y < self.size.1)?;
|
||||
Some(y * self.size.0 + x)
|
||||
}
|
||||
}
|
||||
|
||||
/// Color Halftone: each channel screened at its angle in square cells sized so that a dot of
|
||||
/// `max_radius` fills one; each cell's dot covers the share of it that is the channel's mean
|
||||
/// there (white stays white, black black). Coverage stays as it is.
|
||||
pub(super) fn color_halftone(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, max_radius: f64, angles: [f64; 4]) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let side = max_radius * std::f64::consts::SQRT_2;
|
||||
// Cells under 3 pixels can't show their dots: on average a screen is the image itself.
|
||||
if !(side.is_finite() && side / place.px >= 3.0) {
|
||||
return;
|
||||
}
|
||||
let channels = space.channels;
|
||||
let n = split([0; 4], channels).1;
|
||||
let limit = w.saturating_mul(h).saturating_add(4096);
|
||||
let mut screens = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let angle = angles.get(angle_of(channels, i)).copied().unwrap_or(0.0);
|
||||
let Some(s) = Screen::new(&place, angle, side, limit) else { return };
|
||||
screens.push(s);
|
||||
}
|
||||
// Each cell's mean of each channel, weighted by coverage.
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let (v, _) = split(*p, channels);
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
let q = place.at(x, y);
|
||||
for (s, v) in screens.iter_mut().zip(v) {
|
||||
let c = s.to_cells * q;
|
||||
if let (Some(i), Some(j)) = (cell(c.x), cell(c.y))
|
||||
&& let Some(sum) = s.slot(i, j).and_then(|k| s.sums.get_mut(k))
|
||||
{
|
||||
sum[0] += v * a;
|
||||
sum[1] += a;
|
||||
}
|
||||
}
|
||||
});
|
||||
for s in &mut screens {
|
||||
for sum in &mut s.sums {
|
||||
let mean = if sum[1] > 1e-6 { f64::from(sum[0] / sum[1]) } else { 0.0 };
|
||||
// Full strength fills the cell, corners too; none leaves no speck.
|
||||
let radius = match mean {
|
||||
m if m >= 254.5 / 255.0 => f64::INFINITY,
|
||||
m if m <= 0.5 / 255.0 => 0.0,
|
||||
m => dot_radius(m) * side,
|
||||
};
|
||||
sum[0] = radius as f32;
|
||||
}
|
||||
}
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let mut v = [0.0f32; 4];
|
||||
for (s, out) in screens.iter().zip(v.iter_mut()) {
|
||||
let c = s.to_cells * q;
|
||||
let (Some(i), Some(j)) = (cell(c.x), cell(c.y)) else { continue };
|
||||
// A dot reaches past its cell into the neighbours on the pixel's side only.
|
||||
let (si, sj) = (if c.x - (i as f64) < 0.5 { -1 } else { 1 }, if c.y - (j as f64) < 0.5 { -1 } else { 1 });
|
||||
for (di, dj) in [(0, 0), (si, 0), (0, sj), (si, sj)] {
|
||||
let (ci, cj) = (i.wrapping_add(di), j.wrapping_add(dj));
|
||||
let radius = s.slot(ci, cj).and_then(|k| s.sums.get(k)).map_or(0.0, |sum| f64::from(sum[0]));
|
||||
if radius <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let dist = (c.x - (ci as f64 + 0.5)).hypot(c.y - (cj as f64 + 0.5)) * side;
|
||||
*out = out.max(((radius - dist) / place.px + 0.5).clamp(0.0, 1.0) as f32);
|
||||
}
|
||||
}
|
||||
*p = join(v, channels, p[3]);
|
||||
});
|
||||
}
|
||||
|
||||
/// Mezzotint: every channel turned fully on or off where it is above or below a random threshold
|
||||
/// pattern (each channel its own), so the image becomes dots or streaks of pure colours.
|
||||
pub(super) fn mezzotint(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, kind: Mezzotint) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let channels = space.channels;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (mut v, n) = split(*p, channels);
|
||||
for (c, value) in v.iter_mut().enumerate().take(n) {
|
||||
let t = threshold(kind, 16 + c as u64, q);
|
||||
*value = if f64::from(*value) > t { 1.0 } else { 0.0 };
|
||||
}
|
||||
*p = join(v, channels, p[3]);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
match kind {
|
||||
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);
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ use crate::{RasterFx, raster_effects};
|
|||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left).
|
||||
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) }
|
||||
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0), channels: Channels::Rgb }
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
|
|
@ -224,7 +224,7 @@ 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 };
|
||||
let s = PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO, channels: Channels::Rgb };
|
||||
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()
|
||||
};
|
||||
|
|
@ -253,7 +253,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) };
|
||||
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb };
|
||||
let cases = [
|
||||
("blur.radial", json!({"quality": "draft"})),
|
||||
("blur.radial", json!({"quality": "good"})),
|
||||
|
|
@ -261,6 +261,12 @@ fn pixel_timings() {
|
|||
("blur.smart", json!({"quality": "low"})),
|
||||
("blur.smart", json!({"quality": "high", "radius": 30})),
|
||||
("sharpen.unsharpMask", json!({"radius": 20})),
|
||||
("pixelate.colorHalftone", json!({"maxRadius": 4})),
|
||||
("pixelate.crystallize", json!({"cellSize": 3})),
|
||||
("pixelate.crystallize", json!({"cellSize": 300})),
|
||||
("pixelate.mezzotint", json!({"type": "grainyDots"})),
|
||||
("pixelate.pointillize", json!({"cellSize": 3})),
|
||||
("pixelate.pointillize", json!({"cellSize": 300})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let mut d = src.clone();
|
||||
|
|
@ -269,3 +275,205 @@ fn pixel_timings() {
|
|||
println!("{id} {p}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixelate_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("pixelate.colorHalftone", json!({})), PixelFx::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] });
|
||||
assert_eq!(
|
||||
fx("pixelate.colorHalftone", json!({"maxRadius": 1e9, "channel1": -1e9, "channel2": 30, "channel4": "x"})),
|
||||
PixelFx::ColorHalftone { max_radius: 127.0, angles: [-360.0, 30.0, 90.0, 45.0] }
|
||||
);
|
||||
assert_eq!(fx("pixelate.colorHalftone", json!({"maxRadius": 0})), PixelFx::ColorHalftone { max_radius: 4.0, angles: [108.0, 162.0, 90.0, 45.0] });
|
||||
assert_eq!(fx("pixelate.crystallize", json!({})), PixelFx::Crystallize { cell: 10.0 });
|
||||
assert_eq!(fx("pixelate.crystallize", json!({"cellSize": -4})), PixelFx::Crystallize { cell: 3.0 });
|
||||
assert_eq!(fx("pixelate.pointillize", json!({})), PixelFx::Pointillize { cell: 5.0 });
|
||||
assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 1e300})), PixelFx::Pointillize { cell: 300.0 });
|
||||
assert_eq!(fx("pixelate.mezzotint", json!({})), PixelFx::Mezzotint { kind: Mezzotint::FINE_DOTS });
|
||||
assert_eq!(fx("pixelate.mezzotint", json!({"type": "LONGSTROKES"})), PixelFx::Mezzotint { kind: Mezzotint::Lines { length: 24, width: 2 } });
|
||||
for junk in [json!({"type": 7}), json!({"type": "nonsense"})] {
|
||||
assert_eq!(fx("pixelate.mezzotint", junk), PixelFx::Mezzotint { kind: Mezzotint::FINE_DOTS });
|
||||
}
|
||||
// Every Type is a pattern of its own, documented in the catalogue.
|
||||
let doc = crate::effect_info("pixelate.mezzotint").unwrap().params;
|
||||
let kinds: Vec<Mezzotint> = MEZZOTINT_TYPES.iter().map(|(_, v)| Mezzotint::parse(v)).collect();
|
||||
for (i, (_, v)) in MEZZOTINT_TYPES.iter().enumerate() {
|
||||
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
|
||||
assert!(kinds.iter().skip(i + 1).all(|k| *k != kinds[i]), "{v}");
|
||||
}
|
||||
// Dots and crystals reach beyond the object; screens and grain stay on it.
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
assert_eq!(fx("pixelate.crystallize", json!({"cellSize": 20})).outset(b), 30.0);
|
||||
assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 20})).outset(b), 30.0);
|
||||
assert_eq!(fx("pixelate.colorHalftone", json!({})).outset(b), 0.0);
|
||||
assert_eq!(fx("pixelate.mezzotint", json!({})).reach(), Some(0.0));
|
||||
}
|
||||
|
||||
/// Fraction of `d`'s pixels for which `f` holds.
|
||||
fn share(d: &[u8], f: impl Fn(&[u8; 4]) -> bool) -> f64 {
|
||||
let px = d.as_chunks::<4>().0;
|
||||
px.iter().filter(|p| f(p)).count() as f64 / px.len() as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_halftone_screens_midtones_into_dots_and_keeps_white_and_black() {
|
||||
let (w, h) = (64, 64);
|
||||
let halftone = fx("pixelate.colorHalftone", json!({}));
|
||||
for v in [0, 255] {
|
||||
let mut d = image(w, h, |_, _| grey(v));
|
||||
halftone.apply(&mut d, w, h, &space(w, h));
|
||||
assert_eq!(d, image(w, h, |_, _| grey(v)), "{v} stays");
|
||||
}
|
||||
// Mid grey: each channel's dots cover about half its screen, so the tone stays.
|
||||
let mut d = image(w, h, |_, _| grey(128));
|
||||
halftone.apply(&mut d, w, h, &space(w, h));
|
||||
let px = d.as_chunks::<4>().0;
|
||||
for c in 0..3 {
|
||||
let mean = px.iter().map(|p| f64::from(p[c])).sum::<f64>() / px.len() as f64;
|
||||
assert!((mean - 128.0).abs() < 16.0, "channel {c}: {mean}");
|
||||
}
|
||||
assert!(share(&d, |p| p[0] == 255) > 0.25 && share(&d, |p| p[0] == 0) > 0.25, "dots, not grey");
|
||||
assert!(share(&d, |p| p[0] != p[1]) > 0.1, "the channels' screens lie at different angles");
|
||||
assert!(px.iter().all(|p| p[3] == 255), "coverage stays");
|
||||
// Transparency stays transparent.
|
||||
let mut half = image(w, h, |x, _| if x < 32 { grey(160) } else { [0; 4] });
|
||||
halftone.apply(&mut half, w, h, &space(w, h));
|
||||
assert!((0..h).all(|y| (32..w).all(|x| at(&half, w, x, y) == [0; 4])));
|
||||
// Dots are bigger where the image is brighter.
|
||||
let mut ramp = image(w, h, |x, _| grey((x * 4) as u8));
|
||||
halftone.apply(&mut ramp, w, h, &space(w, h));
|
||||
let lit = |x0: usize| (0..h).flat_map(|y| (x0..x0 + 16).map(move |x| (x, y))).filter(|(x, y)| at(&ramp, w, *x, *y)[0] > 127).count();
|
||||
assert!(lit(8) < lit(40), "{} < {}", lit(8), lit(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_halftone_screens_inks_in_cmyk_documents() {
|
||||
let (w, h) = (64, 64);
|
||||
let halftone = fx("pixelate.colorHalftone", json!({}));
|
||||
let cmyk = PixelSpace { channels: Channels::Cmyk, ..space(w, h) };
|
||||
// A neutral grey prints with all four inks: dots of each on white paper, black where they
|
||||
// all overlap.
|
||||
let mut d = image(w, h, |_, _| grey(128));
|
||||
halftone.apply(&mut d, w, h, &cmyk);
|
||||
assert!(share(&d, |p| *p == grey(255)) > 0.02 && share(&d, |p| *p == grey(0)) > 0.02);
|
||||
for (ink, colour) in [("cyan", [0, 255, 255, 255]), ("magenta", [255, 0, 255, 255]), ("yellow", [255, 255, 0, 255])] {
|
||||
assert!(share(&d, |p| *p == colour) > 0.0, "{ink} dots alone");
|
||||
}
|
||||
// White paper stays bare; pure cyan ink fills its cells.
|
||||
let mut white = image(w, h, |_, _| grey(255));
|
||||
halftone.apply(&mut white, w, h, &cmyk);
|
||||
assert_eq!(white, image(w, h, |_, _| grey(255)));
|
||||
let mut cyan = image(w, h, |_, _| [0, 255, 255, 255]);
|
||||
halftone.apply(&mut cyan, w, h, &cmyk);
|
||||
assert_eq!(cyan, image(w, h, |_, _| [0, 255, 255, 255]));
|
||||
// Ink planes: the complemented K plane screens one grey channel at the black angle (45°,
|
||||
// the same in every quarter turn).
|
||||
let mut k = image(w, h, |_, _| grey(128));
|
||||
halftone.apply(&mut k, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
|
||||
assert!(k.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "one grey channel");
|
||||
assert!(share(&k, |p| p[0] == 255) > 0.25 && share(&k, |p| p[0] == 0) > 0.25);
|
||||
let mut turned = image(w, h, |_, _| grey(128));
|
||||
fx("pixelate.colorHalftone", json!({"channel4": 135})).apply(&mut turned, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
|
||||
let mut other = image(w, h, |_, _| grey(128));
|
||||
fx("pixelate.colorHalftone", json!({"channel4": 30})).apply(&mut other, w, h, &PixelSpace { channels: Channels::KPlane, ..space(w, h) });
|
||||
assert_eq!(turned, k, "channel 4 sets the K plane's angle");
|
||||
assert_ne!(other, k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crystallize_redraws_in_solid_polygons() {
|
||||
let (w, h) = (48, 48);
|
||||
let crystals = fx("pixelate.crystallize", json!({"cellSize": 6}));
|
||||
// One colour stays that colour (away from the raster's edges, where crystals around points
|
||||
// beyond it take the transparency there).
|
||||
let mut d = image(w, h, |_, _| [200, 100, 50, 255]);
|
||||
crystals.apply(&mut d, w, h, &space(w, h));
|
||||
assert!((9..39).all(|y| (9..39).all(|x| at(&d, w, x, y) == [200, 100, 50, 255])));
|
||||
// Two halves: every pixel takes one of the colours, and the border between them zigzags.
|
||||
let (red, blue) = ([200, 0, 0, 255], [0, 0, 200, 255]);
|
||||
let src = image(w, h, |x, _| if x < 24 { red } else { blue });
|
||||
let mut d = src.clone();
|
||||
crystals.apply(&mut d, w, h, &space(w, h));
|
||||
assert!((9..39).all(|y| (9..39).all(|x| [red, blue].contains(&at(&d, w, x, y)))));
|
||||
let crossed = (9..39).flat_map(|y| (9..39).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) != at(&src, w, *x, *y)).count();
|
||||
assert!(crossed > 10, "{crossed}");
|
||||
let border: std::collections::BTreeSet<usize> = (9..39).map(|y| (9..39).find(|x| at(&d, w, *x, y) == blue).unwrap()).collect();
|
||||
assert!(border.len() > 2, "{border:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mezzotint_turns_channels_fully_on_or_off() {
|
||||
let (w, h) = (64, 64);
|
||||
let grey_square = image(w, h, |x, y| if (8..56).contains(&x) && (8..56).contains(&y) { grey(128) } else { [0; 4] });
|
||||
let mut seen = Vec::new();
|
||||
for (_, kind) in MEZZOTINT_TYPES {
|
||||
let f = fx("pixelate.mezzotint", json!({ "type": kind }));
|
||||
for v in [0, 255] {
|
||||
let mut d = image(w, h, |_, _| grey(v));
|
||||
f.apply(&mut d, w, h, &space(w, h));
|
||||
assert_eq!(d, image(w, h, |_, _| grey(v)), "{kind}: {v} stays");
|
||||
}
|
||||
let mut d = grey_square.clone();
|
||||
f.apply(&mut d, w, h, &space(w, h));
|
||||
let px = d.as_chunks::<4>().0;
|
||||
assert!(px.iter().all(|p| p[..3].iter().all(|c| *c == 0 || *c == p[3])), "{kind}: pure colours");
|
||||
let on = px.iter().filter(|p| p[3] > 0 && p[0] > 0).count() as f64 / (48.0 * 48.0);
|
||||
assert!((0.25..0.75).contains(&on), "{kind}: {on}");
|
||||
assert!(px.iter().any(|p| p[3] > 0 && p[0] != p[1]), "{kind}: each channel its own pattern");
|
||||
assert!(d.iter().zip(&grey_square).skip(3).step_by(4).all(|(a, b)| a == b), "{kind}: coverage stays");
|
||||
assert!(!seen.contains(&d), "{kind}: a pattern of its own");
|
||||
seen.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointillize_paints_dots_on_a_white_canvas() {
|
||||
let (w, h) = (80, 80);
|
||||
let red = [200, 0, 0, 255];
|
||||
let src = image(w, h, |x, y| if (20..60).contains(&x) && (20..60).contains(&y) { red } else { [0; 4] });
|
||||
let mut d = src.clone();
|
||||
fx("pixelate.pointillize", json!({})).apply(&mut d, w, h, &space(w, h));
|
||||
let inside: Vec<[u8; 4]> = (26..54).flat_map(|y| (26..54).map(move |x| (x, y))).map(|(x, y)| at(&d, w, x, y)).collect();
|
||||
let canvas = inside.iter().filter(|p| **p == grey(255)).count();
|
||||
let dots = inside.iter().filter(|p| **p == red).count();
|
||||
assert!(canvas > inside.len() / 20 && dots > inside.len() / 3, "{canvas} canvas, {dots} dots of {}", inside.len());
|
||||
assert!(inside.iter().all(|p| p[3] == 255 && p[1] == p[2] && p[0] >= p[1]), "red over white only");
|
||||
// Dots at the edges reach a little past the object; far from it stays transparent.
|
||||
assert!((0..h).any(|y| (14..20).any(|x| at(&d, w, x, y)[3] > 0)));
|
||||
assert!((0..h).all(|y| (0..10).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]));
|
||||
}
|
||||
|
||||
/// The patterns lie in document space around the object's centre: the same at twice the
|
||||
/// resolution, and moving with the object.
|
||||
#[test]
|
||||
fn pixelate_patterns_follow_the_object_at_any_resolution() {
|
||||
let (w, h) = (40, 40);
|
||||
let art = |x: f64, y: f64| -> [u8; 4] {
|
||||
if (8.0..32.0).contains(&x) && (8.0..32.0).contains(&y) { [(x * 6.0) as u8, (y * 6.0) as u8, 120, 255] } else { [0; 4] }
|
||||
};
|
||||
for (id, p) in [
|
||||
("pixelate.colorHalftone", json!({"maxRadius": 4})),
|
||||
("pixelate.crystallize", json!({"cellSize": 4})),
|
||||
("pixelate.mezzotint", json!({"type": "coarseDots"})),
|
||||
("pixelate.pointillize", json!({"cellSize": 4})),
|
||||
] {
|
||||
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));
|
||||
// Twice the pixels for the same art, averaged back down.
|
||||
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 < 24.0, "{id}: {diff}");
|
||||
// The object and its raster 7 × 3 points further: the same pixels.
|
||||
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(27.0, 23.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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen ›
|
||||
//! Unsharp Mask): 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.
|
||||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate ›
|
||||
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask): 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.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
|
@ -16,6 +17,10 @@ const EFFECTS: [(&str, &str); 3] = [
|
|||
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
|
||||
];
|
||||
|
||||
/// Effect › Pixelate, at their defaults.
|
||||
const PIXELATE: [(&str, &str); 4] =
|
||||
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
|
||||
|
||||
/// A group of a red square and a darker red stripe across it; returns the group's id.
|
||||
fn striped() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
|
|
@ -52,6 +57,10 @@ 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 (id, _) in PIXELATE {
|
||||
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", "Pixelate"])), "{id}");
|
||||
}
|
||||
// Junk parameters are stored as given and read clamped; the canvas still draws.
|
||||
for (id, _) in EFFECTS {
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
|
||||
|
|
@ -71,7 +80,7 @@ fn listed_applied_edited_and_undone() {
|
|||
|
||||
#[test]
|
||||
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
for (id, params) in EFFECTS {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE) {
|
||||
let (mut s, g) = striped();
|
||||
let plain = render(&s);
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
|
|
@ -100,7 +109,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 {
|
||||
for (id, params) in EFFECTS.into_iter().chain(PIXELATE) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -278,6 +278,10 @@ fn structured_junk() {
|
|||
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
|
||||
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
|
||||
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
|
||||
("effect.apply", json!({"effect": "pixelate.colorHalftone", "params": {"maxRadius": 1e308, "channel1": "1e999", "channel4": null}})),
|
||||
("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": "no.such.effect"})),
|
||||
("effect.remove", json!({"index": 99})),
|
||||
("effect.setParams", json!({"index": 0, "params": null})),
|
||||
|
|
|
|||
|
|
@ -595,7 +595,13 @@ impl Renderer {
|
|||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(wu, hu);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let space = effects::PixelSpace { to_doc, px, center: content.reach.center() };
|
||||
let channels = match f.ink {
|
||||
Ink::Cmy => effects::pixel::Channels::CmyPlane,
|
||||
Ink::K => effects::pixel::Channels::KPlane,
|
||||
Ink::Display if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
|
||||
Ink::Display => effects::pixel::Channels::Rgb,
|
||||
};
|
||||
let space = effects::PixelSpace { to_doc, px, center: content.reach.center(), channels };
|
||||
let (wz, hz) = (wu as usize, hu as usize);
|
||||
for fx in chain {
|
||||
match fx {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
|
|||
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
|
||||
("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),
|
||||
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
|
||||
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -350,6 +350,11 @@ pub(super) fn humanize(k: &str) -> String {
|
|||
"Sy" => "Vertical %".into(),
|
||||
"Radius1" => "Radius 1".into(),
|
||||
"Radius2" => "Radius 2".into(),
|
||||
"Max Radius" => "Max. Radius:".into(),
|
||||
"Channel1" => "Channel 1:".into(),
|
||||
"Channel2" => "Channel 2:".into(),
|
||||
"Channel3" => "Channel 3:".into(),
|
||||
"Channel4" => "Channel 4:".into(),
|
||||
"Include Cmy Blacks" => "Include Blacks with CMY:".into(),
|
||||
"Align To Path" => "Align to Path:".into(),
|
||||
_ => format!("{s}:"),
|
||||
|
|
|
|||
|
|
@ -168,7 +168,15 @@ fn effect_preview_is_kept_as_one_undo_step() {
|
|||
|
||||
#[test]
|
||||
fn raster_filter_dialogs_preview_and_apply() {
|
||||
for (effect, key, value) in [("blur.radial", "method", "zoom"), ("blur.smart", "quality", "high"), ("sharpen.unsharpMask", "amount", "120")] {
|
||||
for (effect, key, value) in [
|
||||
("blur.radial", "method", "zoom"),
|
||||
("blur.smart", "quality", "high"),
|
||||
("sharpen.unsharpMask", "amount", "120"),
|
||||
("pixelate.colorHalftone", "channel1", "30"),
|
||||
("pixelate.crystallize", "cellSize", "20"),
|
||||
("pixelate.mezzotint", "type", "longLines"),
|
||||
("pixelate.pointillize", "cellSize", "12"),
|
||||
] {
|
||||
let mut app = app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
|
|
@ -188,6 +196,13 @@ fn raster_filter_dialogs_preview_and_apply() {
|
|||
let ranks: Vec<usize> = ["radius", "threshold", "quality"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
|
||||
assert!(ranks.windows(2).all(|w| w[0] < w[1]), "{ranks:?}");
|
||||
assert_eq!(super::effect::doc_rank(doc, "nope"), usize::MAX);
|
||||
let doc = vectorcraft_effects::effect_info("pixelate.colorHalftone").unwrap().params;
|
||||
let ranks: Vec<usize> = ["maxRadius", "channel1", "channel2", "channel3", "channel4"].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:?}");
|
||||
// Labels as the dialogs show them.
|
||||
assert_eq!(super::form::humanize("maxRadius"), "Max. Radius:");
|
||||
assert_eq!(super::form::humanize("channel3"), "Channel 3:");
|
||||
assert_eq!(super::form::humanize("cellSize"), "Cell Size:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -652,3 +652,8 @@
|
|||
Smart Blur… Chytré rozostření…
|
||||
Sharpen Zostření
|
||||
Unsharp Mask… Maska rozostření…
|
||||
Pixelate Pixelizace
|
||||
Color Halftone… Barevný polotón…
|
||||
Crystallize… Krystalizace…
|
||||
Mezzotint… Mezzotinta…
|
||||
Pointillize… Pointilismus…
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -21,6 +21,31 @@
|
|||
Smart Blur… Desenfoque inteligente…
|
||||
Sharpen Enfocar
|
||||
Unsharp Mask… Máscara de enfoque…
|
||||
Pixelate Pixelizar
|
||||
Color Halftone… Semitono de color…
|
||||
Crystallize… Cristalizar…
|
||||
Mezzotint… Grabado a media tinta…
|
||||
Pointillize… Puntillismo…
|
||||
Color Halftone Semitono de color
|
||||
Crystallize Cristalizar
|
||||
Mezzotint Grabado a media tinta
|
||||
Pointillize Puntillismo
|
||||
Max. Radius Radio máx.
|
||||
Channel 1 Canal 1
|
||||
Channel 2 Canal 2
|
||||
Channel 3 Canal 3
|
||||
Channel 4 Canal 4
|
||||
Cell Size Tamaño de celda
|
||||
Fine Dots Puntos finos
|
||||
Medium Dots Puntos medios
|
||||
Grainy Dots Puntos granulados
|
||||
Coarse Dots Puntos gruesos
|
||||
Short Lines Líneas cortas
|
||||
Medium Lines Líneas medias
|
||||
Long Lines Líneas largas
|
||||
Short Strokes Trazos cortos
|
||||
Medium Strokes Trazos medios
|
||||
Long Strokes Trazos largos
|
||||
Radial Blur Desenfoque radial
|
||||
Smart Blur Desenfoque inteligente
|
||||
Unsharp Mask Máscara de enfoque
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -22,6 +22,31 @@
|
|||
Smart Blur… Flou intelligent…
|
||||
Sharpen Netteté
|
||||
Unsharp Mask… Masque de netteté…
|
||||
Pixelate Pixellisation
|
||||
Color Halftone… Demi-teintes couleur…
|
||||
Crystallize… Cristalliser…
|
||||
Mezzotint… Manière noire…
|
||||
Pointillize… Pointillisme…
|
||||
Color Halftone Demi-teintes couleur
|
||||
Crystallize Cristalliser
|
||||
Mezzotint Manière noire
|
||||
Pointillize Pointillisme
|
||||
Max. Radius Rayon max.
|
||||
Channel 1 Couche 1
|
||||
Channel 2 Couche 2
|
||||
Channel 3 Couche 3
|
||||
Channel 4 Couche 4
|
||||
Cell Size Taille de cellule
|
||||
Fine Dots Points fins
|
||||
Medium Dots Points moyens
|
||||
Grainy Dots Points granuleux
|
||||
Coarse Dots Gros points
|
||||
Short Lines Lignes courtes
|
||||
Medium Lines Lignes moyennes
|
||||
Long Lines Lignes longues
|
||||
Short Strokes Traits courts
|
||||
Medium Strokes Traits moyens
|
||||
Long Strokes Traits longs
|
||||
Radial Blur Flou radial
|
||||
Smart Blur Flou intelligent
|
||||
Unsharp Mask Masque de netteté
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -21,6 +21,31 @@
|
|||
Smart Blur… Sfocatura intelligente…
|
||||
Sharpen Nitidezza
|
||||
Unsharp Mask… Maschera di contrasto…
|
||||
Pixelate Effetto pixel
|
||||
Color Halftone… Mezzetinte colore…
|
||||
Crystallize… Cristallizza…
|
||||
Mezzotint… Mezzatinta…
|
||||
Pointillize… Puntinismo…
|
||||
Color Halftone Mezzetinte colore
|
||||
Crystallize Cristallizza
|
||||
Mezzotint Mezzatinta
|
||||
Pointillize Puntinismo
|
||||
Max. Radius Raggio max.
|
||||
Channel 1 Canale 1
|
||||
Channel 2 Canale 2
|
||||
Channel 3 Canale 3
|
||||
Channel 4 Canale 4
|
||||
Cell Size Dimensione cella
|
||||
Fine Dots Punti fini
|
||||
Medium Dots Punti medi
|
||||
Grainy Dots Punti granulosi
|
||||
Coarse Dots Punti grossi
|
||||
Short Lines Linee corte
|
||||
Medium Lines Linee medie
|
||||
Long Lines Linee lunghe
|
||||
Short Strokes Tratti corti
|
||||
Medium Strokes Tratti medi
|
||||
Long Strokes Tratti lunghi
|
||||
Radial Blur Sfocatura radiale
|
||||
Smart Blur Sfocatura intelligente
|
||||
Unsharp Mask Maschera di contrasto
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3217,6 +3217,31 @@ theme Appearance 外観
|
|||
Smart Blur… ぼかし (詳細)…
|
||||
Sharpen シャープ
|
||||
Unsharp Mask… アンシャープマスク…
|
||||
Pixelate ピクセレート
|
||||
Color Halftone… カラーハーフトーン…
|
||||
Crystallize… 結晶化…
|
||||
Mezzotint… メゾティント…
|
||||
Pointillize… 点描…
|
||||
Color Halftone カラーハーフトーン
|
||||
Crystallize 結晶化
|
||||
Mezzotint メゾティント
|
||||
Pointillize 点描
|
||||
Max. Radius 最大半径
|
||||
Channel 1 チャンネル 1
|
||||
Channel 2 チャンネル 2
|
||||
Channel 3 チャンネル 3
|
||||
Channel 4 チャンネル 4
|
||||
Cell Size セルの大きさ
|
||||
Fine Dots 細かい点
|
||||
Medium Dots 中程度の点
|
||||
Grainy Dots 粒状の点
|
||||
Coarse Dots 粗い点
|
||||
Short Lines 短い線
|
||||
Medium Lines 中程度の線
|
||||
Long Lines 長い線
|
||||
Short Strokes 短いストローク
|
||||
Medium Strokes 中程度のストローク
|
||||
Long Strokes 長いストローク
|
||||
Radial Blur ぼかし (放射状)
|
||||
Smart Blur ぼかし (詳細)
|
||||
Unsharp Mask アンシャープマスク
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2713,3 +2713,8 @@
|
|||
Smart Blur… Desfoque Inteligente…
|
||||
Sharpen Nitidez
|
||||
Unsharp Mask… Máscara de Nitidez…
|
||||
Pixelate Pixelizar
|
||||
Color Halftone… Meio-Tom Colorido…
|
||||
Crystallize… Cristalizar…
|
||||
Mezzotint… Meia-Tinta…
|
||||
Pointillize… Pontilhar…
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -19,6 +19,31 @@
|
|||
Smart Blur… Умное размытие…
|
||||
Sharpen Резкость
|
||||
Unsharp Mask… Нерезкая маска…
|
||||
Pixelate Пикселизация
|
||||
Color Halftone… Цветные полутона…
|
||||
Crystallize… Кристаллизация…
|
||||
Mezzotint… Меццо-тинто…
|
||||
Pointillize… Пуантилизм…
|
||||
Color Halftone Цветные полутона
|
||||
Crystallize Кристаллизация
|
||||
Mezzotint Меццо-тинто
|
||||
Pointillize Пуантилизм
|
||||
Max. Radius Макс. радиус
|
||||
Channel 1 Канал 1
|
||||
Channel 2 Канал 2
|
||||
Channel 3 Канал 3
|
||||
Channel 4 Канал 4
|
||||
Cell Size Размер ячейки
|
||||
Fine Dots Мелкие точки
|
||||
Medium Dots Средние точки
|
||||
Grainy Dots Зернистые точки
|
||||
Coarse Dots Крупные точки
|
||||
Short Lines Короткие линии
|
||||
Medium Lines Средние линии
|
||||
Long Lines Длинные линии
|
||||
Short Strokes Короткие штрихи
|
||||
Medium Strokes Средние штрихи
|
||||
Long Strokes Длинные штрихи
|
||||
Radial Blur Радиальное размытие
|
||||
Smart Blur Умное размытие
|
||||
Unsharp Mask Нерезкая маска
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -20,6 +20,31 @@
|
|||
Smart Blur… Розумне розмиття…
|
||||
Sharpen Різкість
|
||||
Unsharp Mask… Нерізка маска…
|
||||
Pixelate Пікселізація
|
||||
Color Halftone… Кольорові півтони…
|
||||
Crystallize… Кристалізація…
|
||||
Mezzotint… Меццо-тинто…
|
||||
Pointillize… Пуантилізм…
|
||||
Color Halftone Кольорові півтони
|
||||
Crystallize Кристалізація
|
||||
Mezzotint Меццо-тинто
|
||||
Pointillize Пуантилізм
|
||||
Max. Radius Макс. радіус
|
||||
Channel 1 Канал 1
|
||||
Channel 2 Канал 2
|
||||
Channel 3 Канал 3
|
||||
Channel 4 Канал 4
|
||||
Cell Size Розмір комірки
|
||||
Fine Dots Дрібні точки
|
||||
Medium Dots Середні точки
|
||||
Grainy Dots Зернисті точки
|
||||
Coarse Dots Великі точки
|
||||
Short Lines Короткі лінії
|
||||
Medium Lines Середні лінії
|
||||
Long Lines Довгі лінії
|
||||
Short Strokes Короткі штрихи
|
||||
Medium Strokes Середні штрихи
|
||||
Long Strokes Довгі штрихи
|
||||
Radial Blur Радіальне розмиття
|
||||
Smart Blur Розумне розмиття
|
||||
Unsharp Mask Нерізка маска
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -3468,6 +3468,31 @@ axis Both 两者
|
|||
Smart Blur… 智能模糊…
|
||||
Sharpen 锐化
|
||||
Unsharp Mask… 反锐化遮罩…
|
||||
Pixelate 像素化
|
||||
Color Halftone… 彩色半调…
|
||||
Crystallize… 晶格化…
|
||||
Mezzotint… 铜版雕刻…
|
||||
Pointillize… 点彩…
|
||||
Color Halftone 彩色半调
|
||||
Crystallize 晶格化
|
||||
Mezzotint 铜版雕刻
|
||||
Pointillize 点彩
|
||||
Max. Radius 最大半径
|
||||
Channel 1 色版 1
|
||||
Channel 2 色版 2
|
||||
Channel 3 色版 3
|
||||
Channel 4 色版 4
|
||||
Cell Size 单元格大小
|
||||
Fine Dots 细点
|
||||
Medium Dots 中等点
|
||||
Grainy Dots 颗粒点
|
||||
Coarse Dots 粗点
|
||||
Short Lines 短线
|
||||
Medium Lines 中等线
|
||||
Long Lines 长线
|
||||
Short Strokes 短笔画
|
||||
Medium Strokes 中等笔画
|
||||
Long Strokes 长笔画
|
||||
Radial Blur 径向模糊
|
||||
Smart Blur 智能模糊
|
||||
Unsharp Mask 反锐化遮罩
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3246,6 +3246,31 @@ axis Both 兩者
|
|||
Smart Blur… 智慧型模糊…
|
||||
Sharpen 銳利化
|
||||
Unsharp Mask… 反銳利化遮罩…
|
||||
Pixelate 像素化
|
||||
Color Halftone… 彩色半色調…
|
||||
Crystallize… 結晶化…
|
||||
Mezzotint… 網線銅版…
|
||||
Pointillize… 點描…
|
||||
Color Halftone 彩色半色調
|
||||
Crystallize 結晶化
|
||||
Mezzotint 網線銅版
|
||||
Pointillize 點描
|
||||
Max. Radius 最大半徑
|
||||
Channel 1 色版 1
|
||||
Channel 2 色版 2
|
||||
Channel 3 色版 3
|
||||
Channel 4 色版 4
|
||||
Cell Size 單元大小
|
||||
Fine Dots 細點
|
||||
Medium Dots 中等點
|
||||
Grainy Dots 顆粒點
|
||||
Coarse Dots 粗點
|
||||
Short Lines 短線
|
||||
Medium Lines 中等線
|
||||
Long Lines 長線
|
||||
Short Strokes 短筆畫
|
||||
Medium Strokes 中等筆畫
|
||||
Long Strokes 長筆畫
|
||||
Radial Blur 放射狀模糊
|
||||
Smart Blur 智慧型模糊
|
||||
Unsharp Mask 反銳利化遮罩
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3305,7 +3305,8 @@ 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]); 2] = [("Blur", &["Effect", "Blur"]), ("Sharpen", &["Effect", "Sharpen"])];
|
||||
const RASTER_MENUS: [(&str, &[&str]); 3] =
|
||||
[("Blur", &["Effect", "Blur"]), ("Pixelate", &["Effect", "Pixelate"]), ("Sharpen", &["Effect", "Sharpen"])];
|
||||
|
||||
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
|
||||
fn effect_menu() -> Vec<Item> {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,20 @@ fn effect_menu_has_a_vector_effects_section() {
|
|||
assert!(effect.iter().any(|i| matches!(i, Item::Header("Vector Effects"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_menu_lists_the_pixelate_filters_between_blur_and_sharpen() {
|
||||
let app = app();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let pixelate: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Pixelate"]).collect();
|
||||
let labels: Vec<&str> = pixelate.iter().map(|e| e.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Color Halftone…", "Crystallize…", "Mezzotint…", "Pointillize…"]);
|
||||
assert!(pixelate.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("Blur") < at("Pixelate") && at("Pixelate") < at("Sharpen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_submenus_are_disabled_placeholders() {
|
||||
let app = app();
|
||||
|
|
|
|||
Loading…
Reference in New Issue