Files
vectorcraft/crates/effects/src/pixel/edges.rs
T
bflatasticandClaude Opus 5.5 59c890df9e Part of gap 2: Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e)
Eight raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::brushstrokes), under a new Effect › Brush
Strokes submenu between Blur and Distort in the Raster Effects section:

- Accented Edges (edgeWidth pt 1..14, default 2; edgeBrightness 0..50,
  38; smoothness 1..15, 5): the edges of the softened object painted
  towards white chalk at high brightness or black ink at low brightness;
  Smoothness also leaves the faint edges out. The shape stays as it was.
- Angled Strokes (directionBalance 0..100, 50; strokeLength pt 3..50, 15;
  sharpness 0..10, 3): light areas in right diagonal strokes, dark areas
  in left diagonal ones; 0 makes every stroke a left diagonal, 100 every
  stroke a right diagonal. Sharpness brings detail and bristles back.
- Crosshatch (strokeLength pt 3..50, 9; sharpness 0..20, 6; strength
  1..3, 1): the detail kept under pencil hatching along both diagonals,
  Strength passes of it, the edges roughened.
- Dark Strokes (balance 0..10, 5; blackIntensity 0..10, 6;
  whiteIntensity 0..10, 2): short strokes towards black in the dark
  areas, long strokes towards white in the light areas.
- Ink Outlines (strokeLength pt 1..50, 4; darkIntensity 0..50, 20;
  lightIntensity 0..50, 10): black outlines over the edges, ink hatching
  in the shadows, shadows darkened and highlights lightened.
- Spatter (sprayRadius pt 0..25, 10; smoothness 1..15, 5): each pixel
  shifted by noise up to 0.4 x the radius; Smoothness makes the grains
  larger and smoother.
- Sprayed Strokes (strokeLength pt 0..20, 12; sprayRadius pt 0..25, 7;
  strokeDirection Right Diagonal, Horizontal, Left Diagonal or Vertical):
  strokes along the direction, scattered by up to 0.3 x the radius.
- Sumi-e (strokeWidth pt 3..15, 10; strokePressure 0..15, 2; contrast
  0..40, 16): wet strokes, ink bleeding from the darks along paper
  fibres, more ink with pressure and an S-curve contrast for rich blacks.
  It works in value only: hues and saturation stay, and so does the
  object's shape (no fringe in the transparency).

A stroke averages the colour along a segment through the pixel (16
samples at most, the art softened first when they lie more than a pixel
apart); strokes lie in rows around the object's centre, each row
starting at random and each stroke shifted along itself, with streaked
noise for the bristles. Everything is in document space, so the effects
look the same at any zoom or resolution, move with the object and draw
the same every time; outsets and reaches are exact. On a 2000 x 2000
raster in release the slowest settings take 0.3-2.0 s (Crosshatch at
50 pt with 3 passes the most).

Shared helpers instead of copies: edges::sobel (Glowing Edges now uses
it, unchanged), distort::displace and texture::Tones are pub(super).

Catalogue rows with parameter docs, menu entries, Sprayed Strokes'
direction dropdown, translations (de, es, fr, it, ja, ru, uk, zh-hans,
zh-hant; menu labels in cs and pt-br), tests (parameters and clamping,
outsets and reaches, each filter and option showing, chalk and ink on
the edges only, shadows and rich blacks, Sumi-e keeping the shape and
hues, hatching keeping the tone, strokes smearing along their direction,
Spatter's radius, resolution and move invariance, canvas, multithreaded,
PDF and SVG, the command sweep with junk parameters, the menu) and
ROADMAP.

Behaviour from public documentation only:
https://helpx.adobe.com/photoshop-elements/using/brush-stroke-filters.html
https://www.computerhope.com/jargon/p/photoshop-sprayed-strokes.htm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:19:26 +07:00

92 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Stylize › Glowing Edges.
use super::blur_plane;
/// Find Sobel edges, smooth and expand them, then draw coloured outlines on black. Samples outside
/// the raster are transparent.
pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightness: f64, smoothness: f64) {
if w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) {
return;
}
let src = px.to_vec();
let pixel = |x: isize, y: isize| -> ([f32; 3], f32) {
// Beyond the raster's edges is transparency (not the next row's first pixel).
let (Ok(x), Ok(y)) = (usize::try_from(x), usize::try_from(y)) else { return ([0.0; 3], 0.0) };
if x >= w || y >= h {
return ([0.0; 3], 0.0);
}
let Some(p) = src.get(y * w + x) else { return ([0.0; 3], 0.0) };
let alpha = f32::from(p[3]) / 255.0;
let straight =
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: Vec<f32> = (0..px.len())
.map(|i| {
let (rgb, alpha) = pixel((i % w) as isize, (i / w) as isize);
(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha
})
.collect();
let coverage: Vec<f32> = src.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let mut edges: Vec<f32> = sobel(&signal, w, h).iter().zip(sobel(&coverage, w, h)).map(|(s, a)| s.max(a).clamp(0.0, 1.0)).collect();
let mut colours: [Vec<f32>; 3] = std::array::from_fn(|_| vec![0.0; px.len()]);
for (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;
}
}
}
blur_plane(&mut edges, w, h, smoothness.max(0.0));
if width > 0.5 {
blur_plane(&mut edges, w, h, width * 0.5);
}
let peak = edges.iter().copied().fold(0.0f32, f32::max);
if peak > 0.0 {
for colour in &mut colours {
blur_plane(colour, w, h, smoothness.max(0.0));
if width > 0.5 {
blur_plane(colour, w, h, width * 0.5);
}
}
}
for (index, (out, (original, edge))) in px.iter_mut().zip(src.iter().zip(edges)).enumerate() {
let alpha = f32::from(original[3]) / 255.0;
let glow = if peak > 0.0 { (edge / peak * brightness as f32 / 6.0).clamp(0.0, 1.0) } else { 0.0 };
let colour_weight = colours[0][index].max(colours[1][index]).max(colours[2][index]);
let straight = if colour_weight > 1e-6 { std::array::from_fn(|channel| colours[channel][index] / colour_weight) } else { [0.0; 3] };
// Premultiplied: the glow is light of the edge's colour, so outside the shape it keeps that
// colour at the glow's opacity instead of darkening as it fades.
let a = alpha.max(glow);
*out = [
(straight[0] * glow * 255.0).round().clamp(0.0, 255.0) as u8,
(straight[1] * glow * 255.0).round().clamp(0.0, 255.0) as u8,
(straight[2] * glow * 255.0).round().clamp(0.0, 255.0) as u8,
(a * 255.0).round() as u8,
];
}
}
/// The Sobel gradient's length over a `w` × `h` plane of values (zero beyond its edges): 8 × the
/// slope per pixel on a ramp, 4 × the step at a sharp edge. Empty when the sizes don't match.
pub(super) fn sobel(v: &[f32], w: usize, h: usize) -> Vec<f32> {
if w == 0 || w.checked_mul(h) != Some(v.len()) {
return Vec::new();
}
let at = |x: usize, dx: isize, y: usize, dy: isize| -> f32 {
match (x.checked_add_signed(dx), y.checked_add_signed(dy)) {
(Some(x), Some(y)) if x < w && y < h => v.get(y * w + x).copied().unwrap_or(0.0),
_ => 0.0,
}
};
(0..v.len())
.map(|i| {
let (x, y) = (i % w, i / w);
let gx = at(x, 1, y, -1) - at(x, -1, y, -1) + 2.0 * (at(x, 1, y, 0) - at(x, -1, y, 0)) + at(x, 1, y, 1) - at(x, -1, y, 1);
let gy = at(x, -1, y, 1) - at(x, -1, y, -1) + 2.0 * (at(x, 0, y, 1) - at(x, 0, y, -1)) + at(x, 1, y, 1) - at(x, 1, y, -1);
gx.hypot(gy)
})
.collect()
}