Mask feather: bound the variable feather's distance pass (#2947)

* Mask feather: bound the variable feather's distance pass

Every flattened segment scanned its box grown by the largest feather radius,
so a path with many vertices and a radius spanning the mask revisited every
pixel once per segment. The pixel visits are now counted first; past a
budget of 2^31 the flattened polyline is thinned (every other point, arc
lengths kept) until it fits, and only a degenerate case falls back to the
hard mask.

* clippy manual_is_multiple_of
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-03 12:32:20 -05:00
1 parent b7dee07cf4
commit 5464a2380e
1 file changed
+78 -1
+78 -1
View File
@@ -70,6 +70,22 @@ fn flatten(sp: &ShapePath, m: &Mat3) -> Flat {
}
impl Flat {
/// Keep every other point (and the last), with their arc lengths.
fn thin(&mut self) {
let n = self.pts.len();
let keep = |i: usize| i.is_multiple_of(2) || i + 1 == n;
let mut i = 0;
self.pts.retain(|_| {
i += 1;
keep(i - 1)
});
let mut i = 0;
self.s.retain(|_| {
i += 1;
keep(i - 1)
});
}
/// Arc length of a feather point.
fn arc_of(&self, f: &FeatherPoint) -> Option<f64> {
let table = self.seg_tables.get(f.segment)?;
@@ -123,14 +139,39 @@ impl Profile {
}
}
/// Most pixel visits (summed over the flattened path's segments) a variable feather's distance
/// pass may take: a few seconds of work, far beyond a feathered mask at 4K with a radius of a
/// few hundred pixels. Past it the flattened path is thinned (see [`variable_feather_coverage`]).
pub const FEATHER_WORK_BUDGET: u64 = 1 << 31;
/// Pixel visits of the distance pass over the polyline `pts`: each segment's box grown by
/// `reach`, clipped to the `w × h` mask.
fn feather_work(pts: &[[f64; 2]], reach: f64, w: u32, h: u32) -> u64 {
let span = |lo: f64, hi: f64, n: u32| -> u64 {
let a = (lo - reach).floor().max(0.0);
let b = (hi + reach).ceil().min(f64::from(n) - 1.0);
if b >= a { (b - a + 1.0) as u64 } else { 0 }
};
pts.windows(2)
.map(|p| {
let (a, b) = (p[0], p[1]);
span(a[0].min(b[0]), a[0].max(b[0]), w).saturating_mul(span(a[1].min(b[1]), a[1].max(b[1]), h))
})
.fold(0u64, u64::saturating_add)
}
/// Coverage of a mask path with feather points, transformed by `m` (translate + uniform scale)
/// into a `w × h` mask. `expansion` (layer pixels) grows (>0) or shrinks the mask first;
/// `linear` selects the Linear feather falloff instead of Smooth.
pub fn variable_feather_coverage(sp: &ShapePath, m: &Mat3, w: u32, h: u32, expansion: f64, linear: bool) -> Mask {
coverage_within(sp, m, (w, h), expansion, linear, FEATHER_WORK_BUDGET)
}
fn coverage_within(sp: &ShapePath, m: &Mat3, (w, h): (u32, u32), expansion: f64, linear: bool, budget: u64) -> Mask {
let scale = m.mean_scale();
let path = to_kurbo(sp);
let hard = fill_coverage(std::slice::from_ref(&path), m, w, h, FillRule::NonZero);
let flat = flatten(sp, m);
let mut flat = flatten(sp, m);
if flat.pts.len() < 2 {
return hard;
}
@@ -148,6 +189,16 @@ pub fn variable_feather_coverage(sp: &ShapePath, m: &Mat3, w: u32, h: u32, expan
let rmax = prof.pts.iter().map(|p| p.1.max(p.2)).fold(0.0, f64::max);
let exp = expansion * scale;
let reach = rmax + exp.abs() + 2.0;
// Each flattened segment visits its box grown by the reach, so many segments with a reach
// spanning the mask would revisit every pixel once per segment. Past the budget the
// polyline is thinned (every other point, keeping their arc lengths) until it fits: the
// distances move by a fraction of the chords, small next to a feather that wide.
while feather_work(&flat.pts, reach, w, h) > budget {
if flat.pts.len() <= 8 {
return hard;
}
flat.thin();
}
let (wu, hu) = (w as usize, h as usize);
let mut dist = vec![f32::INFINITY; wu * hu];
let mut arc = vec![0.0f32; wu * hu];
@@ -218,6 +269,32 @@ mod tests {
m.data[(y * m.width + x) as usize]
}
#[test]
fn feather_work_is_bounded_by_thinning_the_path() {
// Thousands of vertices with a feather reaching across the mask: the polyline is thinned
// until the distance pass fits the budget, and the result is still a feathered edge.
let n = 2000;
let verts: Vec<[f64; 2]> = (0..n)
.map(|i| {
let a = i as f64 / n as f64 * std::f64::consts::TAU;
[100.0 + 50.0 * a.cos(), 100.0 + 50.0 * a.sin()]
})
.collect();
let mut sp = ShapePath::polygon(&verts, true);
sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 40.0, tension: 0.0 }];
let budget = 1 << 22;
let full = flatten(&sp, &Mat3::IDENTITY);
assert!(feather_work(&full.pts, 42.0, 200, 200) > budget);
let m = coverage_within(&sp, &Mat3::IDENTITY, (200, 200), 0.0, true, budget);
let unbounded = coverage_within(&sp, &Mat3::IDENTITY, (200, 200), 0.0, true, u64::MAX);
// Outside the edge: 1 at the circle falling linearly to 0 at 40 px, close to the
// unthinned result.
assert!(at(&m, 100, 100) > 0.99);
let (thin, exact) = (at(&m, 100, 30), at(&unbounded, 100, 30));
assert!(thin > 0.4 && thin < 0.6 && (thin - exact).abs() < 0.05, "{thin} {exact}");
assert!(at(&m, 100, 5) < 0.01);
}
#[test]
fn no_points_is_the_hard_mask() {
let m = variable_feather_coverage(&square(), &Mat3::IDENTITY, 200, 200, 0.0, false);