diff --git a/README.md b/README.md index f86423fb..0d0457fd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index c422d4c0..b97cf0a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 1814ef08..1c12a5c8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 { "{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…", diff --git a/crates/effects/src/pixel/brushstrokes.rs b/crates/effects/src/pixel/brushstrokes.rs new file mode 100644 index 00000000..416a67de --- /dev/null +++ b/crates/effects/src/pixel/brushstrokes.rs @@ -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(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 { + let mut tone: Vec = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect(); + let mut cover: Vec = 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 = 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); + }); +} diff --git a/crates/effects/src/pixel/distort.rs b/crates/effects/src/pixel/distort.rs index fcf94056..1f4884ff 100644 --- a/crates/effects/src/pixel/distort.rs +++ b/crates/effects/src/pixel/distort.rs @@ -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; } diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index 25812fff..6cacd04c 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -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 = (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 = src.iter().map(|p| f32::from(p[3]) / 255.0).collect(); + let mut edges: Vec = 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; 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 { + 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() +} diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 62592768..77cc0b62 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -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 { 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) diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 2de43c0e..f2b8a784 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -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 { + 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 { + 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 { + 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)> = 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![]; + 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, ys: std::ops::Range) -> 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::() / (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 { + 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| 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::() / 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::() / 4) as u8) + }); + let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::() / 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}"); + } +} diff --git a/crates/effects/src/pixel/texture.rs b/crates/effects/src/pixel/texture.rs index ead50a7f..9855aa3c 100644 --- a/crates/effects/src/pixel/texture.rs +++ b/crates/effects/src/pixel/texture.rs @@ -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, 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 = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect(); let mut a: Vec = 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)) } diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index a32bd709..90f6d2b5 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -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(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 1ad18115..d603ca88 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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}})), diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 1337e341..e29175ff 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -165,6 +165,7 @@ fn choices(effect: &str, key: &str) -> Option { ("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), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f02a85f8..4cafe577 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ca51323e..b64e2878 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 82ed1583..cf34a1a3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6b6e260a..67993dce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eccca0d4..094f6e51 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 179e4b86..e109c1ea 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 左斜め diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ec9b4d38..c1d67d4b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a3316937..6226e177 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Диагональ влево diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6de840be..bc000368 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Діагональ ліворуч diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 59a88ce3..6358d2ae 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 左对角线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 77ac676d..2df10453 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 左對角線 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4ddfd628..091fb5da 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3303,8 +3303,9 @@ fn font_items(english_names: bool) -> Vec { /// 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"]), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index a5b37af9..eeade4fb 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -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() { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index cd2e7a83..afd2338c 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index 405d06da..9d92e4ba 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 749f5b70..a5f7653a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a3d1de2a..4d1d1a53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 |