diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index bb7a521114..d6e1837dd1 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -806,6 +806,13 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { crate::selection_blur::feather(m, w, h, radius) } +/// How far [`feather`] spreads, in pixels (`⌈3σ⌉` with σ = `radius`, 0 when it changes nothing). +/// Samples farther than this from every nonzero sample stay 0, so a working window grown by this +/// much gives the same result as the whole canvas. +pub fn feather_reach(radius: f32) -> usize { + crate::selection_blur::feather_reach(radius) +} + /// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal /// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of /// rows are filled in parallel, and each sub-row tests only the edges that diff --git a/crates/algo/src/selection/tests.rs b/crates/algo/src/selection/tests.rs index 30ae9b23a2..226a4c5a68 100644 --- a/crates/algo/src/selection/tests.rs +++ b/crates/algo/src/selection/tests.rs @@ -674,3 +674,16 @@ fn polygon_window_keeps_full_canvas_fractional_coverage() { } } } + +#[test] +fn feather_leaves_everything_beyond_its_reach_at_zero() { + for radius in [0.05, 0.5, 2.5, 7.0, 40.0] { + let reach = feather_reach(radius); + let w = 3 * reach + 20; + let mut m = vec![0.0; w]; + m[0] = 1.0; + let out = feather(&m, w, 1, radius); + assert!(out[0] > 0.0, "radius {radius}"); + assert!(out[reach + 1..].iter().all(|v| *v == 0.0), "radius {radius}: nonzero past reach {reach}"); + } +} diff --git a/crates/algo/src/selection_blur.rs b/crates/algo/src/selection_blur.rs index 4c64250a68..1262e9b77f 100644 --- a/crates/algo/src/selection_blur.rs +++ b/crates/algo/src/selection_blur.rs @@ -542,13 +542,9 @@ impl CosKernel { } } -/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius`, the kernel truncated -/// at `⌈3σ⌉` and normalized, zero beyond the canvas. Photoshop's feather radius is the Gaussian's -/// σ (measured on Photoshop 27.11; the same mapping the mask feather uses, fitted on the -/// psd-tools corpus in `photocraft_compose::masks::feather_sigma`). It was `radius / 2`: half -/// as wide as Photoshop's. -pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { - let n = w.saturating_mul(h); +/// The feather's Gaussian σ for `radius`, capped at [`MAX_SIGMA`]. Below 0.1 the feather leaves +/// the mask as it is. +fn feather_sigma(radius: f32) -> f32 { let sigma = if radius.is_finite() { radius.max(0.0) } else if radius > 0.0 { @@ -556,11 +552,28 @@ pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { } else { 0.0 }; + sigma.min(MAX_SIGMA) +} + +/// How far [`feather`] spreads, in pixels: its kernel's truncation radius `⌈3σ⌉`, or 0 when it +/// leaves the mask as it is. Samples farther than this from every nonzero sample stay 0. +pub(crate) fn feather_reach(radius: f32) -> usize { + let sigma = feather_sigma(radius); + if sigma < 0.1 { 0 } else { (sigma * 3.0).ceil() as usize } +} + +/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius`, the kernel truncated +/// at `⌈3σ⌉` and normalized, zero beyond the canvas. Photoshop's feather radius is the Gaussian's +/// σ (measured on Photoshop 27.11; the same mapping the mask feather uses, fitted on the +/// psd-tools corpus in `photocraft_compose::masks::feather_sigma`). It was `radius / 2`: half +/// as wide as Photoshop's. +pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { + let n = w.saturating_mul(h); + let sigma = feather_sigma(radius); if sigma < 0.1 || m.len() != n || n == 0 { return m.to_vec(); } - let sigma = sigma.min(MAX_SIGMA); - let r = (sigma * 3.0).ceil() as usize; + let r = feather_reach(radius); let Some(b) = nonzero_bounds(m, w, h) else { return m.to_vec(); }; diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index 9cf48f3545..362e889fa9 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -464,11 +464,11 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result { let area = if matches!(op, "expand" | "contract" | "border") { modify_window::area(doc, r) } else { canvas }; 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σ = 3 r (σ = r, as in Photoshop); the others reach r. A margin past that + // 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" { r * 3.0 } else { r }; - let pad = if touches_edge(&m, w, h) { reach.ceil() as usize + 2 } else { 0 }; + let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize }; + 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 { return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() }); @@ -675,7 +675,8 @@ pub(crate) fn feather_window(bounds: Rect, canvas: Rect, feather: f32) -> Rect { if bounds.is_empty() { return Rect::EMPTY; } - bounds.inflate(((feather * 3.0).ceil() as i32).saturating_add(1)).intersect(&canvas) + let reach = i32::try_from(sel::feather_reach(feather)).unwrap_or(i32::MAX); + bounds.inflate(reach.saturating_add(1)).intersect(&canvas) } macro_rules! spec {