refactor(selection): take the feather reach from the blur (#9802)

The feather's ⌈3σ⌉ truncation radius was written out in the blur and again in two engine callers: the windowed marquee and lasso feather (#9794) and the canvas-edge padding of Select › Modify › Feather. selection::feather_reach returns it from the blur's own σ rules, and both callers use it, so a change to the blur's reach can't leave them behind. Output is unchanged; a test pins that the feather leaves everything past its reach at zero.
This commit is contained in:
aiongg authored and GitHub committed 2026-10-27 01:07:05 -04:00
1 parent 805ed134e4
commit 7ac7b8b106
4 files changed
+47 -13

No files matched your search

+7
View File
@@ -806,6 +806,13 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
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
+13
View File
@@ -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}");
}
}
+22 -9
View File
@@ -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<f32> {
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<f32> {
} 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<f32> {
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();
};
+5 -4
View File
@@ -464,11 +464,11 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
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 {