From 2d371849df6ecb0dbacfda5a5dfbf335421acb3b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 01:24:10 +1100 Subject: [PATCH 1/5] M9.1: add Find Edges raster effect --- ROADMAP.md | 6 +++--- crates/effects/src/lib.rs | 1 + crates/effects/src/pixel/edges.rs | 30 ++++++++++++++++++++++++++++++ crates/effects/src/pixel/mod.rs | 11 +++++++++-- crates/effects/src/pixel/tests.rs | 11 +++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 crates/effects/src/pixel/edges.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8e838ddf..201c3531 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,11 +53,11 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent 1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`, `06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h. -2. **Photoshop-style raster effects and the Effect Gallery:** about 52 filters (Artistic, Brush Strokes, Distort, +2. **Photoshop-style raster effects and the Effect Gallery:** about 51 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery. The pixel-filter pipeline is in place (`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur - (Normal mode only) and Sharpen › Unsharp Mask; each new filter is now one function, a catalogue row, menu and + (Normal mode only), Sharpen › Unsharp Mask and Stylize › Find Edges; each new filter is now one function, a catalogue row, menu and translations. Parallelizes well across agents. 26–40 h. 3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors. @@ -219,7 +219,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | -| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 24% | ~52 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur and Unsharp Mask are done | 26–40 | +| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Find Edges are done | 26–40 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7835e382..c7bb5b5e 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -290,6 +290,7 @@ pub fn effect_catalog() -> Vec { "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), ), + r("stylize.findEdges", "Find Edges", STYLIZE, "{} replaces colour with a Sobel edge map while preserving transparency", json!({})), ]; v.extend([ g( diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs new file mode 100644 index 00000000..b9804177 --- /dev/null +++ b/crates/effects/src/pixel/edges.rs @@ -0,0 +1,30 @@ +//! Stylize › Find Edges. + +use super::Px16; + +/// Convert premultiplied RGBA pixels to a Sobel edge map in place. +pub(super) fn find(px: &mut [[u8; 4]], w: usize, h: usize) { + if w < 3 || h < 3 || w.checked_mul(h) != Some(px.len()) { + return; + } + let src: Vec = px.iter().map(|p| p.map(u16::from)).collect(); + let luminance = |x: usize, y: usize| -> f32 { + src.get(y * w + x).map_or(0.0, |p| { + (0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2])) / 257.0 + }) + }; + for y in 1..h - 1 { + for x in 1..w - 1 { + let gx = -luminance(x - 1, y - 1) + luminance(x + 1, y - 1) + - 2.0 * luminance(x - 1, y) + 2.0 * luminance(x + 1, y) + - luminance(x - 1, y + 1) + luminance(x + 1, y + 1); + let gy = -luminance(x - 1, y - 1) - 2.0 * luminance(x, y - 1) - luminance(x + 1, y - 1) + + luminance(x - 1, y + 1) + 2.0 * luminance(x, y + 1) + luminance(x + 1, y + 1); + let edge = gx.hypot(gy).clamp(0.0, 255.0) as u8; + if let Some(out) = px.get_mut(y * w + x) { + let alpha = out[3]; + *out = [edge.min(alpha), edge.min(alpha), edge.min(alpha), alpha]; + } + } + } +} diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 3d2166c0..7e84f46c 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,5 +1,5 @@ //! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp -//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8. +//! Mask, and Stylize › Find Edges): filters over premultiplied RGBA8 pixels. //! //! - Every distance is in document units (points) and becomes pixels through the raster's //! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects @@ -9,6 +9,7 @@ //! - Beyond the raster's edges is transparency. mod blur; +mod edges; mod sharpen; use serde_json::Value; @@ -17,7 +18,7 @@ use vectorcraft_geom::{Affine, Point, Rect}; use crate::util::{num, text}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). -pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"]; +pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.findEdges"]; /// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths /// are in document units. @@ -33,6 +34,8 @@ pub enum PixelFx { /// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount` /// (1 = 100 %), where they differ from it by at least `threshold` levels. UnsharpMask { amount: f64, radius: f64, threshold: f64 }, + /// Stylize › Find Edges: replace colour with a Sobel edge map while preserving opacity. + FindEdges, } /// Where a raster's pixels lie in the document. @@ -69,6 +72,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { radius: num(p, "radius", 1.0).clamp(0.1, 250.0), threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0), }, + "stylize.findEdges" => PixelFx::FindEdges, _ => return None, }) } @@ -85,6 +89,7 @@ impl PixelFx { PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0), PixelFx::SmartBlur { radius, .. } => radius, PixelFx::UnsharpMask { .. } => 0.0, + PixelFx::FindEdges => 0.0, } } @@ -95,6 +100,7 @@ impl PixelFx { PixelFx::RadialBlur { .. } => None, PixelFx::SmartBlur { radius, .. } => Some(radius), PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius), + PixelFx::FindEdges => Some(1.0), } } @@ -107,6 +113,7 @@ impl PixelFx { 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::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), + PixelFx::FindEdges => edges::find(px, w, h), } } } diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index dc3d1d04..341bc072 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -55,6 +55,7 @@ fn params_take_defaults_and_stay_in_range() { fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})), PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 } ); + assert_eq!(fx("stylize.findEdges", json!({})), PixelFx::FindEdges); for id in PIXEL_EFFECTS { assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}"); } @@ -120,6 +121,16 @@ fn every_effect_is_deterministic() { } } +#[test] +fn find_edges_detects_a_boundary_and_preserves_alpha() { + let (w, h) = (9, 9); + let src = image(w, h, |x, _| if x < 4 { [0, 0, 0, 200] } else { [200, 200, 200, 200] }); + let mut out = src.clone(); + fx("stylize.findEdges", json!({})).apply(&mut out, w, h, &space(w, h)); + assert!(at(&out, w, 3, 4)[0] > 0 || at(&out, w, 4, 4)[0] > 0); + assert!(out.chunks_exact(4).all(|p| p[3] == 200 && p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); +} + #[test] fn spin_blur_smears_along_arcs_and_keeps_the_centre() { let (w, h) = (41, 41); From 11aaf816d00540849f500b84cf0584c8a124cf48 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 02:54:38 +1100 Subject: [PATCH 2/5] M9.2: replace Find Edges with Glowing Edges --- ROADMAP.md | 4 +-- crates/effects/src/lib.rs | 11 ++++-- crates/effects/src/pixel/edges.rs | 52 +++++++++++++++++------------ crates/effects/src/pixel/mod.rs | 20 ++++++----- crates/effects/src/pixel/tests.rs | 8 ++--- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 5 +++ crates/ui-egui/src/i18n/fr.tsv | 5 +++ crates/ui-egui/src/i18n/it.tsv | 5 +++ crates/ui-egui/src/i18n/ja.tsv | 5 +++ crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 5 +++ crates/ui-egui/src/i18n/uk.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++ 15 files changed, 99 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 201c3531..4c55f459 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery. The pixel-filter pipeline is in place (`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur - (Normal mode only), Sharpen › Unsharp Mask and Stylize › Find Edges; each new filter is now one function, a catalogue row, menu and + (Normal mode only), Sharpen › Unsharp Mask and Stylize › Glowing Edges; each new filter is now one function, a catalogue row, menu and translations. Parallelizes well across agents. 26–40 h. 3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors. @@ -219,7 +219,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | -| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Find Edges are done | 26–40 | +| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges are done | 26–40 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c7bb5b5e..623d0488 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -6,7 +6,7 @@ //! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble, //! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order. //! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the -//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask) are described by +//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges) are described by //! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond //! the geometry. //! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the @@ -125,6 +125,7 @@ fn lengths_of(id: &str) -> Lengths { "stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]), "stylize.dropShadow" => always(&["x", "y", "blur"]), "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), + "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), _ => Lengths::default(), } } @@ -290,7 +291,13 @@ pub fn effect_catalog() -> Vec { "{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}", json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}), ), - r("stylize.findEdges", "Find Edges", STYLIZE, "{} replaces colour with a Sobel edge map while preserving transparency", json!({})), + r( + "stylize.glowingEdges", + "Glowing Edges…", + STYLIZE, + "{edgeWidth: pt 1..14 (2), edgeBrightness: 0..20 (6), smoothness: pt 1..15 (5)} finds alpha-weighted Sobel edges and draws bright coloured outlines on black", + json!({"edgeWidth": 2.0, "edgeBrightness": 6.0, "smoothness": 5.0}), + ), ]; v.extend([ g( diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index b9804177..f1c17060 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -1,30 +1,38 @@ -//! Stylize › Find Edges. +//! Stylize › Glowing Edges. -use super::Px16; +use super::blur_plane; -/// Convert premultiplied RGBA pixels to a Sobel edge map in place. -pub(super) fn find(px: &mut [[u8; 4]], w: usize, h: usize) { - if w < 3 || h < 3 || w.checked_mul(h) != Some(px.len()) { +/// Find Sobel edges using alpha-weighted straight colour, 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: Vec = px.iter().map(|p| p.map(u16::from)).collect(); - let luminance = |x: usize, y: usize| -> f32 { - src.get(y * w + x).map_or(0.0, |p| { - (0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2])) / 257.0 - }) + let src = px.to_vec(); + let signal = |x: isize, y: isize| -> f32 { + if x < 0 || y < 0 { return 0.0; } + let Some(p) = src.get(y as usize * w + x as usize) else { return 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] }; + (0.2126 * straight[0] + 0.7152 * straight[1] + 0.0722 * straight[2]) * alpha }; - for y in 1..h - 1 { - for x in 1..w - 1 { - let gx = -luminance(x - 1, y - 1) + luminance(x + 1, y - 1) - - 2.0 * luminance(x - 1, y) + 2.0 * luminance(x + 1, y) - - luminance(x - 1, y + 1) + luminance(x + 1, y + 1); - let gy = -luminance(x - 1, y - 1) - 2.0 * luminance(x, y - 1) - luminance(x + 1, y - 1) - + luminance(x - 1, y + 1) + 2.0 * luminance(x, y + 1) + luminance(x + 1, y + 1); - let edge = gx.hypot(gy).clamp(0.0, 255.0) as u8; - if let Some(out) = px.get_mut(y * w + x) { - let alpha = out[3]; - *out = [edge.min(alpha), edge.min(alpha), edge.min(alpha), alpha]; - } + let mut edges = vec![0.0f32; 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); + if let Some(edge) = edges.get_mut(y as usize * w + x as usize) { *edge = gx.hypot(gy).clamp(0.0, 1.0); } } } + blur_plane(&mut edges, w, h, smoothness.max(0.0)); + if width > 0.5 { blur_plane(&mut edges, w, h, width * 0.5); } + for (out, (original, edge)) in px.iter_mut().zip(src.iter().zip(edges)) { + let alpha = f32::from(original[3]) / 255.0; + let glow = (edge * brightness as f32 / 6.0).clamp(0.0, 1.0); + let straight = if original[3] > 0 { [f32::from(original[0]) / 255.0 / alpha.max(1e-6), f32::from(original[1]) / 255.0 / alpha.max(1e-6), f32::from(original[2]) / 255.0 / alpha.max(1e-6)] } else { [1.0, 1.0, 1.0] }; + let a = alpha.max(glow); + *out = [(straight[0] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, (straight[1] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, (straight[2] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, (a * 255.0).round() as u8]; + } } diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 7e84f46c..1b73fc4a 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,5 +1,5 @@ //! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp -//! Mask, and Stylize › Find Edges): filters over premultiplied RGBA8 pixels. +//! Mask, and Stylize › Glowing Edges): filters over premultiplied RGBA8 pixels. //! //! - Every distance is in document units (points) and becomes pixels through the raster's //! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects @@ -18,7 +18,7 @@ use vectorcraft_geom::{Affine, Point, Rect}; use crate::util::{num, text}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). -pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.findEdges"]; +pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.glowingEdges"]; /// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths /// are in document units. @@ -34,8 +34,8 @@ pub enum PixelFx { /// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount` /// (1 = 100 %), where they differ from it by at least `threshold` levels. UnsharpMask { amount: f64, radius: f64, threshold: f64 }, - /// Stylize › Find Edges: replace colour with a Sobel edge map while preserving opacity. - FindEdges, + /// Stylize › Glowing Edges: bright coloured Sobel outlines on black. + GlowingEdges { width: f64, brightness: f64, smoothness: f64 }, } /// Where a raster's pixels lie in the document. @@ -72,7 +72,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { radius: num(p, "radius", 1.0).clamp(0.1, 250.0), threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0), }, - "stylize.findEdges" => PixelFx::FindEdges, + "stylize.glowingEdges" => PixelFx::GlowingEdges { + width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0), + brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0), + smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0), + }, _ => return None, }) } @@ -89,7 +93,7 @@ impl PixelFx { PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0), PixelFx::SmartBlur { radius, .. } => radius, PixelFx::UnsharpMask { .. } => 0.0, - PixelFx::FindEdges => 0.0, + PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0), } } @@ -100,7 +104,7 @@ impl PixelFx { PixelFx::RadialBlur { .. } => None, PixelFx::SmartBlur { radius, .. } => Some(radius), PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius), - PixelFx::FindEdges => Some(1.0), + PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)), } } @@ -113,7 +117,7 @@ impl PixelFx { 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::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), - PixelFx::FindEdges => edges::find(px, w, h), + PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)), } } } diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 341bc072..9a0ebe46 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -55,7 +55,7 @@ fn params_take_defaults_and_stay_in_range() { fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})), PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 } ); - assert_eq!(fx("stylize.findEdges", json!({})), PixelFx::FindEdges); + assert_eq!(fx("stylize.glowingEdges", json!({})), PixelFx::GlowingEdges { width: 2.0, brightness: 6.0, smoothness: 5.0 }); for id in PIXEL_EFFECTS { assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}"); } @@ -122,13 +122,13 @@ fn every_effect_is_deterministic() { } #[test] -fn find_edges_detects_a_boundary_and_preserves_alpha() { +fn glowing_edges_draw_a_bright_outline_and_preserve_premultiplication() { let (w, h) = (9, 9); let src = image(w, h, |x, _| if x < 4 { [0, 0, 0, 200] } else { [200, 200, 200, 200] }); let mut out = src.clone(); - fx("stylize.findEdges", json!({})).apply(&mut out, w, h, &space(w, h)); + fx("stylize.glowingEdges", json!({"edgeWidth": 1, "smoothness": 1, "edgeBrightness": 20})).apply(&mut out, w, h, &space(w, h)); assert!(at(&out, w, 3, 4)[0] > 0 || at(&out, w, 4, 4)[0] > 0); - assert!(out.chunks_exact(4).all(|p| p[3] == 200 && p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); + assert!(out.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); } #[test] diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b6f70826..9b7ce7a8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -9,6 +9,7 @@ # back to English. Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, # language names, OpenType, the built-in workspace names and the bracketed perspective grid preset # names. + Glowing Edges… Zářící okraje… About VectorCraft O aplikaci VectorCraft Align to Path Zarovnat k cestě Cut Path at Selected Anchor Points Rozdělit cestu ve vybraných kotevních bodech diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 57d405c9..cd1d11e4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -12,6 +12,11 @@ # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). + Glowing Edges… Bordes resplandecientes… + Glowing Edges Bordes resplandecientes + Edge Width Ancho del borde + Edge Brightness Brillo del borde + Smoothness Suavizado Radial Blur… Desenfoque radial… Smart Blur… Desenfoque inteligente… Sharpen Enfocar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 45d68a1f..873fc3cf 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -13,6 +13,11 @@ # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). + Glowing Edges… Bords lumineux… + Glowing Edges Bords lumineux + Edge Width Largeur du bord + Edge Brightness Luminosité du bord + Smoothness Lissage Radial Blur… Flou radial… Smart Blur… Flou intelligent… Sharpen Netteté diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ada181c3..2545208c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -12,6 +12,11 @@ # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). + Glowing Edges… Bordi luminosi… + Glowing Edges Bordi luminosi + Edge Width Larghezza bordo + Edge Brightness Luminosità bordo + Smoothness Uniformità Radial Blur… Sfocatura radiale… Smart Blur… Sfocatura intelligente… Sharpen Nitidezza diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ec05ce82..d3744f32 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4,6 +4,11 @@ # Clean-room: menu and feature names follow the names Japanese users of vector illustration # software know, written from the meaning of VectorCraft's English labels; no other application's # Japanese localisation was copied. Toggles read as their action (「…を表示」「…を隠す」). + Glowing Edges… エッジの光彩… + Glowing Edges エッジの光彩 + Edge Width エッジの幅 + Edge Brightness エッジの明るさ + Smoothness 滑らかさ # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, # tested) — and the status and error messages (`@msg` rows at the end, `COMPLETE_MESSAGES`, tested). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4f3f90a1..e2e03f23 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -14,6 +14,7 @@ # Brazilian Portuguese computing vocabulary (arquivo, camada, caminho, preenchimento, traçado, # pincel, amostra, mesa de trabalho ...), following common desktop-software conventions (commands # in the infinitive, menus as nouns); no proprietary localisation resource, string table or product + Glowing Edges… Bordas brilhantes… # UI was consulted or copied; add entries the same way. Product and technology names (VectorCraft, # ArtCraft, SVG, OpenType, CMYK, Lab ...) stay in Latin letters; the trailing "…" of dialog # commands is kept. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f8a56eb..542731c6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -10,6 +10,11 @@ # tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). + Glowing Edges… Светящиеся края… + Glowing Edges Светящиеся края + Edge Width Ширина края + Edge Brightness Яркость края + Smoothness Сглаживание Radial Blur… Радиальное размытие… Smart Blur… Умное размытие… Sharpen Резкость diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9e630bb3..058cc687 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -10,6 +10,11 @@ # Coverage: every menu string, tl! literal, plural, status and error message (enforced in tests). # Product, language, built-in workspace and perspective preset names follow MENU_KEEP_AS_IS. # Keep placeholders, ellipses and leading/trailing spaces of fragments when editing. + Glowing Edges… Сяйливі краї… + Glowing Edges Сяйливі краї + Edge Width Ширина краю + Edge Brightness Яскравість краю + Smoothness Згладжування Radial Blur… Радіальне розмиття… Smart Blur… Розумне розмиття… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8d7a32a9..bcf90783 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -9,6 +9,11 @@ # proprietary localisation resource, string table or product UI may be copied. Product and # technology names stay in Latin letters. Full-width punctuation (,。:“”) in sentences, half-width # parentheses. + Glowing Edges… 发光边缘… + Glowing Edges 发光边缘 + Edge Width 边缘宽度 + Edge Brightness 边缘亮度 + Smoothness 平滑度 — missing — 缺失 to search every command 以搜索所有命令 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1b317374..e0657d72 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -17,6 +17,11 @@ # commands is kept. Full-width punctuation (,。:) inside sentences; half-width parentheses and # the " · " separators as in the English. Terms shared with PhotoCraft use the same words as its # zh-hant catalog (圖層, 選取, 偏好設定, 混合模式 ...), so the two apps read alike. + Glowing Edges… 發光邊緣… + Glowing Edges 發光邊緣 + Edge Width 邊緣寬度 + Edge Brightness 邊緣亮度 + Smoothness 平滑度 — missing — 遺失 to search every command 以搜尋所有指令 From 800baacbba277f6b1b8868c114ee9b7f8358a738 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 01:23:55 +0700 Subject: [PATCH 3/5] Glowing Edges: transparency beyond the right and bottom edges; rustfmt; as_chunks in tests The edge filter read `y * w + x` for x = w (the next row's first pixel) and y = h at the raster's right and bottom edges; it now reads transparency there, as the module documents. A test checks a solid square glows alike on every side. Formatted with rustfmt, and the test uses as_chunks for clippy 1.99. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/pixel/edges.rs | 44 ++++++++++++++++++++++++------- crates/effects/src/pixel/tests.rs | 14 +++++++++- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index f1c17060..afed97da 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -10,10 +10,15 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes } let src = px.to_vec(); let signal = |x: isize, y: isize| -> f32 { - if x < 0 || y < 0 { return 0.0; } - let Some(p) = src.get(y as usize * w + x as usize) else { return 0.0 }; + // 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 }; + if x >= w || y >= h { + return 0.0; + } + let Some(p) = src.get(y * w + x) else { return 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] }; + 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] }; (0.2126 * straight[0] + 0.7152 * straight[1] + 0.0722 * straight[2]) * alpha }; let mut edges = vec![0.0f32; px.len()]; @@ -21,18 +26,39 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes 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); - if let Some(edge) = edges.get_mut(y as usize * w + x as usize) { *edge = gx.hypot(gy).clamp(0.0, 1.0); } + 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); + if let Some(edge) = edges.get_mut(y as usize * w + x as usize) { + *edge = gx.hypot(gy).clamp(0.0, 1.0); + } } } blur_plane(&mut edges, w, h, smoothness.max(0.0)); - if width > 0.5 { blur_plane(&mut edges, w, h, width * 0.5); } + if width > 0.5 { + blur_plane(&mut edges, w, h, width * 0.5); + } for (out, (original, edge)) in px.iter_mut().zip(src.iter().zip(edges)) { let alpha = f32::from(original[3]) / 255.0; let glow = (edge * brightness as f32 / 6.0).clamp(0.0, 1.0); - let straight = if original[3] > 0 { [f32::from(original[0]) / 255.0 / alpha.max(1e-6), f32::from(original[1]) / 255.0 / alpha.max(1e-6), f32::from(original[2]) / 255.0 / alpha.max(1e-6)] } else { [1.0, 1.0, 1.0] }; + let straight = if original[3] > 0 { + [ + f32::from(original[0]) / 255.0 / alpha.max(1e-6), + f32::from(original[1]) / 255.0 / alpha.max(1e-6), + f32::from(original[2]) / 255.0 / alpha.max(1e-6), + ] + } else { + [1.0, 1.0, 1.0] + }; let a = alpha.max(glow); - *out = [(straight[0] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, (straight[1] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, (straight[2] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, (a * 255.0).round() as u8]; + *out = [ + (straight[0] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, + (straight[1] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, + (straight[2] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, + (a * 255.0).round() as u8, + ]; } } diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 9a0ebe46..120e7a1c 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -128,7 +128,19 @@ fn glowing_edges_draw_a_bright_outline_and_preserve_premultiplication() { let mut out = src.clone(); fx("stylize.glowingEdges", json!({"edgeWidth": 1, "smoothness": 1, "edgeBrightness": 20})).apply(&mut out, w, h, &space(w, h)); assert!(at(&out, w, 3, 4)[0] > 0 || at(&out, w, 4, 4)[0] > 0); - assert!(out.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); + assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); +} + +/// Glowing Edges sees transparency beyond every side of the raster: a solid square glows alike at +/// its left and right edges (the right one doesn't read the next row's first pixel). +#[test] +fn glowing_edges_glow_alike_on_every_side() { + let (w, h) = (9, 9); + let mut out = image(w, h, |_, _| [180, 180, 180, 255]); + fx("stylize.glowingEdges", json!({"edgeWidth": 1, "smoothness": 1, "edgeBrightness": 20})).apply(&mut out, w, h, &space(w, h)); + let (left, right, top, bottom) = (at(&out, w, 0, 4), at(&out, w, 8, 4), at(&out, w, 4, 0), at(&out, w, 4, 8)); + assert!(left[0] > 0 && left == right && top == bottom, "{left:?} {right:?} {top:?} {bottom:?}"); + assert!(at(&out, w, 4, 4)[0] < left[0], "the inside stays darker than the edges"); } #[test] From 60831b87d9d8b7f86732e5c4c363084a9079209c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 12:03:13 +1100 Subject: [PATCH 4/5] Preserve edge color and brightness through glow blur --- crates/effects/src/pixel/edges.rs | 61 +++++++++++++++++++++---------- crates/effects/src/pixel/tests.rs | 15 ++++++++ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index afed97da..d63b2421 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -2,26 +2,31 @@ use super::blur_plane; -/// Find Sobel edges using alpha-weighted straight colour, smooth and expand them, then draw -/// coloured outlines on black. Samples outside the raster are transparent. +/// 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 signal = |x: isize, y: isize| -> f32 { + 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 }; + 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; + return ([0.0; 3], 0.0); } - let Some(p) = src.get(y * w + x) else { return 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] }; - (0.2126 * straight[0] + 0.7152 * straight[1] + 0.0722 * straight[2]) * alpha + (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 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; @@ -32,8 +37,22 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes + signal(x - 1, y + 1) + 2.0 * signal(x, y + 1) + signal(x + 1, y + 1); - if let Some(edge) = edges.get_mut(y as usize * w + x as usize) { - *edge = gx.hypot(gy).clamp(0.0, 1.0); + 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; + } } } } @@ -41,18 +60,20 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes if width > 0.5 { blur_plane(&mut edges, w, h, width * 0.5); } - for (out, (original, edge)) in px.iter_mut().zip(src.iter().zip(edges)) { + 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 = (edge * brightness as f32 / 6.0).clamp(0.0, 1.0); - let straight = if original[3] > 0 { - [ - f32::from(original[0]) / 255.0 / alpha.max(1e-6), - f32::from(original[1]) / 255.0 / alpha.max(1e-6), - f32::from(original[2]) / 255.0 / alpha.max(1e-6), - ] - } else { - [1.0, 1.0, 1.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] }; let a = alpha.max(glow); *out = [ (straight[0] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 120e7a1c..883a5cbc 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -143,6 +143,21 @@ fn glowing_edges_glow_alike_on_every_side() { assert!(at(&out, w, 4, 4)[0] < left[0], "the inside stays darker than the edges"); } +#[test] +fn glowing_edges_defaults_keep_the_outline_bright_and_use_source_colour() { + let (w, h) = (17, 17); + let src = image(w, h, |x, y| if (4..13).contains(&x) && (4..13).contains(&y) { [180, 0, 0, 255] } else { [0; 4] }); + let mut out = src.clone(); + fx("stylize.glowingEdges", json!({})).apply(&mut out, w, h, &space(w, h)); + + let outline = at(&out, w, 4, 8); + assert!(outline[0] >= 170, "default outline should retain a near-full-color peak: {outline:?}"); + let outside = at(&out, w, 3, 8); + assert_eq!(outside[1], 0, "transparent halo must not be grey: {outside:?}"); + assert_eq!(outside[2], 0, "transparent halo must retain source hue: {outside:?}"); + assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); +} + #[test] fn spin_blur_smears_along_arcs_and_keeps_the_centre() { let (w, h) = (41, 41); From b9b880a5deff2acda1aa36415ecfa64115edb2e6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 09:46:43 +0700 Subject: [PATCH 5/5] Review fixes for #765: the glow outside a shape keeps the edge's colour The output multiplied the glow's colour by its opacity a second time, so outside the shape (where the glow sets the opacity) it darkened as it faded and showed as a grey rim over a light background. The test now checks that the fading glow keeps the edge's colour. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/pixel/edges.rs | 8 +++++--- crates/effects/src/pixel/tests.rs | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index d63b2421..25812fff 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -74,11 +74,13 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes 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 * a * 255.0).round().clamp(0.0, 255.0) as u8, - (straight[1] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, - (straight[2] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, + (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, ]; } diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 883a5cbc..92768009 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -155,6 +155,8 @@ fn glowing_edges_defaults_keep_the_outline_bright_and_use_source_colour() { let outside = at(&out, w, 3, 8); assert_eq!(outside[1], 0, "transparent halo must not be grey: {outside:?}"); assert_eq!(outside[2], 0, "transparent halo must retain source hue: {outside:?}"); + // The glow outside keeps the edge's colour as it fades, rather than darkening towards grey. + assert!(outside[3] > 0 && u32::from(outside[0]) * 255 >= 240 * u32::from(outside[3]), "{outside:?}"); assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); }