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
@@ -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 {@@ -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();@@ -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 |@@ -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.@@ -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 |