Merge pull request #1017 from storytold/feat/brush-strokes-effects

Part of gap 2: Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e)
This commit is contained in:
BFlat 2026-10-11 01:24:49 +07:00 committed by GitHub
commit 6f7f32c64d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1420 additions and 64 deletions

View File

@ -225,7 +225,7 @@ The scores are self-assessed from our code against Illustrator's public document
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- most of the Photoshop-style raster effects (20 of 57) and the Effect Gallery;
- about half of the Photoshop-style raster effects (29 of 57 still missing) and the Effect Gallery;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness ~62% by the standard's additive method; per-audience hours; essentials-user score ~75% added; ready for real work re-examined: 55% full, 65% mainstream; core-workflow gate applied: stays alpha; earlier the same day: full re-measure against Illustrator 2026 30.x, restructured to the craftrules progress-docs standard, detail moved to `docs/`) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@ -22,7 +22,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 20/57, 3D 1/4 (initial Revolve) |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 28/57, 3D 1/4 (initial Revolve) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@ -31,7 +31,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (30–50 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
@ -69,7 +69,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| Strokes, brushes, width profiles | 88% | 8–15 |
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
| Live vector effects | 85% | 6–10 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 40% | 30–50 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 48% | 24–40 |
| 3D and Materials | ~8% | 50–85 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
@ -116,13 +116,14 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
3. **Launch and stability on real machines** (G10): 20–35 h.
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
5. **Raster effects** (G2) alongside: 37 filters and the Effect Gallery, 30–50 h.
5. **Raster effects** (G2) alongside: 29 filters and the Effect Gallery, 24–40 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2) |
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |

View File

@ -131,6 +131,11 @@ fn lengths_of(id: &str) -> Lengths {
"stylize.dropShadow" => always(&["x", "y", "blur"]),
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
"brushStrokes.accentedEdges" => always(&["edgeWidth"]),
"brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]),
"brushStrokes.spatter" => always(&["sprayRadius"]),
"brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]),
"brushStrokes.sumiE" => always(&["strokeWidth"]),
"pixelate.colorHalftone" => always(&["maxRadius"]),
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
"texture.craquelure" => always(&["crackSpacing"]),
@ -149,6 +154,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
const DISTORT: &[&str] = &["Effect", "Distort"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
@ -306,6 +312,62 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
),
r(
"brushStrokes.accentedEdges",
"Accented Edges…",
BRUSH_STROKES,
"{edgeWidth: pt 1..14 (2), edgeBrightness: 0..50 (38; high: white chalk, low: black ink), smoothness: 1..15 (5; softens the object first and leaves its faint edges out)} accentuates the object's edges",
json!({"edgeWidth": 2.0, "edgeBrightness": 38.0, "smoothness": 5.0}),
),
r(
"brushStrokes.angledStrokes",
"Angled Strokes…",
BRUSH_STROKES,
"{directionBalance: 0..100 (50; 0: every stroke a left diagonal, 100: every stroke a right diagonal, between: light areas right, dark areas left), strokeLength: pt 3..50 (15), sharpness: 0..10 (3)} repaints the object with diagonal strokes, the light areas' going the opposite way to the dark areas'",
json!({"directionBalance": 50.0, "strokeLength": 15.0, "sharpness": 3.0}),
),
r(
"brushStrokes.crosshatch",
"Crosshatch…",
BRUSH_STROKES,
"{strokeLength: pt 3..50 (9), sharpness: 0..20 (6), strength: 1..3 (1; passes of hatching)} keeps the object's detail under pencil hatching along both diagonals and roughens its edges",
json!({"strokeLength": 9.0, "sharpness": 6.0, "strength": 1.0}),
),
r(
"brushStrokes.darkStrokes",
"Dark Strokes…",
BRUSH_STROKES,
"{balance: 0..10 (5; the higher, the more of the object counts as dark), blackIntensity: 0..10 (6), whiteIntensity: 0..10 (2)} paints the dark areas closer to black with short strokes and the light areas with long white strokes",
json!({"balance": 5.0, "blackIntensity": 6.0, "whiteIntensity": 2.0}),
),
r(
"brushStrokes.inkOutlines",
"Ink Outlines…",
BRUSH_STROKES,
"{strokeLength: pt 1..50 (4), darkIntensity: 0..50 (20), lightIntensity: 0..50 (10)} redraws the object in pen and ink: fine outlines over its edges and ink strokes in its shadows",
json!({"strokeLength": 4.0, "darkIntensity": 20.0, "lightIntensity": 10.0}),
),
r(
"brushStrokes.spatter",
"Spatter…",
BRUSH_STROKES,
"{sprayRadius: pt 0..25 (10; colours scattered up to 0.4 × this), smoothness: 1..15 (5; larger, smoother grains)} renders the object as if sprayed with a spatter airbrush",
json!({"sprayRadius": 10.0, "smoothness": 5.0}),
),
r(
"brushStrokes.sprayedStrokes",
"Sprayed Strokes…",
BRUSH_STROKES,
"{strokeLength: pt 0..20 (12), sprayRadius: pt 0..25 (7; strokes scattered up to 0.3 × this), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} repaints the object with sprayed, angled strokes of its main colours",
json!({"strokeLength": 12.0, "sprayRadius": 7.0, "strokeDirection": "rightDiagonal"}),
),
r(
"brushStrokes.sumiE",
"Sumi-e…",
BRUSH_STROKES,
"{strokeWidth: pt 3..15 (10), strokePressure: 0..15 (2; more ink), contrast: 0..40 (16)} paints the object in the Japanese style, as if with a wet brush full of black ink on rice paper: soft, blurred edges and rich blacks, the colours keeping their hue and the object its shape",
json!({"strokeWidth": 10.0, "strokePressure": 2.0, "contrast": 16.0}),
),
r(
"distort.diffuseGlow",
"Diffuse Glow…",

View File

@ -0,0 +1,518 @@
//! Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines,
//! Spatter, Sprayed Strokes, Sumi-e).
//!
//! The stroke filters repaint the object with brush strokes: each pixel takes the object's colour
//! averaged along a segment through it ([`Smear`], a bounded number of samples whatever the
//! length). Strokes lie in rows across their direction, each row starting at random and each
//! stroke shifted along itself at random, so they begin and end like separate strokes; a streaked
//! noise along them gives them their bristle texture. Accented Edges and Ink Outlines find edges
//! with Glowing Edges' Sobel operator over the softened object; Spatter and Sprayed Strokes shift
//! the content by noise as Ocean Ripple does. Every pattern lies in document space around the
//! object's centre, as the Texture filters' do.
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
use vectorcraft_geom::{Point, Vec2};
use super::distort::displace;
use super::edges::sobel;
use super::pixelate::{Place, cell, rand, value_noise};
use super::texture::{Tones, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, gaussian, pick, to16};
const SALT_ANGLED: u64 = 144;
const SALT_CROSS: u64 = 152;
const SALT_DARK: u64 = 160;
const SALT_INK: u64 = 168;
const SALT_SPATTER: u64 = 176;
const SALT_SPRAYED: u64 = 184;
const SALT_SUMI: u64 = 192;
/// Sprayed Strokes' stroke directions, in its Stroke Direction menu's order: (label, parameter
/// value).
pub const STROKE_DIRECTIONS: [(&str, &str); 4] =
[("Right Diagonal", "rightDiagonal"), ("Horizontal", "horizontal"), ("Left Diagonal", "leftDiagonal"), ("Vertical", "vertical")];
/// The way Sprayed Strokes are painted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StrokeDirection {
RightDiagonal,
Horizontal,
LeftDiagonal,
Vertical,
}
impl StrokeDirection {
const ALL: [StrokeDirection; 4] =
[StrokeDirection::RightDiagonal, StrokeDirection::Horizontal, StrokeDirection::LeftDiagonal, StrokeDirection::Vertical];
/// The direction of `value` (a [`STROKE_DIRECTIONS`] value, any case); Right Diagonal
/// otherwise.
pub fn parse(value: &str) -> Self {
pick(&STROKE_DIRECTIONS, &Self::ALL, value, StrokeDirection::RightDiagonal)
}
/// The unit vector along the strokes (document axes, y down).
fn along(self) -> Vec2 {
match self {
StrokeDirection::RightDiagonal => RIGHT,
StrokeDirection::Horizontal => Vec2::new(1.0, 0.0),
StrokeDirection::LeftDiagonal => LEFT,
StrokeDirection::Vertical => Vec2::new(0.0, 1.0),
}
}
}
/// Along a right diagonal stroke, rising to the right (y down).
const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
/// Along a left diagonal stroke, falling to the right.
const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
/// The most samples a stroke averages, whatever its length.
const MAX_SAMPLES: usize = 16;
/// The share of its length by which a stroke is shifted along itself at random, at most.
const JITTER: f64 = 0.25;
/// How soft (points, the σ) the tones are that decide which strokes a pixel gets.
const TONE_SOFTNESS: f64 = 1.0;
/// Dark Strokes' short dark strokes and long light ones (points).
pub(super) const DARK_LENGTH: f64 = 3.0;
pub(super) const LIGHT_LENGTH: f64 = 9.0;
/// How soft (points, the σ) Ink Outlines' outlines are.
const INK_SOFTNESS: f64 = 0.6;
/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling
/// between pixels add to a filter's reach.
const MARGIN: f64 = 1.0;
/// How far (document units) strokes `len` long reach: half their length, their random shift, the
/// softening between their samples and the pixels a sample is taken between.
pub(super) fn stroke_reach(len: f64) -> f64 {
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
(0.5 + JITTER + 1.5 / (MAX_SAMPLES - 1) as f64) * len + MARGIN
}
/// How far (document units) the tones reach that the stroke filters choose their strokes by.
pub(super) const TONE_REACH: f64 = 3.0 * TONE_SOFTNESS + MARGIN;
/// How far Sumi-e reaches with strokes `width` across: the strokes, and the wet ink bleeding from
/// 3 σ of 0.2 × the width.
pub(super) fn sumi_reach(width: f64) -> f64 {
stroke_reach(width).max(0.6 * width + MARGIN)
}
/// The σ (points) Accented Edges softens the object by before finding its edges, for Edge Width
/// `width` (1..14) and Smoothness `smoothness` (1..15).
pub(super) fn accent_softness(width: f64, smoothness: f64) -> f64 {
0.35 * width.clamp(1.0, 14.0) + 0.1 * smoothness.clamp(1.0, 15.0)
}
/// How far Accented Edges reaches for Edge Width `width` and Smoothness `smoothness`.
pub(super) fn accent_reach(width: f64, smoothness: f64) -> f64 {
3.0 * accent_softness(width, smoothness) + MARGIN
}
/// How far Ink Outlines reaches with strokes `len` long.
pub(super) fn ink_reach(len: f64) -> f64 {
stroke_reach(len).max(3.0 * INK_SOFTNESS + MARGIN)
}
/// How far (points) Crosshatch roughens the edges, with strokes `len` long.
pub(super) fn rough(len: f64) -> f64 {
0.1 * if len.is_finite() { len.max(0.0) } else { 0.0 }
}
/// The longest shift (points) Spatter gives at Spray Radius `radius` (0..25).
pub(super) fn spatter_shift(radius: f64) -> f64 {
0.4 * radius.clamp(0.0, 25.0)
}
/// The longest shift (points) Sprayed Strokes' spray gives at Spray Radius `radius` (0..25).
pub(super) fn spray_shift(radius: f64) -> f64 {
0.3 * radius.clamp(0.0, 25.0)
}
/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object
/// softened first when they lie more than a pixel apart.
struct Smear<'a> {
/// Premultiplied colours (0..1).
src: Vec<[f32; 4]>,
w: usize,
h: usize,
place: &'a Place,
len: f64,
n: usize,
}
impl<'a> Smear<'a> {
fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
let span = len / place.px;
let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES };
let gap = if n > 1 { span / (n - 1) as f64 } else { 0.0 };
let mut wide = to16(px);
if gap > 1.0 {
gaussian(&mut wide, w, 0.5 * gap);
}
let src = wide.iter().map(|p| p.map(|v| f32::from(v) / 65535.0)).collect();
Self { src, w, h, place, len, n }
}
/// The premultiplied colour (0..1) averaged along the stroke through `centre` (relative to the
/// object's centre) along `dir`.
fn at(&self, centre: Point, dir: Vec2) -> [f32; 4] {
let half = dir * (0.5 * self.len);
let (a, b) = (self.place.to_px * (centre - half), self.place.to_px * (centre + half));
if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
return [0.0; 4];
}
// Pixel centres at whole coordinates.
let (mut x, mut y, step) = match self.n {
0 | 1 => (0.5 * (a.x + b.x) - 0.5, 0.5 * (a.y + b.y) - 0.5, (0.0, 0.0)),
n => (a.x - 0.5, a.y - 0.5, ((b.x - a.x) / (n - 1) as f64, (b.y - a.y) / (n - 1) as f64)),
};
let mut acc = [0.0f32; 4];
for _ in 0..self.n {
self.add(&mut acc, x, y);
x += step.0;
y += step.1;
}
acc.map(|v| v / self.n.max(1) as f32)
}
/// Add the colour at (`x`, `y`) (pixel centres at whole coordinates), interpolated between the
/// four pixels around it, to `acc`; transparent outside.
fn add(&self, acc: &mut [f32; 4], x: f64, y: f64) {
let (x0, y0) = (x.floor(), y.floor());
if !(x0 >= -1.0 && y0 >= -1.0 && x0 < self.w as f64 && y0 < self.h as f64) {
return;
}
let (fx, fy) = ((x - x0) as f32, (y - y0) as f32);
let (i, j) = (x0 as isize, y0 as isize);
// Inside the raster: the four pixels as two pairs.
if i >= 0 && j >= 0 && (i as usize) + 1 < self.w && (j as usize) + 1 < self.h {
let at = j as usize * self.w + i as usize;
if let (Some([p00, p10]), Some([p01, p11])) = (self.src.get(at..at + 2), self.src.get(at + self.w..at + self.w + 2)) {
let (k00, k10, k01, k11) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy);
for (c, a) in acc.iter_mut().enumerate() {
*a += p00[c] * k00 + p10[c] * k10 + p01[c] * k01 + p11[c] * k11;
}
}
return;
}
for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] {
let Some(row) = usize::try_from(row).ok().filter(|r| *r < self.h) else { continue };
for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] {
let Some(col) = usize::try_from(col).ok().filter(|c| *c < self.w) else { continue };
if let Some(p) = self.src.get(row * self.w + col) {
let k = wx * wy;
for (a, v) in acc.iter_mut().zip(p) {
*a += v * k;
}
}
}
}
}
/// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart.
fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir)
}
}
/// `q`'s coordinates along `dir` (unit) and across it.
fn frame(q: Point, dir: Vec2) -> (f64, f64) {
(q.x * dir.x + q.y * dir.y, q.y * dir.x - q.x * dir.y)
}
/// How far along `dir` the stroke through `q` is shifted: strokes `len` long lie in rows `width`
/// apart across `dir`, each row starting at random, each stroke shifted by up to [`JITTER`] ×
/// `len`.
fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
if !(len > 0.0 && width > 0.0) {
return 0.0;
}
let (u, v) = frame(q, dir);
let Some(row) = cell(v / width) else { return 0.0 };
let Some(col) = cell(u / len + rand(salt, row, 0, 0)) else { return 0.0 };
(2.0 * rand(salt, col, row, 1) - 1.0) * JITTER * len
}
/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide.
fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
let (u, v) = frame(q, dir);
2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0
}
/// The straight colour and coverage of premultiplied `pm` (0..1).
fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
let a = pm[3].clamp(0.0, 1.0);
if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) }
}
/// `a` + (`b` − `a`) × `t`, channel by channel.
fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t)
}
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1.
fn ramp(t: f64) -> f64 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
t * t * (3.0 - 2.0 * t)
}
/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1.
fn unit(p: [u8; 4]) -> [f32; 4] {
p.map(|v| f32::from(v) / 255.0)
}
/// The object's edges after softening it by `sigma` (document units): about 1 across a step from
/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't
/// match.
fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let s = sigma / place.px;
blur_plane(&mut tone, w, h, s);
blur_plane(&mut cover, w, h, s);
// A step softened by σ pixels rises by 1 / (σ √(2π)) per pixel at most, and Sobel gives 8 ×
// the slope; a pixel is the least it softens by.
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
let norm = if norm.is_finite() { norm } else { 0.0 };
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
}
/// Accented Edges: the object's edges accented in white chalk when `brightness` (0..50) is high,
/// in black ink when it's low, about `width` (1..14) wide; `smoothness` (1..15) softens the object
/// first and leaves out its fainter edges.
pub(super) fn accented_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, brightness: f64, smoothness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let found = edges(px, w, h, &place, accent_softness(width, smoothness));
let faint = (0.02 * smoothness.clamp(1.0, 15.0)) as f32;
let level = (brightness / 50.0).clamp(0.0, 1.0) as f32;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let e = found.get(y * w + x).copied().unwrap_or(0.0);
let e = (2.5 * (e - faint) / (1.0 - faint)).clamp(0.0, 1.0);
let c = straight(*p);
// Chalk or ink of the brightness level, a little of the colour showing through.
let accent = c.map(|v| 0.9 * level + 0.1 * v);
*p = premultiply(mix(c, accent, e), f32::from(p[3]) / 255.0);
});
}
/// Angled Strokes: the object repainted in diagonal strokes `len` long, light areas in right
/// diagonal strokes and dark areas in left diagonal ones; `balance` (0..100) moves the split
/// (0 all left diagonal, 100 all right diagonal) and `sharpness` (0..10) brings the detail and
/// the bristles out.
pub(super) fn angled_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, len: f64, sharpness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let smear = Smear::new(px, w, h, &place, len);
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
let split = 1.07 - 1.14 * (balance / 100.0).clamp(0.0, 1.0);
let sharp = sharpness.clamp(0.0, 10.0);
let (detail, bristle) = ((0.05 * sharp) as f32, 0.03 + 0.012 * sharp);
let row = (0.2 * smear.len).max(1.0);
let src = px.to_vec();
place.each(px, |x, y, p| {
let q = place.at(x, y);
// The split between the directions wanders along the strokes, so they interlock.
let right = tones.get(x, y) > split + 0.06 * streak(SALT_ANGLED + 3, q, RIGHT, row, row);
let (dir, salt) = if right { (RIGHT, SALT_ANGLED) } else { (LEFT, SALT_ANGLED + 1) };
let pm = smear.stroke(salt, q, dir, row);
let pm = mix(pm, unit(src.get(y * w + x).copied().unwrap_or([0; 4])), detail);
let (c, a) = unpremultiply(pm);
let n = (bristle * streak(SALT_ANGLED + 2, q, dir, 0.5 * smear.len.max(1.0), 0.6)) as f32;
*p = premultiply(c.map(|v| v + n), a);
});
}
/// Crosshatch: the object's detail kept under pencil hatching along both diagonals, strokes `len`
/// long, roughening its edges; `sharpness` (0..20) sets how crisp and dark the hatching is,
/// `passes` (1..3) how many layers of it there are.
pub(super) fn crosshatch(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, sharpness: f64, passes: u32) {
let Some(place) = Place::new(space, w, h) else { return };
let smear = Smear::new(px, w, h, &place, len);
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
let sharp = sharpness.clamp(0.0, 20.0);
let (amount, crisp) = (0.05 + 0.01 * sharp, 1.0 + 0.3 * sharp);
let row = (0.2 * smear.len).max(1.0);
let roughen = rough(smear.len);
let src = px.to_vec();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let line = |k: u32| {
let dir = if k.is_multiple_of(2) { RIGHT } else { LEFT };
streak(SALT_CROSS + u64::from(k), q, dir, smear.len.max(1.0), 0.7 + 0.3 * f64::from(k))
};
let first = line(0);
// The edges roughened along the first hatching.
let o = q + RIGHT * (roughen * first);
let strokes = mix(smear.stroke(SALT_CROSS + 4, o, RIGHT, row), smear.stroke(SALT_CROSS + 5, o, LEFT, row), 0.5);
let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), strokes, 0.5);
let (mut c, a) = unpremultiply(pm);
if a <= 0.0 {
*p = [0; 4];
return;
}
let shade = 1.3 - tones.get(x, y);
for k in 0..passes.clamp(1, 3) {
let v = (if k == 0 { first } else { line(k) } * crisp).clamp(-1.0, 1.0);
// Dark pencil lines and the light paper between them.
let s = if v > 0.0 { -(amount * v * shade) } else { -0.6 * amount * v };
let s = s.clamp(-1.0, 1.0) as f32;
c = c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s });
}
*p = premultiply(c, a);
});
}
/// Dark Strokes: dark areas painted closer to black with short strokes, light areas with long
/// strokes closer to white; `balance` (0..10) moves the tone between them up, `black` (0..10) and
/// `white` (0..10) set how dark and how light they get.
pub(super) fn dark_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, black: f64, white: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let short = Smear::new(px, w, h, &place, DARK_LENGTH);
let long = Smear::new(px, w, h, &place, LIGHT_LENGTH);
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
let split = 0.15 + 0.07 * balance.clamp(0.0, 10.0);
let (black, white) = (0.085 * black.clamp(0.0, 10.0), 0.085 * white.clamp(0.0, 10.0));
place.each(px, |x, y, p| {
let q = place.at(x, y);
let dark = 1.0 - ramp((tones.get(x, y) - split) / 0.12 + 0.5);
let painted = |light: bool| {
let (smear, dir, salt) = if light { (&long, RIGHT, SALT_DARK) } else { (&short, LEFT, SALT_DARK + 1) };
let (c, a) = unpremultiply(smear.stroke(salt, q, dir, 1.0));
let n = 0.7 + 0.3 * streak(salt + 2, q, dir, 0.5 * smear.len, 0.5);
let c = if light {
let s = (white * n).clamp(0.0, 1.0) as f32;
c.map(|v| v + (1.0 - v) * s)
} else {
let s = (black * n).clamp(0.0, 1.0) as f32;
c.map(|v| v * (1.0 - s))
};
[c[0] * a, c[1] * a, c[2] * a, a]
};
let pm = if dark >= 1.0 {
painted(false)
} else if dark <= 0.0 {
painted(true)
} else {
mix(painted(true), painted(false), dark as f32)
};
let (c, a) = unpremultiply(pm);
*p = premultiply(c, a);
});
}
/// Ink Outlines: the object redrawn in pen and ink, fine outlines over its edges and ink strokes
/// `len` long in its shadows; `dark` (0..50) darkens the shadows, `light` (0..50) lightens the
/// highlights.
pub(super) fn ink_outlines(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, dark: f64, light: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let smear = Smear::new(px, w, h, &place, len);
let found = edges(px, w, h, &place, INK_SOFTNESS);
let (dark, light) = ((dark / 50.0).clamp(0.0, 1.0), (light / 50.0).clamp(0.0, 1.0));
let row = (0.25 * smear.len).max(0.75);
let src = px.to_vec();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let pm = mix(unit(src.get(y * w + x).copied().unwrap_or([0; 4])), smear.stroke(SALT_INK, q, LEFT, row), 0.5);
let (c, a) = unpremultiply(pm);
if a <= 0.0 {
*p = [0; 4];
return;
}
let l = f64::from(luma(c));
let (shadow, highlight) = (((0.75 - l) / 0.75).clamp(0.0, 1.0), ((l - 0.45) / 0.55).clamp(0.0, 1.0));
let (down, up) = ((0.7 * dark * shadow) as f32, (0.7 * light * highlight) as f32);
let c = c.map(|v| v * (1.0 - down)).map(|v| v + (1.0 - v) * up);
// Ink lines along the strokes in the shadows, and the outlines.
let hatch = ramp(2.0 * streak(SALT_INK + 1, q, LEFT, 1.5 * smear.len.max(1.0), 0.5) + 0.2) * shadow * (0.4 + 1.2 * dark);
let outline = (1.5 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0)) - 0.15).clamp(0.0, 1.0);
let ink = (0.9 * outline).max(0.8 * hatch).min(1.0) as f32;
*p = premultiply(c.map(|v| v * (1.0 - ink)), a);
});
}
/// Spatter: the object as if sprayed with a spatter airbrush, each pixel taking the colour of one
/// up to [`spatter_shift`] away; `smoothness` (1..15) makes the spatter's grains larger and
/// smoother.
pub(super) fn spatter(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, radius: f64, smoothness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let grain = 0.3 + 0.15 * smoothness.clamp(1.0, 15.0);
let most = spatter_shift(radius);
displace(px, w, h, &place, most, |q| {
let n = |salt: u64| 0.6 * smooth_noise(salt, q, grain) + 0.4 * smooth_noise(salt + 2, q, 0.4 * grain);
Vec2::new(n(SALT_SPATTER), n(SALT_SPATTER + 1)) * (1.8 * most)
});
}
/// Sprayed Strokes: the object repainted in sprayed strokes `len` long along `dir`, scattered by
/// up to [`spray_shift`].
pub(super) fn sprayed_strokes(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, radius: f64, dir: StrokeDirection) {
let Some(place) = Place::new(space, w, h) else { return };
let smear = Smear::new(px, w, h, &place, len);
let (dir, spray) = (dir.along(), spray_shift(radius));
let along = (0.5 * smear.len).max(0.5);
place.each(px, |x, y, p| {
let q = place.at(x, y);
let n = |salt: u64| streak(salt, q, dir, along, 0.5);
let mut d = Vec2::new(n(SALT_SPRAYED), n(SALT_SPRAYED + 1)) * (1.6 * spray);
let l = d.hypot();
if l > spray && l.is_finite() {
d *= spray / l;
}
let (c, a) = unpremultiply(smear.stroke(SALT_SPRAYED + 2, q + d, dir, (0.2 * smear.len).max(1.0)));
*p = premultiply(c, a);
});
}
/// Sumi-e: the object as if painted with a wet brush on rice paper, in strokes `width` (3..15)
/// across with soft, bleeding edges and rich blacks; `pressure` (0..15) loads the brush with more
/// ink and `contrast` (0..40) sets the contrast. It works in the ink (the value): the colours keep
/// their hue and saturation, and the object keeps its shape.
pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, contrast: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let smear = Smear::new(px, w, h, &place, width);
// The ink around each pixel: the object's darkness by its coverage, softened.
let mut wet: Vec<f32> = px.iter().map(|p| (1.0 - luma(straight(*p))) * f32::from(p[3]) / 255.0).collect();
blur_plane(&mut wet, w, h, 0.2 * smear.len / place.px);
let load = 1.0 + 0.06 * pressure.clamp(0.0, 15.0);
// How far the ink moves along an S curve: up to once at 20, twice at 40.
let gain = contrast.clamp(0.0, 40.0) / 20.0;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (c, a) = unpremultiply(smear.stroke(SALT_SUMI, q, LEFT, (0.3 * smear.len).max(1.0)));
// Where the stroke brings no paint, the pixel's own colour.
let c = if a > 0.0 { c } else { straight(*p) };
let l = f64::from(luma(c));
// The ink bleeds into the paper around the darks, along its fibres.
let bleed = 0.85 * f64::from(wet.get(y * w + x).copied().unwrap_or(0.0));
let fibre = 1.0 + 0.1 * streak(SALT_SUMI + 1, q, Vec2::new(1.0, 0.0), 2.0, 0.35);
let mut ink = ((1.0 - l).max(bleed) * load * fibre).clamp(0.0, 1.0);
let mut left = gain;
while left > 0.0 {
ink += (ramp(ink) - ink) * left.min(1.0);
left -= 1.0;
}
let tone = (1.0 - ink).clamp(0.0, 1.0);
// Darker towards black, lighter towards the paper's white: the hue stays.
let out = if tone <= l {
let k = if l > 1e-3 { (tone / l) as f32 } else { 0.0 };
c.map(|v| v * k)
} else {
let k = ((tone - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32;
c.map(|v| v + (1.0 - v) * k)
};
*p = premultiply(out, f32::from(p[3]) / 255.0);
});
}

View File

@ -159,7 +159,7 @@ pub(super) fn diffuse_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
/// Move the object's content by `shift(q)` (document units; `q` a pixel's centre relative to the
/// object's centre, its length capped at `most`): each pixel takes the colour `shift` away from it,
/// interpolated between pixels, transparent beyond the raster.
fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) {
pub(super) fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) {
if !(most.is_finite() && most > 0.0) {
return;
}

View File

@ -21,38 +21,20 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes
if alpha > 0.0 { [f32::from(p[0]) / 255.0 / alpha, f32::from(p[1]) / 255.0 / alpha, f32::from(p[2]) / 255.0 / alpha] } else { [0.0; 3] };
(straight, alpha)
};
let signal = |x: isize, y: isize| {
let (rgb, alpha) = pixel(x, y);
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
};
let mut edges = vec![0.0f32; px.len()];
let signal: Vec<f32> = (0..px.len())
.map(|i| {
let (rgb, alpha) = pixel((i % w) as isize, (i / w) as isize);
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
})
.collect();
let coverage: Vec<f32> = src.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let mut edges: Vec<f32> = sobel(&signal, w, h).iter().zip(sobel(&coverage, w, h)).map(|(s, a)| s.max(a).clamp(0.0, 1.0)).collect();
let mut colours: [Vec<f32>; 3] = std::array::from_fn(|_| vec![0.0; px.len()]);
for y in 0..h {
for x in 0..w {
let x = x as isize;
let y = y as isize;
let gx = -signal(x - 1, y - 1) + signal(x + 1, y - 1) - 2.0 * signal(x - 1, y) + 2.0 * signal(x + 1, y) - signal(x - 1, y + 1)
+ signal(x + 1, y + 1);
let gy = -signal(x - 1, y - 1) - 2.0 * signal(x, y - 1) - signal(x + 1, y - 1)
+ signal(x - 1, y + 1)
+ 2.0 * signal(x, y + 1)
+ signal(x + 1, y + 1);
let alpha_signal = |x, y| pixel(x, y).1;
let agx = -alpha_signal(x - 1, y - 1) + alpha_signal(x + 1, y - 1) - 2.0 * alpha_signal(x - 1, y) + 2.0 * alpha_signal(x + 1, y)
- alpha_signal(x - 1, y + 1)
+ alpha_signal(x + 1, y + 1);
let agy = -alpha_signal(x - 1, y - 1) - 2.0 * alpha_signal(x, y - 1) - alpha_signal(x + 1, y - 1)
+ alpha_signal(x - 1, y + 1)
+ 2.0 * alpha_signal(x, y + 1)
+ alpha_signal(x + 1, y + 1);
let index = y as usize * w + x as usize;
let edge = gx.hypot(gy).max(agx.hypot(agy)).clamp(0.0, 1.0);
if let Some(value) = edges.get_mut(index) {
*value = edge;
let (rgb, alpha) = pixel(x, y);
for channel in 0..3 {
colours[channel][index] = rgb[channel] * alpha * edge;
}
for (index, edge) in edges.iter().enumerate() {
let (rgb, alpha) = pixel((index % w) as isize, (index / w) as isize);
for (colour, v) in colours.iter_mut().zip(rgb) {
if let Some(c) = colour.get_mut(index) {
*c = v * alpha * edge;
}
}
}
@ -85,3 +67,25 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes
];
}
}
/// The Sobel gradient's length over a `w` × `h` plane of values (zero beyond its edges): 8 × the
/// slope per pixel on a ramp, 4 × the step at a sharp edge. Empty when the sizes don't match.
pub(super) fn sobel(v: &[f32], w: usize, h: usize) -> Vec<f32> {
if w == 0 || w.checked_mul(h) != Some(v.len()) {
return Vec::new();
}
let at = |x: usize, dx: isize, y: usize, dy: isize| -> f32 {
match (x.checked_add_signed(dx), y.checked_add_signed(dy)) {
(Some(x), Some(y)) if x < w && y < h => v.get(y * w + x).copied().unwrap_or(0.0),
_ => 0.0,
}
};
(0..v.len())
.map(|i| {
let (x, y) = (i % w, i / w);
let gx = at(x, 1, y, -1) - at(x, -1, y, -1) + 2.0 * (at(x, 1, y, 0) - at(x, -1, y, 0)) + at(x, 1, y, 1) - at(x, -1, y, 1);
let gy = at(x, -1, y, 1) - at(x, -1, y, -1) + 2.0 * (at(x, 0, y, 1) - at(x, 0, y, -1)) + at(x, 1, y, 1) - at(x, 1, y, -1);
gx.hypot(gy)
})
.collect()
}

View File

@ -1,8 +1,9 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort › Diffuse
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
//! Sharpen › Unsharp Mask, Stylize › Glowing
//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and
//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//!
//! - 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
@ -12,6 +13,7 @@
//! - Beyond the raster's edges is transparency.
mod blur;
mod brushstrokes;
mod distort;
mod edges;
mod pixelate;
@ -24,14 +26,23 @@ use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{flag, num, text};
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
pub use distort::{GLASS_TEXTURES, GlassTexture};
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; 19] = [
pub const PIXEL_EFFECTS: [&str; 27] = [
"blur.radial",
"blur.smart",
"brushStrokes.accentedEdges",
"brushStrokes.angledStrokes",
"brushStrokes.crosshatch",
"brushStrokes.darkStrokes",
"brushStrokes.inkOutlines",
"brushStrokes.spatter",
"brushStrokes.sprayedStrokes",
"brushStrokes.sumiE",
"distort.diffuseGlow",
"distort.glass",
"distort.oceanRipple",
@ -62,6 +73,32 @@ pub enum PixelFx {
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
/// `samples` × `samples` neighbours at most.
SmartBlur { radius: f64, threshold: f64, samples: u32 },
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
AccentedEdges { width: f64, brightness: f64, smoothness: f64 },
/// Brush Strokes › Angled Strokes: diagonal strokes `length` long, the light areas' and the
/// dark areas' going opposite ways, split as `balance` (0..100) says, as crisp as `sharpness`
/// (0..10).
AngledStrokes { balance: f64, length: f64, sharpness: f64 },
/// Brush Strokes › Crosshatch: pencil hatching `length` long along both diagonals, as crisp
/// as `sharpness` (0..20), in `strength` (1..3) passes.
Crosshatch { length: f64, sharpness: f64, strength: u32 },
/// Brush Strokes › Dark Strokes: short strokes towards black in the dark areas, long ones
/// towards white in the light areas, split by `balance` (0..10), as dark as `black` (0..10)
/// and as light as `white` (0..10).
DarkStrokes { balance: f64, black: f64, white: f64 },
/// Brush Strokes › Ink Outlines: pen-and-ink outlines and strokes `length` long, the shadows
/// darkened by `dark` (0..50) and the highlights lightened by `light` (0..50).
InkOutlines { length: f64, dark: f64, light: f64 },
/// Brush Strokes › Spatter: a spatter airbrush spraying within `radius` (0..25), its grains as
/// smooth as `smoothness` (1..15).
Spatter { radius: f64, smoothness: f64 },
/// Brush Strokes › Sprayed Strokes: sprayed strokes `length` long along `direction`, scattered
/// as `radius` (0..25) says.
SprayedStrokes { length: f64, radius: f64, direction: StrokeDirection },
/// Brush Strokes › Sumi-e: wet black brush strokes `width` (3..15) across on rice paper, as
/// loaded as `pressure` (0..15) says, with `contrast` (0..40).
SumiE { width: f64, pressure: f64, contrast: f64 },
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
@ -165,6 +202,44 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
},
"brushStrokes.accentedEdges" => PixelFx::AccentedEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
brightness: num(p, "edgeBrightness", 38.0).clamp(0.0, 50.0),
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
},
"brushStrokes.angledStrokes" => PixelFx::AngledStrokes {
balance: num(p, "directionBalance", 50.0).clamp(0.0, 100.0),
length: num(p, "strokeLength", 15.0).clamp(3.0, 50.0),
sharpness: num(p, "sharpness", 3.0).clamp(0.0, 10.0),
},
"brushStrokes.crosshatch" => PixelFx::Crosshatch {
length: num(p, "strokeLength", 9.0).clamp(3.0, 50.0),
sharpness: num(p, "sharpness", 6.0).clamp(0.0, 20.0),
strength: num(p, "strength", 1.0).clamp(1.0, 3.0).round() as u32,
},
"brushStrokes.darkStrokes" => PixelFx::DarkStrokes {
balance: num(p, "balance", 5.0).clamp(0.0, 10.0),
black: num(p, "blackIntensity", 6.0).clamp(0.0, 10.0),
white: num(p, "whiteIntensity", 2.0).clamp(0.0, 10.0),
},
"brushStrokes.inkOutlines" => PixelFx::InkOutlines {
length: num(p, "strokeLength", 4.0).clamp(1.0, 50.0),
dark: num(p, "darkIntensity", 20.0).clamp(0.0, 50.0),
light: num(p, "lightIntensity", 10.0).clamp(0.0, 50.0),
},
"brushStrokes.spatter" => {
PixelFx::Spatter { radius: num(p, "sprayRadius", 10.0).clamp(0.0, 25.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0) }
}
"brushStrokes.sprayedStrokes" => PixelFx::SprayedStrokes {
length: num(p, "strokeLength", 12.0).clamp(0.0, 20.0),
radius: num(p, "sprayRadius", 7.0).clamp(0.0, 25.0),
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
},
"brushStrokes.sumiE" => PixelFx::SumiE {
width: num(p, "strokeWidth", 10.0).clamp(3.0, 15.0),
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 15.0),
contrast: num(p, "contrast", 16.0).clamp(0.0, 40.0),
},
"distort.diffuseGlow" => PixelFx::DiffuseGlow {
graininess: num(p, "graininess", 6.0).clamp(0.0, 10.0),
glow: num(p, "glowAmount", 10.0).clamp(0.0, 20.0),
@ -248,6 +323,15 @@ impl PixelFx {
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::AccentedEdges { .. } => 0.0,
// Strokes reach out of the object as far as they reach into it.
PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length),
PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
PixelFx::SprayedStrokes { length, radius, .. } => brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius),
// Sumi-e keeps the object's shape.
PixelFx::SumiE { .. } => 0.0,
PixelFx::DiffuseGlow { .. } => 0.0,
// Content shifted outwards reaches past the bounds by as much as the shift.
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
@ -275,6 +359,17 @@ impl PixelFx {
match *self {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, .. } => Some(radius),
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
// The strokes, and the softened tones that choose them.
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
PixelFx::Crosshatch { length, .. } => {
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
}
PixelFx::DarkStrokes { .. } => Some(brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH).max(brushstrokes::TONE_REACH)),
PixelFx::InkOutlines { length, .. } => Some(brushstrokes::ink_reach(length)),
PixelFx::Spatter { radius, .. } => Some(brushstrokes::spatter_shift(radius)),
PixelFx::SprayedStrokes { length, radius, .. } => Some(brushstrokes::stroke_reach(length) + brushstrokes::spray_shift(radius)),
PixelFx::SumiE { width, .. } => Some(brushstrokes::sumi_reach(width)),
PixelFx::DiffuseGlow { .. } => Some(3.0 * distort::GLOW_SPREAD),
PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)),
PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)),
@ -306,6 +401,14 @@ impl PixelFx {
match *self {
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness),
PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength),
PixelFx::DarkStrokes { balance, black, white } => brushstrokes::dark_strokes(px, w, h, space, balance, black, white),
PixelFx::InkOutlines { length, dark, light } => brushstrokes::ink_outlines(px, w, h, space, length, dark, light),
PixelFx::Spatter { radius, smoothness } => brushstrokes::spatter(px, w, h, space, radius, smoothness),
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)

View File

@ -276,6 +276,14 @@ fn pixel_timings() {
("blur.smart", json!({"quality": "low"})),
("blur.smart", json!({"quality": "high", "radius": 30})),
("sharpen.unsharpMask", json!({"radius": 20})),
("brushStrokes.accentedEdges", json!({"edgeWidth": 14})),
("brushStrokes.angledStrokes", json!({"strokeLength": 50})),
("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})),
("brushStrokes.darkStrokes", json!({})),
("brushStrokes.inkOutlines", json!({"strokeLength": 50})),
("brushStrokes.spatter", json!({"sprayRadius": 25})),
("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25})),
("brushStrokes.sumiE", json!({"strokeWidth": 15})),
("pixelate.colorHalftone", json!({"maxRadius": 4})),
("pixelate.crystallize", json!({"cellSize": 3})),
("pixelate.crystallize", json!({"cellSize": 300})),
@ -982,3 +990,322 @@ fn distort_patterns_follow_the_object_at_any_resolution() {
assert_eq!(shifted, one, "{id}");
}
}
/// The Brush Strokes effect ids.
fn brush_strokes() -> impl Iterator<Item = &'static str> {
PIXEL_EFFECTS.into_iter().filter(|id| id.starts_with("brushStrokes."))
}
#[test]
fn brush_strokes_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("brushStrokes.accentedEdges", json!({})), PixelFx::AccentedEdges { width: 2.0, brightness: 38.0, smoothness: 5.0 });
assert_eq!(
fx("brushStrokes.accentedEdges", json!({"edgeWidth": 99, "edgeBrightness": -1, "smoothness": "x"})),
PixelFx::AccentedEdges { width: 14.0, brightness: 0.0, smoothness: 5.0 }
);
assert_eq!(fx("brushStrokes.angledStrokes", json!({})), PixelFx::AngledStrokes { balance: 50.0, length: 15.0, sharpness: 3.0 });
assert_eq!(
fx("brushStrokes.angledStrokes", json!({"directionBalance": 1e308, "strokeLength": 0, "sharpness": 11})),
PixelFx::AngledStrokes { balance: 100.0, length: 3.0, sharpness: 10.0 }
);
assert_eq!(fx("brushStrokes.crosshatch", json!({})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 });
assert_eq!(
fx("brushStrokes.crosshatch", json!({"strokeLength": 51, "sharpness": -1, "strength": 2.6})),
PixelFx::Crosshatch { length: 50.0, sharpness: 0.0, strength: 3 }
);
assert_eq!(fx("brushStrokes.crosshatch", json!({"strength": -7})), PixelFx::Crosshatch { length: 9.0, sharpness: 6.0, strength: 1 });
assert_eq!(fx("brushStrokes.darkStrokes", json!({})), PixelFx::DarkStrokes { balance: 5.0, black: 6.0, white: 2.0 });
assert_eq!(
fx("brushStrokes.darkStrokes", json!({"balance": 11, "blackIntensity": -1, "whiteIntensity": 1e9})),
PixelFx::DarkStrokes { balance: 10.0, black: 0.0, white: 10.0 }
);
assert_eq!(fx("brushStrokes.inkOutlines", json!({})), PixelFx::InkOutlines { length: 4.0, dark: 20.0, light: 10.0 });
assert_eq!(
fx("brushStrokes.inkOutlines", json!({"strokeLength": 0, "darkIntensity": 51, "lightIntensity": -5})),
PixelFx::InkOutlines { length: 1.0, dark: 50.0, light: 0.0 }
);
assert_eq!(fx("brushStrokes.spatter", json!({})), PixelFx::Spatter { radius: 10.0, smoothness: 5.0 });
assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 26, "smoothness": 0})), PixelFx::Spatter { radius: 25.0, smoothness: 1.0 });
assert_eq!(
fx("brushStrokes.sprayedStrokes", json!({})),
PixelFx::SprayedStrokes { length: 12.0, radius: 7.0, direction: StrokeDirection::RightDiagonal }
);
assert_eq!(
fx("brushStrokes.sprayedStrokes", json!({"strokeLength": -1, "sprayRadius": 1e308, "strokeDirection": "VERTICAL"})),
PixelFx::SprayedStrokes { length: 0.0, radius: 25.0, direction: StrokeDirection::Vertical }
);
assert_eq!(fx("brushStrokes.sprayedStrokes", json!({"strokeDirection": "diagonal"})), fx("brushStrokes.sprayedStrokes", json!({})));
assert_eq!(fx("brushStrokes.sumiE", json!({})), PixelFx::SumiE { width: 10.0, pressure: 2.0, contrast: 16.0 });
assert_eq!(
fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})),
PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 }
);
// Outsets: the strokes and the shifts reach out of the object as far as into it; Accented
// Edges and Sumi-e keep the object's shape.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
let reach = brushstrokes::stroke_reach;
assert!((reach(20.0) - (0.75 * 20.0 + 1.5 / 15.0 * 20.0 + 1.0)).abs() < 1e-9);
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).outset(b), 0.0);
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0));
assert_eq!(fx("brushStrokes.angledStrokes", json!({})).outset(b), reach(15.0));
assert_eq!(fx("brushStrokes.crosshatch", json!({})).outset(b), reach(9.0) + 0.9);
assert_eq!(fx("brushStrokes.darkStrokes", json!({})).outset(b), reach(9.0));
assert_eq!(fx("brushStrokes.inkOutlines", json!({"strokeLength": 50})).outset(b), reach(50.0));
let spatter = fx("brushStrokes.spatter", json!({}));
assert_eq!((spatter.outset(b), spatter.reach()), (4.0, Some(4.0)));
assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 0})).outset(b), 0.0);
assert_eq!(fx("brushStrokes.sprayedStrokes", json!({})).outset(b), reach(12.0) + 0.3 * 7.0);
assert_eq!(fx("brushStrokes.sumiE", json!({})).outset(b), 0.0);
for id in brush_strokes() {
let f = fx(id, json!({}));
assert!(f.reach().is_some_and(|r| r >= f.outset(b)), "{id}");
}
}
/// A light square in a darker ground with a ramp across it, over the whole raster: edges,
/// shadows and highlights for the strokes to work on.
fn scene(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, y| if (12..36).contains(&x) && (12..36).contains(&y) { [235, 220, 190, 255] } else { [(30 + x) as u8, 40, 90, 255] })
}
/// `src` (`w` × `h`) through effect `id` with parameters `p`.
fn run_fx(src: &[u8], w: usize, h: usize, id: &str, p: serde_json::Value) -> Vec<u8> {
let mut d = src.to_vec();
fx(id, p).apply(&mut d, w, h, &space(w, h));
d
}
#[test]
fn brush_strokes_each_repaint_the_art_their_own_way() {
let (w, h) = (48, 48);
let src = scene(w, h);
let mut seen: Vec<(&str, Vec<u8>)> = vec![];
for id in brush_strokes() {
let d = run_fx(&src, w, h, id, json!({}));
assert_ne!(d, src, "{id} changes the art");
for (other, o) in &seen {
assert_ne!(&d, o, "{id} differs from {other}");
}
seen.push((id, d));
}
assert_eq!(seen.len(), 8);
// Each stroke direction paints its own way.
let mut ways: Vec<Vec<u8>> = vec![];
for (_, way) in STROKE_DIRECTIONS {
let d = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"strokeDirection": way}));
assert!(!ways.contains(&d), "{way} differs from the other directions");
ways.push(d);
}
// Every option shows.
for (id, a, b) in [
("brushStrokes.accentedEdges", json!({"edgeWidth": 1}), json!({"edgeWidth": 14})),
("brushStrokes.accentedEdges", json!({"smoothness": 1}), json!({"smoothness": 15})),
("brushStrokes.angledStrokes", json!({"directionBalance": 0}), json!({"directionBalance": 100})),
("brushStrokes.angledStrokes", json!({"strokeLength": 3}), json!({"strokeLength": 50})),
("brushStrokes.angledStrokes", json!({"sharpness": 0}), json!({"sharpness": 10})),
("brushStrokes.crosshatch", json!({"strokeLength": 3}), json!({"strokeLength": 50})),
("brushStrokes.crosshatch", json!({"sharpness": 0}), json!({"sharpness": 20})),
("brushStrokes.crosshatch", json!({"strength": 1}), json!({"strength": 3})),
("brushStrokes.darkStrokes", json!({"balance": 0}), json!({"balance": 10})),
("brushStrokes.darkStrokes", json!({"blackIntensity": 0}), json!({"blackIntensity": 10})),
("brushStrokes.darkStrokes", json!({"whiteIntensity": 0}), json!({"whiteIntensity": 10})),
("brushStrokes.inkOutlines", json!({"strokeLength": 1}), json!({"strokeLength": 50})),
("brushStrokes.inkOutlines", json!({"darkIntensity": 0}), json!({"darkIntensity": 50})),
("brushStrokes.inkOutlines", json!({"lightIntensity": 0}), json!({"lightIntensity": 50})),
("brushStrokes.spatter", json!({"sprayRadius": 5}), json!({"sprayRadius": 25})),
("brushStrokes.spatter", json!({"smoothness": 1}), json!({"smoothness": 15})),
("brushStrokes.sprayedStrokes", json!({"strokeLength": 0}), json!({"strokeLength": 20})),
("brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}), json!({"sprayRadius": 25})),
("brushStrokes.sumiE", json!({"strokeWidth": 3}), json!({"strokeWidth": 15})),
("brushStrokes.sumiE", json!({"strokePressure": 0}), json!({"strokePressure": 15})),
("brushStrokes.sumiE", json!({"contrast": 0}), json!({"contrast": 40})),
] {
assert_ne!(run_fx(&src, w, h, id, a.clone()), run_fx(&src, w, h, id, b.clone()), "{id}: {a} and {b}");
}
}
/// The mean of the colour channels over columns `xs` and rows `ys` (0..255).
fn mean_tone(d: &[u8], w: usize, xs: std::ops::Range<usize>, ys: std::ops::Range<usize>) -> f64 {
let px: Vec<[u8; 4]> = ys.flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect();
px.iter().map(|p| f64::from(p[0]) + f64::from(p[1]) + f64::from(p[2])).sum::<f64>() / (3.0 * px.len().max(1) as f64)
}
/// Dark on the left, light on the right, opaque over the whole raster.
fn halves(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, _| if x < w / 2 { [50, 60, 100, 255] } else { [210, 200, 180, 255] })
}
#[test]
fn accented_edges_draw_chalk_or_ink_on_the_edges_only() {
let (w, h) = (48, 48);
// The halves inside a transparent margin.
let both = halves(w, h);
let src = image(w, h, |x, y| if (4..44).contains(&x) && (4..44).contains(&y) { at(&both, w, x, y) } else { [0; 4] });
let chalk = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 50}));
let ink = run_fx(&src, w, h, "brushStrokes.accentedEdges", json!({"edgeBrightness": 0}));
let sum = |p: [u8; 4]| u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]);
// Along the edge between the halves: white chalk on the dark side, black ink on the light.
assert!(sum(at(&chalk, w, 23, 24)) > sum(at(&src, w, 23, 24)) + 150, "{:?}", at(&chalk, w, 23, 24));
assert!(sum(at(&ink, w, 24, 24)) + 150 < sum(at(&src, w, 24, 24)), "{:?}", at(&ink, w, 24, 24));
// Away from the edges nothing changes, and the object keeps its shape.
for (x, y) in [(13, 24), (34, 24), (13, 13)] {
assert_eq!(at(&chalk, w, x, y), at(&src, w, x, y), "({x}, {y})");
}
for (a, b) in chalk.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(a[3], b[3]);
}
}
#[test]
fn dark_and_ink_strokes_darken_the_shadows_and_sumi_e_inks_them_black() {
let (w, h) = (64, 48);
let src = halves(w, h);
let (shadow, light) = (8..24, 40..56);
let tone = |d: &[u8], xs: std::ops::Range<usize>| mean_tone(d, w, xs, 8..40);
let dark = run_fx(&src, w, h, "brushStrokes.darkStrokes", json!({}));
assert!(tone(&dark, shadow.clone()) < tone(&src, shadow.clone()) - 20.0, "dark strokes in the shadows");
assert!(tone(&dark, light.clone()) > tone(&src, light.clone()) + 5.0, "white strokes in the light");
let ink = run_fx(&src, w, h, "brushStrokes.inkOutlines", json!({}));
assert!(tone(&ink, shadow.clone()) < tone(&src, shadow.clone()) - 10.0, "ink in the shadows");
// The outline along the edge is darker than the light side around it.
assert!(tone(&ink, 32..34) + 40.0 < tone(&ink, light.clone()), "an ink outline along the edge");
let sumi = run_fx(&src, w, h, "brushStrokes.sumiE", json!({}));
assert!(tone(&sumi, shadow.clone()) < 0.4 * tone(&src, shadow.clone()), "rich blacks: {}", tone(&sumi, shadow.clone()));
assert!(tone(&sumi, 50..56) > 150.0, "the light stays light: {}", tone(&sumi, 50..56));
}
#[test]
fn sumi_e_keeps_the_shape_and_the_hues() {
let (w, h) = (48, 48);
// A strong yellow square and a dark blue one in a transparent margin.
let src = image(w, h, |x, y| match (x, y) {
(8..24, 8..40) => [230, 200, 40, 255],
(24..40, 8..40) => [30, 40, 120, 255],
_ => [0; 4],
});
let d = run_fx(&src, w, h, "brushStrokes.sumiE", json!({"contrast": 40, "strokePressure": 15}));
// Transparency stays transparent and the coverage stays as it was: no fringe.
for (a, b) in d.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(a[3], b[3]);
if b[3] == 0 {
assert_eq!(*a, [0; 4]);
}
}
// The yellow stays yellow, no more saturated than it was.
let sat = |p: [u8; 4]| {
let (hi, lo) = (p[..3].iter().max().copied().unwrap(), p[..3].iter().min().copied().unwrap());
f64::from(hi - lo) / f64::from(hi.max(1))
};
let y = at(&d, w, 12, 24);
assert!(y[0] > y[2] + 80 && y[1] > y[2] + 80, "{y:?}");
assert!(sat(y) <= sat([230, 200, 40, 255]) + 0.02, "{y:?}");
// The dark blue is inked darker, still blue.
let b = at(&d, w, 34, 24);
assert!(b[2] < 120 && b[2] >= b[0] && b[2] >= b[1], "{b:?}");
}
#[test]
fn crosshatch_hatches_flat_colour_and_keeps_its_tone() {
let (w, h) = (48, 48);
let src = image(w, h, |_, _| grey(128));
let spread = |d: &[u8]| {
let m = mean_tone(d, w, 8..40, 8..40);
let var = (8..40).flat_map(|x| (8..40).map(move |y| (x, y))).map(|(x, y)| (f64::from(at(d, w, x, y)[0]) - m).powi(2)).sum::<f64>() / 1024.0;
(m, var.sqrt())
};
let (one, three) =
(run_fx(&src, w, h, "brushStrokes.crosshatch", json!({})), run_fx(&src, w, h, "brushStrokes.crosshatch", json!({"strength": 3})));
let ((m1, s1), (_, s3)) = (spread(&one), spread(&three));
assert!((m1 - 128.0).abs() < 25.0 && s1 > 3.0, "{m1} ± {s1}");
assert!(s3 > s1, "more passes, more hatching: {s3} vs {s1}");
}
#[test]
fn stroke_directions_smear_along_themselves() {
let (w, h) = (48, 48);
// A white line two pixels thick across black.
let src = image(w, h, |_, y| if (23..25).contains(&y) { grey(255) } else { grey(0) });
let row = |d: &[u8], y: usize| mean_tone(d, w, 12..36, y..y + 1);
let along = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "horizontal"}));
let across = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0, "strokeDirection": "vertical"}));
assert!(row(&along, 20) < 1.0 && row(&along, 24) > 250.0, "strokes along the line keep it");
assert!(row(&across, 20) > 15.0 && row(&across, 24) < 200.0, "strokes across it spread it: {} {}", row(&across, 20), row(&across, 24));
// Diagonal strokes spread it too, along the diagonal.
let diagonal = run_fx(&src, w, h, "brushStrokes.sprayedStrokes", json!({"sprayRadius": 0}));
assert!(row(&diagonal, 20) > 5.0);
}
#[test]
fn spatter_scatters_content_no_farther_than_its_radius() {
let (w, h) = (64, 32);
// The left half white, the right half transparent.
let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] });
assert_eq!(run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 0})), src, "no radius, no spatter");
let f = fx("brushStrokes.spatter", json!({"sprayRadius": 15}));
let most = f.reach().unwrap();
let d = run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 15}));
assert_ne!(d, src);
let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0;
for y in (0..h).filter(|y| inside(*y, h)) {
for x in (0..w).filter(|x| inside(*x, w) && (*x as f64 + 0.5 - 32.0).abs() > most + 1.0) {
assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y})");
}
}
// The edge is spattered: white specks past it and holes before it.
let past = (33..36).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] > 127).count();
let holes = (28..31).flat_map(|x| (8..24).map(move |y| (x, y))).filter(|(x, y)| at(&d, w, *x, *y)[3] < 128).count();
assert!(past > 0 && holes > 0, "{past} specks past the edge, {holes} holes before it");
}
#[test]
fn brush_strokes_look_no_farther_than_their_reach() {
let (w, h) = (200, 24);
let base = image(w, h, |x, y| [(x * 7 % 256) as u8, (y * 11 % 256) as u8, 120, 255]);
let cases = brush_strokes().map(|id| (id, json!({}))).chain([
("brushStrokes.accentedEdges", json!({"edgeWidth": 14, "smoothness": 15})),
("brushStrokes.angledStrokes", json!({"strokeLength": 50})),
("brushStrokes.crosshatch", json!({"strokeLength": 50, "strength": 3})),
("brushStrokes.inkOutlines", json!({"strokeLength": 50})),
("brushStrokes.spatter", json!({"sprayRadius": 25, "smoothness": 15})),
("brushStrokes.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})),
("brushStrokes.sumiE", json!({"strokeWidth": 15})),
]);
for (id, p) in cases {
let reach = fx(id, p.clone()).reach().unwrap();
// Everything from `cut` on differs; the pixels left of 20 can't tell.
let cut = 20 + reach.ceil() as usize + 1;
assert!(cut < w, "{id}");
let other = image(w, h, |x, y| if x >= cut { [255 - (x * 5 % 256) as u8, 30, (y * 9 % 256) as u8, 200] } else { at(&base, w, x, y) });
let (a, b) = (run_fx(&base, w, h, id, p.clone()), run_fx(&other, w, h, id, p.clone()));
for (x, y) in (0..20).flat_map(|x| (0..h).map(move |y| (x, y))) {
let (pa, pb) = (at(&a, w, x, y), at(&b, w, x, y));
assert!(pa.iter().zip(pb).all(|(u, v)| u.abs_diff(v) <= 2), "{id} {p}: ({x}, {y}) {pa:?} vs {pb:?}, reach {reach}");
}
}
}
#[test]
fn brush_strokes_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 in brush_strokes() {
let f = fx(id, json!({}));
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}");
}
}

View File

@ -233,14 +233,14 @@ fn bevel(d: f64, width: f64) -> f64 {
/// 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 {
pub(super) struct Tones {
v: Vec<f32>,
w: usize,
h: usize,
}
impl Tones {
fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
pub(super) 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);
@ -249,7 +249,7 @@ impl Tones {
Self { v, w, h }
}
fn get(&self, x: usize, y: usize) -> f64 {
pub(super) 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))
}

View File

@ -1,5 +1,5 @@
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort ›
//! Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Brush Strokes,
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › 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.
@ -18,6 +18,18 @@ const EFFECTS: [(&str, &str); 3] = [
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
];
/// Effect › Brush Strokes, at their defaults.
const BRUSH_STROKES: [(&str, &str); 8] = [
("brushStrokes.accentedEdges", "{}"),
("brushStrokes.angledStrokes", "{}"),
("brushStrokes.crosshatch", "{}"),
("brushStrokes.darkStrokes", "{}"),
("brushStrokes.inkOutlines", "{}"),
("brushStrokes.spatter", "{}"),
("brushStrokes.sprayedStrokes", "{}"),
("brushStrokes.sumiE", "{}"),
];
/// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the
/// striped art's reds are dark).
const DISTORT: [(&str, &str); 3] =
@ -78,6 +90,7 @@ fn listed_applied_edited_and_undone() {
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 [
(BRUSH_STROKES.as_slice(), "Brush Strokes"),
(DISTORT.as_slice(), "Distort"),
(PIXELATE.as_slice(), "Pixelate"),
(TEXTURE.as_slice(), "Texture"),
@ -108,7 +121,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(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
@ -137,7 +150,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(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(BRUSH_STROKES).chain(DISTORT).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();

View File

@ -292,6 +292,26 @@ 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": "brushStrokes.accentedEdges", "params": {"edgeWidth": 1e308, "edgeBrightness": "NaN", "smoothness": -1e308}}),
),
(
"effect.apply",
json!({"effect": "brushStrokes.angledStrokes", "params": {"directionBalance": [1], "strokeLength": 1e308, "sharpness": null}}),
),
("effect.apply", json!({"effect": "brushStrokes.crosshatch", "params": {"strokeLength": "1e999", "sharpness": 1e308, "strength": -1e308}})),
("effect.apply", json!({"effect": "brushStrokes.darkStrokes", "params": {"balance": -1e308, "blackIntensity": {}, "whiteIntensity": 1e308}})),
(
"effect.apply",
json!({"effect": "brushStrokes.inkOutlines", "params": {"strokeLength": -1, "darkIntensity": 1e308, "lightIntensity": "x"}}),
),
("effect.apply", json!({"effect": "brushStrokes.spatter", "params": {"sprayRadius": 1e308, "smoothness": "NaN"}})),
(
"effect.apply",
json!({"effect": "brushStrokes.sprayedStrokes", "params": {"strokeLength": 1e308, "sprayRadius": -1e308, "strokeDirection": 3}}),
),
("effect.apply", json!({"effect": "brushStrokes.sumiE", "params": {"strokeWidth": 0, "strokePressure": 1e308, "contrast": [1]}})),
("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}})),

View File

@ -165,6 +165,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")]),
("brushStrokes.sprayedStrokes", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS),
("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES),
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),

View File

@ -723,3 +723,11 @@ lighting Light Světlo
Offset Odsazení
Rotation Otočení
Cancel Zrušit
Accented Edges… Zdůrazněné okraje…
Angled Strokes… Šikmé tahy…
Crosshatch… Křížové šrafování…
Dark Strokes… Tmavé tahy…
Ink Outlines… Obrysy tuší…
Spatter… Rozstřik…
Sprayed Strokes… Stříkané tahy…
Sumi-e… Sumi-e…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4016,3 +4016,34 @@ theme Appearance Erscheinungsbild
Shading Schattierung
Show axis Achse einblenden
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Entfernt bei „Aussehen umwandeln“ verdeckte Geometrie. Die Live-Vorschau behält die ganze Oberfläche. Transparente Farben und Oberflächen jenseits der Sichtbarkeitsgrenzen behalten alle Flächen.
Accented Edges… Kanten betonen…
Angled Strokes… Schräge Striche…
Crosshatch… Kreuzschraffur…
Dark Strokes… Dunkle Striche…
Ink Outlines… Tintenumrisse…
Spatter… Spritzer…
Sprayed Strokes… Gesprühte Striche…
Sumi-e… Sumi-e…
Accented Edges Kanten betonen
Angled Strokes Schräge Striche
Crosshatch Kreuzschraffur
Dark Strokes Dunkle Striche
Ink Outlines Tintenumrisse
Spatter Spritzer
Sprayed Strokes Gesprühte Striche
Sumi-e Sumi-e
Direction Balance Richtungsbalance
Stroke Length Strichlänge
Sharpness Schärfe
Strength Stärke
Balance Balance
Black Intensity Schwarzintensität
White Intensity Weißintensität
Dark Intensity Dunkle Intensität
Light Intensity Lichtintensität
Spray Radius Sprühradius
Stroke Direction Strichrichtung
Stroke Width Strichbreite
Stroke Pressure Strichdruck
Right Diagonal Diagonal nach rechts
Left Diagonal Diagonal nach links

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4062,3 +4062,33 @@ axis Both Ambos
lighting Light Luz
Keep visible surfaces only when expanding Conservar solo superficies visibles al expandir
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recorta la geometría oculta al expandir la apariencia. La vista previa conserva toda la superficie. Las pinturas transparentes y las superficies que superan los límites de visibilidad conservan todas las caras.
Accented Edges… Bordes acentuados…
Angled Strokes… Pinceladas en ángulo…
Crosshatch… Sombreado cruzado…
Dark Strokes… Pinceladas oscuras…
Ink Outlines… Contornos de tinta…
Spatter… Salpicadura…
Sprayed Strokes… Pinceladas pulverizadas…
Sumi-e… Sumi-e…
Accented Edges Bordes acentuados
Angled Strokes Pinceladas en ángulo
Crosshatch Sombreado cruzado
Dark Strokes Pinceladas oscuras
Ink Outlines Contornos de tinta
Spatter Salpicadura
Sprayed Strokes Pinceladas pulverizadas
Sumi-e Sumi-e
Direction Balance Equilibrio de dirección
Stroke Length Longitud de pincelada
Sharpness Nitidez
Strength Fuerza
Balance Equilibrio
Black Intensity Intensidad del negro
White Intensity Intensidad del blanco
Dark Intensity Intensidad oscura
Spray Radius Radio de pulverización
Stroke Direction Dirección de pincelada
Stroke Width Anchura de pincelada
Stroke Pressure Presión de pincelada
Right Diagonal Diagonal derecha
Left Diagonal Diagonal izquierda

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4072,3 +4072,33 @@ effect Soft Doux
Keep visible surfaces only when expanding Conserver uniquement les surfaces visibles lors de la décomposition
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Découpe la géométrie masquée lors de la décomposition de l’aspect. L’aperçu conserve toute la surface. Les peintures transparentes et les surfaces dépassant les limites de visibilité conservent toutes les faces.
lighting Light Lumière
Accented Edges… Contours accentués…
Angled Strokes… Traits obliques…
Crosshatch… Hachures croisées…
Dark Strokes… Traits sombres…
Ink Outlines… Contours à l'encre…
Spatter… Éclaboussures…
Sprayed Strokes… Traits pulvérisés…
Sumi-e… Sumi-e…
Accented Edges Contours accentués
Angled Strokes Traits obliques
Crosshatch Hachures croisées
Dark Strokes Traits sombres
Ink Outlines Contours à l'encre
Spatter Éclaboussures
Sprayed Strokes Traits pulvérisés
Sumi-e Sumi-e
Direction Balance Équilibre de direction
Stroke Length Longueur des traits
Sharpness Netteté
Strength Force
Balance Équilibre
Black Intensity Intensité du noir
White Intensity Intensité du blanc
Dark Intensity Intensité sombre
Spray Radius Rayon de pulvérisation
Stroke Direction Direction des traits
Stroke Width Largeur des traits
Stroke Pressure Pression des traits
Right Diagonal Diagonale droite
Left Diagonal Diagonale gauche

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4064,3 +4064,33 @@ effect Regular Normale
lighting Light Luce
Keep visible surfaces only when expanding Mantieni solo le superfici visibili durante l’espansione
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Ritaglia la geometria coperta durante Espandi aspetto. L’anteprima mantiene la superficie completa. Le tinte trasparenti e le superfici che superano i limiti di visibilità mantengono tutte le facce.
Accented Edges… Bordi accentuati…
Angled Strokes… Pennellate angolate…
Crosshatch… Tratteggio incrociato…
Dark Strokes… Pennellate scure…
Ink Outlines… Contorni a china…
Spatter… Spruzzo…
Sprayed Strokes… Pennellate spruzzate…
Sumi-e… Sumi-e…
Accented Edges Bordi accentuati
Angled Strokes Pennellate angolate
Crosshatch Tratteggio incrociato
Dark Strokes Pennellate scure
Ink Outlines Contorni a china
Spatter Spruzzo
Sprayed Strokes Pennellate spruzzate
Sumi-e Sumi-e
Direction Balance Bilanciamento direzione
Stroke Length Lunghezza pennellata
Sharpness Nitidezza
Strength Forza
Balance Bilanciamento
Black Intensity Intensità nero
White Intensity Intensità bianco
Dark Intensity Intensità scuri
Spray Radius Raggio spruzzo
Stroke Direction Direzione pennellata
Stroke Width Larghezza pennellata
Stroke Pressure Pressione pennellata
Right Diagonal Diagonale destra
Left Diagonal Diagonale sinistra

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4057,3 +4057,33 @@ effect Vertical 垂直
lighting Light ライト
Keep visible surfaces only when expanding アピアランス分割時に見える面だけを保持
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. アピアランスの分割時に隠れた部分を除去します。プレビューでは面全体を保持します。透明なペイントや可視性処理の制限を超える面は、すべての面を保持します。
Accented Edges… エッジの強調…
Angled Strokes… 斜めのストローク…
Crosshatch… クロスハッチ…
Dark Strokes… 暗いストローク…
Ink Outlines… インクの輪郭…
Spatter… はね…
Sprayed Strokes… スプレーストローク…
Sumi-e… 墨絵…
Accented Edges エッジの強調
Angled Strokes 斜めのストローク
Crosshatch クロスハッチ
Dark Strokes 暗いストローク
Ink Outlines インクの輪郭
Spatter はね
Sprayed Strokes スプレーストローク
Sumi-e 墨絵
Direction Balance 方向のバランス
Stroke Length ストロークの長さ
Sharpness シャープさ
Strength 強度
Balance バランス
Black Intensity 黒の強さ
White Intensity 白の強さ
Dark Intensity 暗部の強さ
Spray Radius スプレー半径
Stroke Direction ストロークの方向
Stroke Width ストロークの幅
Stroke Pressure ストロークの筆圧
Right Diagonal 右斜め
Left Diagonal 左斜め

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2781,3 +2781,11 @@ lighting Light Luz
Left Esquerda
Right Direita
Offset Deslocamento
Accented Edges… Bordas Acentuadas…
Angled Strokes… Traços Angulares…
Crosshatch… Hachura Cruzada…
Dark Strokes… Traços Escuros…
Ink Outlines… Contornos a Tinta…
Spatter… Respingo…
Sprayed Strokes… Traços Borrifados…
Sumi-e… Sumi-e…

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -4060,3 +4060,33 @@ axis Both Оба
lighting Light Свет
Keep visible surfaces only when expanding При разборе сохранять только видимые поверхности
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Удаляет скрытую геометрию при разборе оформления. Предпросмотр сохраняет всю поверхность. Прозрачные краски и поверхности, превышающие ограничения видимости, сохраняют все грани.
Accented Edges… Акцент на краях…
Angled Strokes… Наклонные мазки…
Crosshatch… Перекрёстная штриховка…
Dark Strokes… Тёмные мазки…
Ink Outlines… Контуры тушью…
Spatter… Разбрызгивание…
Sprayed Strokes… Распылённые мазки…
Sumi-e… Суми-э…
Accented Edges Акцент на краях
Angled Strokes Наклонные мазки
Crosshatch Перекрёстная штриховка
Dark Strokes Тёмные мазки
Ink Outlines Контуры тушью
Spatter Разбрызгивание
Sprayed Strokes Распылённые мазки
Sumi-e Суми-э
Direction Balance Баланс направления
Stroke Length Длина мазка
Sharpness Резкость
Strength Сила
Balance Баланс
Black Intensity Интенсивность чёрного
White Intensity Интенсивность белого
Dark Intensity Интенсивность тёмного
Spray Radius Радиус распыления
Stroke Direction Направление мазков
Stroke Width Ширина мазка
Stroke Pressure Нажим мазка
Right Diagonal Диагональ вправо
Left Diagonal Диагональ влево

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4043,3 +4043,33 @@ axis Both Обидві
Keep visible surfaces only when expanding Під час розгортання зберігати лише видимі поверхні
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Обрізає приховану геометрію під час розгортання оформлення. Попередній перегляд зберігає всю поверхню. Прозорі заливки та поверхні, що перевищують обмеження видимості, зберігають усі грані.
lighting Light Світло
Accented Edges… Акцент на краях…
Angled Strokes… Похилі мазки…
Crosshatch… Перехресне штрихування…
Dark Strokes… Темні мазки…
Ink Outlines… Контури тушшю…
Spatter… Розбризкування…
Sprayed Strokes… Розпилені мазки…
Sumi-e… Сумі-е…
Accented Edges Акцент на краях
Angled Strokes Похилі мазки
Crosshatch Перехресне штрихування
Dark Strokes Темні мазки
Ink Outlines Контури тушшю
Spatter Розбризкування
Sprayed Strokes Розпилені мазки
Sumi-e Сумі-е
Direction Balance Баланс напрямку
Stroke Length Довжина мазка
Sharpness Різкість
Strength Сила
Balance Баланс
Black Intensity Інтенсивність чорного
White Intensity Інтенсивність білого
Dark Intensity Інтенсивність темного
Spray Radius Радіус розпилення
Stroke Direction Напрямок мазків
Stroke Width Ширина мазка
Stroke Pressure Натиск мазка
Right Diagonal Діагональ праворуч
Left Diagonal Діагональ ліворуч

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -3693,3 +3693,33 @@ effect Vertical 垂直
lighting Light 光源
Keep visible surfaces only when expanding 扩展时仅保留可见表面
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 扩展外观时裁剪被遮挡的几何区域。实时预览保留完整表面。透明绘制或超出可见性处理限制的表面保留所有面。
Accented Edges… 强化的边缘…
Angled Strokes… 成角的线条…
Crosshatch… 阴影线…
Dark Strokes… 深色线条…
Ink Outlines… 墨水轮廓…
Spatter… 喷溅…
Sprayed Strokes… 喷色描边…
Sumi-e… 水墨画…
Accented Edges 强化的边缘
Angled Strokes 成角的线条
Crosshatch 阴影线
Dark Strokes 深色线条
Ink Outlines 墨水轮廓
Spatter 喷溅
Sprayed Strokes 喷色描边
Sumi-e 水墨画
Direction Balance 方向平衡
Stroke Length 描边长度
Sharpness 锐化程度
Strength 力度
Balance 平衡
Black Intensity 黑色强度
White Intensity 白色强度
Dark Intensity 深色强度
Spray Radius 喷色半径
Stroke Direction 描边方向
Stroke Width 描边宽度
Stroke Pressure 描边压力
Right Diagonal 右对角线
Left Diagonal 左对角线

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3474,3 +3474,33 @@ effect Soft 柔和
lighting Light 光源
Keep visible surfaces only when expanding 展開時僅保留可見表面
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 展開外觀時裁剪被遮擋的幾何區域。即時預覽保留完整表面。透明繪製或超出可見性處理限制的表面保留所有面。
Accented Edges… 強調邊緣…
Angled Strokes… 傾斜筆觸…
Crosshatch… 交叉影線…
Dark Strokes… 深色筆觸…
Ink Outlines… 墨水輪廓…
Spatter… 噴濺…
Sprayed Strokes… 噴灑筆觸…
Sumi-e… 水墨畫…
Accented Edges 強調邊緣
Angled Strokes 傾斜筆觸
Crosshatch 交叉影線
Dark Strokes 深色筆觸
Ink Outlines 墨水輪廓
Spatter 噴濺
Sprayed Strokes 噴灑筆觸
Sumi-e 水墨畫
Direction Balance 方向平衡
Stroke Length 筆觸長度
Sharpness 銳利度
Strength 力度
Balance 平衡
Black Intensity 黑色強度
White Intensity 白色強度
Dark Intensity 暗部強度
Spray Radius 噴灑半徑
Stroke Direction 筆觸方向
Stroke Width 筆觸寬度
Stroke Pressure 筆觸壓力
Right Diagonal 右對角線
Left Diagonal 左對角線

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3303,8 +3303,9 @@ 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]); 6] = [
const RASTER_MENUS: [(&str, &[&str]); 7] = [
("Blur", &["Effect", "Blur"]),
("Brush Strokes", &["Effect", "Brush Strokes"]),
("Distort", &["Effect", "Distort"]),
("Pixelate", &["Effect", "Pixelate"]),
("Sharpen", &["Effect", "Sharpen"]),

View File

@ -95,6 +95,24 @@ fn effect_menu_lists_the_distort_filters_between_blur_and_pixelate() {
assert!(raster.contains(&"Distort & Transform"));
}
/// Effect › Brush Strokes, between Blur and Distort: each filter opens its dialog.
#[test]
fn effect_menu_lists_the_brush_strokes_filters_between_blur_and_distort() {
let app = app();
let entries = menus::menu_entries(&app);
let strokes: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Brush Strokes"]).collect();
let labels: Vec<&str> = strokes.iter().map(|e| e.label.as_str()).collect();
assert_eq!(
labels,
["Accented Edges…", "Angled Strokes…", "Crosshatch…", "Dark Strokes…", "Ink Outlines…", "Spatter…", "Sprayed Strokes…", "Sumi-e…"]
);
assert!(strokes.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("Brush Strokes") && at("Brush Strokes") < at("Distort"));
}
/// Effect › Texture, between Sharpen and Video: each filter opens its dialog.
#[test]
fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {

View File

@ -12,7 +12,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
| Group | Illustrator | VectorCraft | % | Remaining (h) |
|---|---:|---:|---:|---:|
| Illustrator effects (vector and stylize) | 50 | 44 | 88% | 6–10 |
| Photoshop effects (raster, Effect Gallery) | 57 | 20 | 35% | 30–50 |
| Photoshop effects (raster, Effect Gallery) | 57 | 28 | 49% | 24–40 |
| 3D and Materials | 4 (+3 Classic) | 1 (initial Revolve) | ~8% | 45–75 |
| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — |
@ -38,7 +38,7 @@ Document Raster Effects Settings exists.
|---|---|---:|---|
| Artistic | Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting, Watercolor | 0/15 | all |
| Blur | Gaussian Blur, Radial Blur, Smart Blur | 3/3 | Smart Blur's Edge Only and Overlay Edge modes |
| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 0/8 | all |
| Brush Strokes | Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e | 8/8 | — |
| Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | Glass's Load Texture |
| Pixelate | Color Halftone, Crystallize, Mezzotint, Pointillize | 4/4 | — |
| Sharpen | Unsharp Mask | 1/1 | — |
@ -50,7 +50,7 @@ Document Raster Effects Settings exists.
The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a
catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter
(see [calibration](target-app-parity.md#calibration)), so the 37 missing filters are ~26–44 h and the Effect
(see [calibration](target-app-parity.md#calibration)), so the 29 missing filters are ~20–35 h and the Effect
Gallery dialog another 4–6 h. These parallelize well across agents.
## 3D and Materials
@ -72,5 +72,6 @@ rendering, Turntable, 3D export) is 45–75 h, still the largest single gap.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Brush Strokes landed (8 filters): 28/57 Photoshop effects |
| 2026-10-10 | minor | Initial Revolve landed (#846) |
| 2026-10-10 | major | First checklist, counted from the effects catalogue (20/57 Photoshop effects, 44/50 Illustrator effects) |

View File

@ -22,7 +22,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
| 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) |
| 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) |
| 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) |
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 30–50 | no | [effects-parity.md](effects-parity.md) |
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 24–40 | no | [effects-parity.md](effects-parity.md) |
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 15–25 | no | [target-app-parity.md](target-app-parity.md) |
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 8–16 | macOS pressure | [hardware-parity.md](hardware-parity.md) |
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 17–28 | no | [target-app-parity.md](target-app-parity.md) |
@ -111,12 +111,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G2 Photoshop-style raster effects and the Effect Gallery
- **Missing:** 37 of 57 filters (all of Artistic, Brush Strokes and Sketch), the Effect Gallery dialog, Load
- **Missing:** 29 of 57 filters (all of Artistic and Sketch), the Effect Gallery dialog, Load
Texture for Glass and Texturizer, Smart Blur's Edge Only and Overlay Edge. Measured in
[effects-parity.md](effects-parity.md).
- **Done:** the pixel-filter pipeline and 20 filters (Blur, Distort, Pixelate, Sharpen, Stylize, Texture,
Video), the last 13 on 2026-10-10.
- **Estimate:** 30–50 h (measured 0.7–1.2 h per filter); parallelizes well.
- **Done:** the pixel-filter pipeline and 28 filters (Blur, Brush Strokes, Distort, Pixelate, Sharpen, Stylize,
Texture, Video), Brush Strokes on 2026-10-11.
- **Estimate:** 24–40 h (measured 0.7–1.2 h per filter); parallelizes well.
## G13 Illustrator 2026 (30.x) additions

View File

@ -31,7 +31,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
2. **`.ai` files from real users** (G9): layer structure (#951, #868), guides (#779). 10–20 h.
3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h.
4. **Core-tool bugs** (G11): Shape Builder regions (#937, #893), Asset Export borders (#983). 8–15 h.
5. **Raster effects** (G2) continue alongside: Artistic, Brush Strokes, Sketch, then the Effect Gallery. 30–50 h.
5. **Raster effects** (G2) continue alongside: Artistic, Sketch, then the Effect Gallery. 24–40 h.
## Next

View File

@ -11,7 +11,7 @@ the work list made from it. Deep areas have their own checklists: [UI and intera
| Number | Value | Kind |
|---|---|---|
| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 348 of 375 items wired (92.8%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 20 of 57 (35%), 3D 1 of 4 (initial Revolve); blended by the area weights below |
| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 348 of 375 items wired (92.8%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 28 of 57 (49%), 3D 1 of 4 (initial Revolve); blended by the area weights below |
| **Feature depth** (each area scored by behaviour, weighted) | **~75%** | estimated, area table below |
| **Ready for real work, mainstream illustrator** | **~65%** (60–70%) | estimated, see [Ready for real work](#ready-for-real-work) |
| **Ready for real work, full Illustrator** (decides the stage) | **~62%** (57–67%) | estimated: additive weighted sum over the dimensions, see [Ready for real work](#ready-for-real-work) |
@ -74,7 +74,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Strokes, brushes, width profiles | 5 | 88% | Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated |
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated |
| Raster effects (Effect Gallery) | 4 | 40% | 37 Photoshop-style filters (Artistic, Brush Strokes, Sketch), the Effect Gallery dialog, Load Texture | 30–50 | measured count 20/57 |
| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 |
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
@ -114,7 +114,7 @@ review parallelize less and need humans.
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (30–50 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (24–40 h), advanced type (20–28 h), generative AI (30–60 h), localization (70–110 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |