collective

Browse / github.com/storytold/photocraft / patch

Smooth and Feather: crop to the selection, up to 395× faster on small selections

Smooth and Feather: crop to the selection, up to 395× faster on small selections Smooth and Feather still read and replaced full-canvas masks, even for tiny selections. On a 6000×6000 canvas, each `f32` plane is 144 MB. The blur kernels already limited their work to the nonzero region, but mask conversion and replacement still involved the whole canvas. ## Change - Both commands use the selection window from #9697 for mask reads, algorithm input/output and tiled replacement. - Smooth grows the nonzero bounds by `ceil(radius) + 1`. Feather uses the blur's shared `selection::feather_reach(radius)` plus one pixel: `ceil(3 * radius) + 1` for accepted radii. Both windows are

Status
open
Score
0.00
Adopters
0 other operators
Author
wanikhawar (imported from pull request #9809, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+173 −60 in 5 files
Review
pending
Shared
2026-10-28
Id
89a4e270c97132b7f8a54763

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/selection_cmds.rs crates/engine/src/cmd/selection_cmds/modify_window/tests.rs docs/gaps.md docs/selection-distance-performance.md docs/target-app-parity.md

diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs--- a/crates/engine/src/cmd/selection_cmds.rs+++ b/crates/engine/src/cmd/selection_cmds.rs@@ -458,16 +458,16 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result<Value> {     if canvas.size().area() > MAX_MODIFY_PIXELS as u64 {         return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() });     }+    // Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach at most ceil(r).+    let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize };     // Keep both mask conversion and tiled replacement within the affected region. The     // zero halo makes internal window edges inert; nonzero edges can only be canvas edges,     // so the existing Apply effect at canvas bounds padding still has the same meaning.-    let area = if matches!(op, "expand" | "contract" | "border") { modify_window::area(doc, r) } else { canvas };+    let area = modify_window::area(doc, reach as f32);     let m = sel::mask_from_surface(doc.selection.as_ref(), area);     let (w, h) = (area.width() as usize, area.height() as usize);-    // Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach r. A margin past that-    // keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both-    // readings agree there.-    let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize };+    // A margin past the reach keeps the padded edge out of reach. A selection clear of the+    // canvas edge needs none: both readings agree there.     let pad = if touches_edge(&m, w, h) { reach.saturating_add(2) } else { 0 };     let grown = |n: usize| pad.checked_mul(2).and_then(|p| n.checked_add(p));     let (Some(pw), Some(ph)) = (grown(w), grown(h)) else {diff --git a/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs b/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs--- a/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs+++ b/crates/engine/src/cmd/selection_cmds/modify_window/tests.rs@@ -17,13 +17,17 @@ fn full_canvas_reference(doc: &Document, op: &str, radius: f32, at_bounds: bool)     let canvas = doc.bounds();     let m = sel::mask_from_surface(doc.selection.as_ref(), canvas);     let (w, h) = (canvas.width() as usize, canvas.height() as usize);-    let pad = if touches_edge(&m, w, h) { radius.ceil() as usize + 2 } else { 0 };+    let reach = if op == "feather" { sel::feather_reach(radius) } else { radius.ceil() as usize };+    let pad = if touches_edge(&m, w, h) { reach + 2 } else { 0 };     let (pw, ph) = (w + 2 * pad, h + 2 * pad);     let padded = if pad == 0 { m } else { pad_mask(&m, w, h, pad, op == "border" || at_bounds) };     let out = match op {         "expand" => sel::expand(&padded, pw, ph, radius),         "contract" => sel::contract(&padded, pw, ph, radius),-        _ => sel::border(&padded, pw, ph, radius),+        "border" => sel::border(&padded, pw, ph, radius),+        "smooth" => sel::smooth(&padded, pw, ph, radius),+        "feather" => sel::feather(&padded, pw, ph, radius),+        _ => panic!("unknown modify operation: {op}"),     };     let out = if pad == 0 { out } else { crop_mask(&out, pw, pad, w, h) };     sel::combine(None, &out, canvas, SelectionMode::Replace)@@ -36,9 +40,11 @@ fn sparse_36mp_command_only_materializes_the_selection_halo() {     let doc = document(6000, 6000, selection);     assert_eq!(area(&doc, 5.0), Rect::new(2994, 2994, 3056, 3056));     assert_eq!(area(&doc, 5.0).size().area(), 62 * 62);-    let mut session = Session::new();-    session.add_document(doc, None);-    for op in ["expand", "contract", "border"] {+    assert_eq!(area(&doc, sel::feather_reach(5.0) as f32), Rect::new(2984, 2984, 3066, 3066));+    assert_eq!(area(&doc, sel::feather_reach(5.0) as f32).size().area(), 82 * 82);+    for op in ["expand", "contract", "border", "smooth", "feather"] {+        let mut session = Session::new();+        session.add_document(doc.clone(), None);         session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap();         assert!(session.active().unwrap().doc.selection.is_some());     }@@ -71,8 +77,11 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() {             selection.write_pixel(-10, -10, &[1.0]);         }         let doc = document(75, 61, selection);-        for radius in [1.0, 5.0, 9.5] {-            for op in ["expand", "contract", "border"] {+        for radius in [0.1, 1.0, 1.5, 5.0, 9.5, 25.0] {+            for op in ["expand", "contract", "border", "smooth", "feather"] {+                if radius < 1.0 && op != "feather" {+                    continue;+                }                 for at_bounds in [false, true] {                     let expected = full_canvas_reference(&doc, op, radius, at_bounds);                     let mut session = Session::new();@@ -91,21 +100,79 @@ fn cropped_commands_match_full_canvas_reads_padding_and_writeback() {     } } +#[test]+fn cropped_blurs_preserve_soft_masks_at_every_depth() {+    for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {+        let format = PixelFormat { sample: depth, ..PixelFormat::GRAY8 };+        for default in [0.0, 0.25] {+            let mut selection = Surface::with_default(format, &[default]);+            for y in 21..39 {+                for x in 28..47 {+                    selection.write_pixel(x, y, &[((x * 13 + y * 7) % 17) as f32 / 16.0]);+                }+            }+            let mut doc = Document::new("soft modify", Size::new(83, 67), ColorMode::Rgb, depth);+            doc.selection = Some(selection);+            for op in ["smooth", "feather"] {+                for radius in [1.0, 2.5, 9.5] {+                    for at_bounds in [false, true] {+                        let expected = full_canvas_reference(&doc, op, radius, at_bounds);+                        let mut session = Session::new();+                        session.add_document(doc.clone(), None);+                        session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap();+                        assert_eq!(+                            sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()),+                            sel::mask_from_surface(expected.as_ref(), doc.bounds()),+                            "{depth:?} default {default} {op} radius {radius} bounds {at_bounds}"+                        );+                    }+                }+            }+        }+    }+}++#[test]+fn cropped_blurs_preserve_single_pixel_and_thin_canvases() {+    for (w, h) in [(1, 1), (1, 19), (23, 1)] {+        let mut selection = Surface::new(PixelFormat::GRAY8);+        selection.write_pixel(w as i32 / 2, h as i32 / 2, &[1.0]);+        let doc = document(w, h, selection);+        for op in ["smooth", "feather"] {+            for radius in [1.0, 9.5] {+                for at_bounds in [false, true] {+                    let expected = full_canvas_reference(&doc, op, radius, at_bounds);+                    let mut session = Session::new();+                    session.add_document(doc.clone(), None);+                    session.execute(&format!("select.modify.{op}"), json!({"radius": radius, "applyAtCanvasBounds": at_bounds})).unwrap();+                    assert_eq!(+                        sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()),+                        sel::mask_from_surface(expected.as_ref(), doc.bounds()),+                        "{w}x{h} {op} radius {radius} bounds {at_bounds}"+                    );+                }+            }+        }+    }+}+ #[test] fn cropped_replacement_keeps_undo_and_redo() {     let canvas = Rect::new(0, 0, 75, 61);     let selection = sel::mask_to_surface(&vec![1.0; 10 * 10], Rect::new(30, 25, 40, 35));     let doc = document(75, 61, selection);     let before = sel::mask_from_surface(doc.selection.as_ref(), canvas);-    let mut session = Session::new();-    session.add_document(doc, None);-    session.execute("select.modify.expand", json!({"radius": 5})).unwrap();-    let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas);-    assert_ne!(before, after);-    session.execute("edit.undo", json!({})).unwrap();-    assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before);-    session.execute("edit.redo", json!({})).unwrap();-    assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after);+    for op in ["expand", "smooth", "feather"] {+        let mut session = Session::new();+        session.add_document(doc.clone(), None);+        session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap();+        let after = sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas);+        assert_ne!(before, after, "{op}");+        session.execute("edit.undo", json!({})).unwrap();+        assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), before, "{op}");+        session.execute("edit.redo", json!({})).unwrap();+        assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), canvas), after, "{op}");+    } }  /// Isolate command-window savings: both paths use the optimized algorithm kernels, while@@ -114,43 +181,46 @@ fn cropped_replacement_keeps_undo_and_redo() { #[ignore = "36 MP release benchmark; run on GitHub Actions"] fn sparse_modify_release_comparison() {     use std::time::Instant;-    let selection = sel::mask_to_surface(&vec![1.0; 50 * 50], Rect::new(3000, 3000, 3050, 3050));-    let doc = document(6000, 6000, selection);-    for radius in [3.0, 50.0] {-        for op in ["expand", "contract", "border"] {-            let reference = full_canvas_reference(&doc, op, radius, false);-            let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds());-            let mut old_times = Vec::new();-            let mut new_times = Vec::new();-            for round in 0..3 {-                for old in if round % 2 == 0 { [true, false] } else { [false, true] } {-                    let mut session = Session::new();-                    session.add_document(doc.clone(), None);-                    let now = Instant::now();-                    if old {-                        let result = full_canvas_reference(&doc, op, radius, false);-                        session-                            .edit("Modify selection", |doc, _| {-                                doc.selection = result;-                                Ok(())-                            })-                            .unwrap();-                    } else {-                        session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap();-                    }-                    let elapsed = now.elapsed().as_secs_f64() * 1000.0;-                    if old {-                        old_times.push(elapsed)-                    } else {-                        new_times.push(elapsed)+    for (kind, bounds) in [("sparse", Rect::new(3000, 3000, 3050, 3050)), ("dense", Rect::new(1000, 1000, 5000, 5000)), ("full", Rect::new(0, 0, 6000, 6000))] {+        let selection = sel::mask_to_surface(&vec![1.0; bounds.size().area() as usize], bounds);+        let doc = document(6000, 6000, selection);+        let ops: &[&str] = if kind == "sparse" { &["expand", "contract", "border", "smooth", "feather"] } else { &["smooth", "feather"] };+        for radius in [3.0, 50.0] {+            for &op in ops {+                let reference = full_canvas_reference(&doc, op, radius, false);+                let expected = sel::mask_from_surface(reference.as_ref(), doc.bounds());+                let mut old_times = Vec::new();+                let mut new_times = Vec::new();+                for round in 0..3 {+                    for old in if round % 2 == 0 { [true, false] } else { [false, true] } {+                        let mut session = Session::new();+                        session.add_document(doc.clone(), None);+                        let now = Instant::now();+                        if old {+                            let result = full_canvas_reference(&doc, op, radius, false);+                            session+                                .edit("Modify selection", |doc, _| {+                                    doc.selection = result;+                                    Ok(())+                                })+                                .unwrap();+                        } else {+                            session.execute(&format!("select.modify.{op}"), json!({"radius": radius})).unwrap();+                        }+                        let elapsed = now.elapsed().as_secs_f64() * 1000.0;+                        if old {+                            old_times.push(elapsed)+                        } else {+                            new_times.push(elapsed)+                        }+                        assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected);                     }-                    assert_eq!(sel::mask_from_surface(session.active().unwrap().doc.selection.as_ref(), doc.bounds()), expected);                 }+                old_times.sort_by(f64::total_cmp);+                new_times.sort_by(f64::total_cmp);+                let (old, new) = (old_times[1], new_times[1]);+                println!("36 MP {kind} command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new);             }-            old_times.sort_by(f64::total_cmp);-            new_times.sort_by(f64::total_cmp);-            let (old, new) = (old_times[1], new_times[1]);-            println!("36 MP command {op} r={radius}: full_canvas={old:.3}ms cropped={new:.3}ms speedup={:.2}x exact=true", old / new);         }     } }@@ -165,7 +235,7 @@ fn corrupt_tile_coordinates_do_not_panic_while_finding_the_window() {     let doc = document(75, 61, selection);     // Without the guard, content_bounds overflows while sorting these coordinates.     assert_eq!(area(&doc, 5.0), doc.bounds());-    for op in ["expand", "contract", "border"] {+    for op in ["expand", "contract", "border", "smooth", "feather"] {         let mut session = Session::new();         session.add_document(doc.clone(), None);         session.execute(&format!("select.modify.{op}"), json!({"radius": 5})).unwrap();diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 178 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; G9: 12 of 178 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026  Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that@@ -294,6 +294,12 @@ what an agent can't finish alone. - **Estimate:** 150–250 h.  ### G2. Interactive performance+- **Progress (2026-10-27):** Smooth and Feather also crop command mask reads and+  replacement to the nonzero selection bounds plus their blur reach and a zero margin.+  A 50×50 selection at radius 5 uses 62×62 and 82×82 samples respectively on a 36 MP+  canvas. Full-canvas reference tests and remote release benchmarks cover both commands;+  GUI budgets and readiness percentages are unchanged.+  [Method and limits](selection-distance-performance.md#cropped-smooth-and-feather-commands-2026-10-27). - **Measured progress (2026-10-26):** Expand, Contract and Border restrict distance work to   the nonzero bounds plus a radius halo. Small radii use capped distances; Contract avoids   inverted float masks and Border retains one distance plane. On 36 MP, eight synthetic@@ -395,6 +401,7 @@ what an agent can't finish alone.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-25 | minor | G9: the count of preferences that do nothing is the scorecard's again, 12 of 178 (it still said 24 of 171, from 2026-10-15); no estimate changed | | 2026-10-25 | minor | G6: Art History Brush landed as partial (TOOL-213-20, #1610), so no toolbar tool is missing; no percentage or estimate changed |diff --git a/docs/selection-distance-performance.md b/docs/selection-distance-performance.md--- a/docs/selection-distance-performance.md+++ b/docs/selection-distance-performance.md@@ -145,8 +145,8 @@ selections clear normally. The existing oversized-canvas safety limit remains.  Replacement already accepts a rectangle and builds a fresh sparse selection surface, so it writes only affected tiles and retains the existing undoable transaction. The-shared `current_mask` helper and Grow/Similar are unchanged. Smooth and Feather keep-their existing command paths.+shared `current_mask` helper and Grow/Similar are unchanged. Smooth and Feather also+use command windows as described below.  Command tests compare against full-canvas reads, padding and replacement for empty/full selections, both canvas-bounds settings, edge-touching masks, holes, fractional coverage,@@ -176,3 +176,32 @@ Border also skips distance planes and coverage passes when its binary mask is un there is no edge inside the supplied rectangle. Extreme library radii retain the finite no-seed sentinel behavior; the command's canvas-edge padding still creates a real edge for Select All.+++## Cropped Smooth and Feather commands (2026-10-27)++All five Select › Modify commands now use the same bounded mask read and replacement.+Smooth grows the nonzero selection bounds by `ceil(radius) + 1`. Feather takes the+truncated Gaussian reach from `selection::feather_reach(radius)` and adds the same+one-pixel zero margin: `ceil(3 * radius) + 1` for the accepted command radii. Using the+blur's shared reach keeps command windows and canvas-edge padding in sync with the+kernel. Both windows are clipped to the canvas before any mask allocation.++For a 50×50 selection at radius 5 on a 6000×6000 canvas, the command supplies 62×62+samples to Smooth and 82×82 to Feather, instead of 36 million samples. Nonzero implicit+defaults and malformed tile coordinates retain the full-canvas fallback. The existing+canvas-size safety limit still applies.++The command comparisons above now include Smooth and Feather, fractional radii down+to Feather's 0.1 px minimum, both canvas-bound settings, soft coverage at 8/16/32-bit+sample depths, one-pixel/thin canvases, and undo/redo. They compare stored selection+coverage with the previous full-canvas path.++Dispatch the fork's CI with `selection_morphology=true` to run the release comparison+and the general quick performance suite. The existing+`sparse_modify_release_comparison` test now measures both blurs on sparse 50×50,+dense 4000×4000 and fully selected masks in a 36 MP canvas, at radii 3 and 50. It+alternates execution order for three rounds, reports median command times and verifies+every output sample outside timing. The full-canvas reference uses the same optimized+blur kernels and includes the undoable edit. GUI rendering and live-preview scheduling+are excluded; these measurements do not update the P26/P27 scorecard budgets.diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md--- a/docs/target-app-parity.md+++ b/docs/target-app-parity.md@@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (cropped selection morphology and command mask conversion performance evidence added; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-27 · **Change:** minor (Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026  The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);@@ -363,6 +363,12 @@ i7-9750H, 12 workers, 24 MP RGBA8, Good quality, amount 1: Spin 66.10 → 16.43 (4.02×), Zoom 19.19 → 5.35 s (3.59×), one paired run each, exact output equality. These are local measurements at amount 1; see [method and limits](radial-blur-performance.md). +2026-10-27: Smooth and Feather command reads and replacement also use selection bounds+plus blur reach and a zero margin. A 50×50 selection at radius 5 supplies 62×62 and+82×82 samples respectively on a 36 MP canvas. Full-canvas reference comparisons and+remote release benchmarks cover both commands; GUI budgets and percentages are unchanged.+See [method and limits](selection-distance-performance.md#cropped-smooth-and-feather-commands-2026-10-27).+ 2026-10-26: cropped Expand, Contract and Border measure 1.54–37.82× faster across eight 36 MP synthetic mask cases on GitHub Actions, with exact output equality. Radii up to 5 px use capped distances; Contract avoids inverted float masks and Border retains one distance@@ -580,6 +586,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged | | 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged |