From 2d371849df6ecb0dbacfda5a5dfbf335421acb3b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 01:24:10 +1100 Subject: [PATCH] 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);