From 11adb72d44a043d3fb51d90c2f4be90597d26cf8 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Tue, 20 Oct 2026 02:52:33 +0700 Subject: [PATCH] =?UTF-8?q?G2:=20Effect=20=E2=80=BA=20Sketch=20=E2=80=BA?= =?UTF-8?q?=20Graphic=20Pen=20raster=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #2967 onto #2972 (Chalk & Charcoal): both filters in the effect catalog, PIXEL_EFFECTS and the Sketch label and filter tests. --- crates/effects/src/lib.rs | 8 +++++ crates/effects/src/pixel/brushstrokes.rs | 37 ++++++++++++++++++++++++ crates/effects/src/pixel/mod.rs | 26 +++++++++++++---- crates/effects/src/pixel/sketch.rs | 3 +- crates/effects/src/pixel/tests.rs | 35 ++++++++++++++++++++++ crates/engine/src/tests_rasterfilters.rs | 2 +- crates/engine/tests/command_sweep.rs | 1 + crates/ui-egui/src/dialogs/effect.rs | 1 + crates/ui-egui/src/tests_labels.rs | 8 +++-- docs/effects-parity.md | 2 +- docs/gaps.md | 6 ++-- 11 files changed, 113 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ddf01dc53..7ee3131ed 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -153,6 +153,7 @@ fn lengths_of(id: &str) -> Lengths { "texture.mosaicTiles" => always(&["tileSize"]), "texture.patchwork" => always(&["squareSize"]), "texture.stainedGlass" => always(&["cellSize"]), + "sketch.graphicPen" => always(&["strokeLength"]), "sketch.halftonePattern" => always(&["size"]), RASTERIZE => always(&["addAround"]), _ => Lengths::default(), @@ -474,6 +475,13 @@ pub fn effect_catalog() -> Vec { "{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.", json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}), ), + r( + "sketch.graphicPen", + "Graphic Pen…", + SKETCH, + "{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names from public helpx; numeric defaults and ranges are an unverified visual match to Illustrator.", + json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}), + ), r( "sketch.halftonePattern", "Halftone Pattern…", diff --git a/crates/effects/src/pixel/brushstrokes.rs b/crates/effects/src/pixel/brushstrokes.rs index 416a67de4..fadd458a8 100644 --- a/crates/effects/src/pixel/brushstrokes.rs +++ b/crates/effects/src/pixel/brushstrokes.rs @@ -516,3 +516,40 @@ pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, *p = premultiply(out, f32::from(p[3]) / 255.0); }); } + +const SALT_GRAPHIC: u64 = 200; + +/// Graphic Pen: the object redrawn in fine linear ink strokes along `dir`, each `len` (1..15) +/// points long on white paper; `balance` (0..100) sets the tone threshold (higher draws more +/// black ink). +pub(super) fn graphic_pen(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, balance: f64, dir: StrokeDirection) { + let Some(place) = Place::new(space, w, h) else { return }; + let len = if len.is_finite() { len.clamp(1.0, 15.0) } else { 1.0 }; + let smear = Smear::new(px, w, h, &place, len); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let threshold = 1.0 - balance.clamp(0.0, 100.0) / 100.0; + let row = (0.2 * len).max(0.75); + let along = dir.along(); + place.each(px, |x, y, p| { + let a = f32::from(p[3]) / 255.0; + if a <= 0.0 { + return; + } + let q = place.at(x, y); + let (c, stroke_a) = unpremultiply(smear.stroke(SALT_GRAPHIC, q, along, row)); + let dark = (1.0 - tones.get(x, y)).max(f64::from(1.0 - luma(c)) * f64::from(stroke_a)); + if dark <= threshold { + *p = premultiply([1.0, 1.0, 1.0], a); + return; + } + let weight = ramp((dark - threshold) / (1.0 - threshold).max(1e-3)); + let u = frame(q, along).0; + let on_stroke = { + let t = (u / len).rem_euclid(1.0); + if t < 0.65 { 1.0 } else { 0.0 } + }; + let grain = 0.08 * streak(SALT_GRAPHIC + 1, q, along, 0.35 * len, 0.5); + let ink = (weight * on_stroke + grain).clamp(0.0, 1.0); + *p = premultiply([(1.0 - ink) as f32; 3], a); + }); +} diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 186fb9457..74a272ecd 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,8 +1,8 @@ //! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, Blur › Radial Blur and Smart Blur, Brush Strokes › -//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes +//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Graphic Pen, 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, Sketch › Chalk & Charcoal and -//! Halftone Pattern, Stylize › +//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic/n//! Pen and Halftone Pattern, 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. @@ -40,7 +40,7 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern}; 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; 33] = [ +pub const PIXEL_EFFECTS: [&str; 34] = [ "artistic.cutout", "artistic.filmGrain", "artistic.neonGlow", @@ -64,6 +64,7 @@ pub const PIXEL_EFFECTS: [&str; 33] = [ "pixelate.pointillize", "sharpen.unsharpMask", "sketch.chalkCharcoal", + "sketch.graphicPen", "sketch.halftonePattern", "stylize.glowingEdges", "texture.craquelure", @@ -144,6 +145,9 @@ pub enum PixelFx { /// Sketch › Chalk & Charcoal: chalk highlights in light areas and charcoal in dark ones; /// `chalk` and `charcoal` (0..20) set how much, `pressure` (0..5) the stroke weight. ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 }, + /// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long + /// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink. + GraphicPen { length: f64, balance: f64, direction: StrokeDirection }, /// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size` /// (1..12) apart, its edges as hard as `contrast` (0..50) says. HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern }, @@ -321,6 +325,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { charcoal: num(p, "charcoalArea", 6.0).clamp(0.0, 20.0), pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0), }, + "sketch.graphicPen" => PixelFx::GraphicPen { + length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0), + balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0), + direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")), + }, "sketch.halftonePattern" => PixelFx::HalftonePattern { size: num(p, "size", 1.0).clamp(1.0, 12.0), contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0), @@ -391,7 +400,9 @@ impl PixelFx { 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::AngledStrokes { length, .. } | PixelFx::GraphicPen { 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), @@ -432,7 +443,9 @@ impl PixelFx { PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)), 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::AngledStrokes { length, .. } | PixelFx::GraphicPen { 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)) } @@ -505,6 +518,7 @@ impl PixelFx { PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude), PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure), + PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction), PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind), PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)), PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles), diff --git a/crates/effects/src/pixel/sketch.rs b/crates/effects/src/pixel/sketch.rs index 4e5183ef7..08da64fe4 100644 --- a/crates/effects/src/pixel/sketch.rs +++ b/crates/effects/src/pixel/sketch.rs @@ -1,5 +1,4 @@ -//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern). Original filters built from public -//! behaviour documentation: +//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern; Graphic Pen lives in [`super::brushstrokes`]).\n//! Original filters built from public behaviour documentation: //! (Sketch effects redraw in //! black and white) and the public Photoshop Elements Artistic filters guide. Visual //! approximations, not proprietary reproductions. diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 0286ba0ce..cd6e31239 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -2054,6 +2054,41 @@ fn period(values: &[u8]) -> usize { (1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len()) } +#[test] +fn graphic_pen_params_take_defaults_and_stay_in_range() { + use super::STROKE_DIRECTIONS; + assert_eq!( + fx("sketch.graphicPen", json!({})), + PixelFx::GraphicPen { length: 5.0, balance: 50.0, direction: StrokeDirection::RightDiagonal } + ); + assert_eq!( + fx("sketch.graphicPen", json!({"strokeLength": 0, "lightDarkBalance": 200, "strokeDirection": "VERTICAL"})), + PixelFx::GraphicPen { length: 1.0, balance: 100.0, direction: StrokeDirection::Vertical } + ); + let doc = crate::effect_info("sketch.graphicPen").unwrap().params; + assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}"); + let f = fx("sketch.graphicPen", json!({"strokeLength": 15})); + assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0)); + assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH))); + assert_eq!( + fx("sketch.graphicPen", json!({"strokeLength": 99})), + PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal } + ); +} + +/// Graphic Pen on a mid-grey field draws both black ink and white paper. +#[test] +fn graphic_pen_draws_ink_and_paper_on_grey() { + let (w, h) = (48, 48); + let src = image(w, h, |_, _| grey(32)); + let mut out = src.clone(); + fx("sketch.graphicPen", json!({"strokeLength": 4, "lightDarkBalance": 50})).apply(&mut out, w, h, &space(w, h)); + let lumas: Vec = out.as_chunks::<4>().0.iter().map(|p| p[0]).collect(); + let (min, max) = lumas.iter().fold((255u8, 0u8), |(lo, hi), l| (lo.min(*l), hi.max(*l))); + assert!(min < 80 && max > 200, "min {min} max {max}"); + assert_ne!(out, src, "the filter should change the raster"); +} + #[test] fn halftone_pattern_params_take_defaults_and_stay_in_range() { assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot }); diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index fe7be6208..b0e2195b0 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -44,7 +44,7 @@ const PIXELATE: [(&str, &str); 4] = [("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")]; /// Effect › Sketch, at their defaults. -const SKETCH: [(&str, &str); 1] = [("sketch.halftonePattern", "{}")]; +const SKETCH: [(&str, &str); 3] = [("sketch.chalkCharcoal", "{}"), ("sketch.graphicPen", "{}"), ("sketch.halftonePattern", "{}")]; /// Effect › Texture, at their defaults. const TEXTURE: [(&str, &str); 6] = [ diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 5725a038f..ab0cab022 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -334,6 +334,7 @@ fn structured_junk() { ("effect.apply", json!({"effect": "artistic.cutout", "params": {"levels": 1e308, "edgeSimplicity": "NaN", "edgeFidelity": -1e308}})), ("effect.apply", json!({"effect": "artistic.neonGlow", "params": {"glowSize": -1e308, "glowBrightness": "1e999", "color": [1]}})), ("effect.apply", json!({"effect": "artistic.posterEdges", "params": {"edgeThickness": 1e308, "edgeIntensity": [], "posterization": -7}})), + ("effect.apply", json!({"effect": "sketch.graphicPen", "params": {"strokeLength": -1e308, "lightDarkBalance": "NaN", "strokeDirection": 3}})), ("effect.apply", json!({"effect": "sketch.halftonePattern", "params": {"size": -1e308, "contrast": "NaN", "patternType": 3}})), ("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})), ("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})), diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 5539d65fd..d746dfdbe 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -283,6 +283,7 @@ fn choices(effect: &str, key: &str) -> Option { ("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), + ("sketch.graphicPen", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS), ("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES), ("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES), ("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 8bb432359..5d192f4df 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -178,12 +178,14 @@ fn effect_menu_lists_the_sketch_filters_between_sharpen_and_stylize() { let entries = menus::menu_entries(&app); let sketch: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Sketch"]).collect(); let labels: Vec<&str> = sketch.iter().map(|e| e.label.as_str()).collect(); - assert_eq!(labels, ["Chalk & Charcoal…", "Halftone Pattern…"]); + assert_eq!(labels, ["Chalk & Charcoal…", "Graphic Pen…", "Halftone Pattern…"]); assert_eq!(sketch[0].command.as_deref(), Some("effect.dialog")); assert_eq!(sketch[0].params, json!({"effect": "sketch.chalkCharcoal"})); - assert_eq!(sketch[1].params, json!({"effect": "sketch.halftonePattern"})); + assert_eq!(sketch[1].params, json!({"effect": "sketch.graphicPen"})); + assert_eq!(sketch[2].params, json!({"effect": "sketch.halftonePattern"})); let at = |label: &str| entries.iter().position(|e| e.path[0] == "Effect" && e.label == label).unwrap(); - assert!(at("Unsharp Mask…") < at("Chalk & Charcoal…") && at("Chalk & Charcoal…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Glowing Edges…")); + assert!(at("Unsharp Mask…") < at("Chalk & Charcoal…") && at("Chalk & Charcoal…") < at("Graphic Pen…")); + assert!(at("Graphic Pen…") < at("Halftone Pattern…") && at("Halftone Pattern…") < at("Glowing Edges…")); } /// Effect › Stylize under Raster Effects, between Sharpen and Texture: Glowing Edges opens its diff --git a/docs/effects-parity.md b/docs/effects-parity.md index feb324be4..a28220eab 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -44,7 +44,7 @@ its resolution, PDF export warns once per object with the requested and the lowe | Distort | Diffuse Glow, Glass, Ocean Ripple | 3/3 | — | | Pixelate | Color Halftone, Crystallize, Mezzotint, Pointillize | 4/4 | — | | Sharpen | Unsharp Mask | 1/1 | — | -| Sketch | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | 1/14 | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | +| Sketch | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | 2/14 | Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges, Water Paper | | Stylize | Glowing Edges | 1/1 | — | | Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | Texturizer's Load Texture | | Video | De-Interlace, NTSC Colors | 2/2 | — | diff --git a/docs/gaps.md b/docs/gaps.md index 531807998..938d29f0c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -223,11 +223,11 @@ 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:** 24 of 57 filters (the remaining 11 Artistic filters and 13 of Sketch's 14), the Effect Gallery dialog, Load +- **Missing:** 23 of 57 filters (the remaining 11 Artistic filters and 12 of Sketch's 14), the Effect Gallery dialog, Load Texture for Texturizer. Measured in [effects-parity.md](effects-parity.md). -- **Done:** the pixel-filter pipeline and 33 filters (Artistic: Cutout, Film Grain, Neon Glow, Poster Edges, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Sketch: - Halftone Pattern, Stylize, Texture, Video), Brush Strokes on 2026-10-11, Film Grain on 2026-10-12, Halftone Pattern on 2026-10-16, Cutout, Neon Glow and Poster Edges on 2026-10-16, and Smart Blur's Edge Only and Overlay Edge +- **Done:** the pixel-filter pipeline and 34 filters (Artistic: Cutout, Film Grain, Neon Glow, Poster Edges, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Sketch: + Graphic Pen and Halftone Pattern, Stylize, Texture, Video), Brush Strokes on 2026-10-11, Film Grain on 2026-10-12, Halftone Pattern on 2026-10-16, Graphic Pen on 2026-10-18, Cutout, Neon Glow and Poster Edges on 2026-10-16, and Smart Blur's Edge Only and Overlay Edge modes. - **Estimate:** 21–35 h (measured 0.7–1.2 h per filter); parallelizes well.