269 lines
11 KiB
Rust
269 lines
11 KiB
Rust
//! Variable-width mask feather (After Effects' Mask Feather tool).
|
||
//!
|
||
//! Feather points sit on the mask path with a signed radius (outer > 0, inner < 0) and a
|
||
//! tension. Between points the outer and inner radii are interpolated along the path's arc
|
||
//! length (smoothly at tension 0, linearly at tension 1; closed paths wrap around). Every pixel
|
||
//! near the path takes the feather width at its nearest path position: outside the path the
|
||
//! opacity falls from 1 at the edge to 0 at the outer radius, inside it rises from 0 at the
|
||
//! edge to 1 at the inner radius. Where no radius applies the edge stays hard (anti-aliased).
|
||
|
||
use effectcraft_geom::{Mat3, vec2};
|
||
use effectcraft_keyframe::{FeatherPoint, ShapePath};
|
||
use effectcraft_raster::Mask;
|
||
|
||
use crate::{FillRule, fill_coverage, to_kurbo};
|
||
|
||
/// A flattened path in buffer space: points, their arc lengths, and per segment the
|
||
/// `(parameter, arc length)` table used to place feather points.
|
||
struct Flat {
|
||
pts: Vec<[f64; 2]>,
|
||
s: Vec<f64>,
|
||
seg_tables: Vec<Vec<(f64, f64)>>,
|
||
len: f64,
|
||
}
|
||
|
||
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
|
||
let u = 1.0 - t;
|
||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||
[a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]]
|
||
}
|
||
|
||
fn flatten(sp: &ShapePath, m: &Mat3) -> Flat {
|
||
let n = sp.vertices.len();
|
||
let tr = |p: [f64; 2]| {
|
||
let r = m.apply(vec2(p[0], p[1]));
|
||
[r.x, r.y]
|
||
};
|
||
let mut pts: Vec<[f64; 2]> = Vec::new();
|
||
let mut s: Vec<f64> = Vec::new();
|
||
let mut seg_tables = Vec::new();
|
||
let segs = sp.segment_count();
|
||
let mut acc = 0.0;
|
||
for i in 0..segs {
|
||
let j = (i + 1) % n;
|
||
let v0 = sp.vertices[i];
|
||
let v1 = sp.vertices[j];
|
||
let o = sp.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
|
||
let it = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
|
||
let (p0, p1, p2, p3) = (tr(v0), tr([v0[0] + o[0], v0[1] + o[1]]), tr([v1[0] + it[0], v1[1] + it[1]]), tr(v1));
|
||
let est = ((p1[0] - p0[0]).hypot(p1[1] - p0[1]) + (p2[0] - p1[0]).hypot(p2[1] - p1[1]) + (p3[0] - p2[0]).hypot(p3[1] - p2[1])).max(1.0);
|
||
let k = ((est / 1.5).ceil() as usize).clamp(4, 1024);
|
||
let mut table = Vec::with_capacity(k + 1);
|
||
if pts.is_empty() {
|
||
pts.push(p0);
|
||
s.push(0.0);
|
||
}
|
||
table.push((0.0, acc));
|
||
let mut prev = p0;
|
||
for q in 1..=k {
|
||
let t = q as f64 / k as f64;
|
||
let p = cubic(p0, p1, p2, p3, t);
|
||
acc += (p[0] - prev[0]).hypot(p[1] - prev[1]);
|
||
pts.push(p);
|
||
s.push(acc);
|
||
table.push((t, acc));
|
||
prev = p;
|
||
}
|
||
seg_tables.push(table);
|
||
}
|
||
Flat { pts, s, seg_tables, len: acc }
|
||
}
|
||
|
||
impl Flat {
|
||
/// Arc length of a feather point.
|
||
fn arc_of(&self, f: &FeatherPoint) -> Option<f64> {
|
||
let table = self.seg_tables.get(f.segment)?;
|
||
let t = f.t.clamp(0.0, 1.0);
|
||
let i = table.partition_point(|e| e.0 <= t).clamp(1, table.len() - 1);
|
||
let (a, b) = (table[i - 1], table[i]);
|
||
let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
|
||
Some(a.1 + (b.1 - a.1) * u)
|
||
}
|
||
}
|
||
|
||
/// Outer / inner feather radius (buffer pixels) along the path, from the feather points.
|
||
struct Profile {
|
||
/// (arc length, outer radius, inner radius, tension), sorted by arc length.
|
||
pts: Vec<(f64, f64, f64, f64)>,
|
||
len: f64,
|
||
closed: bool,
|
||
}
|
||
|
||
impl Profile {
|
||
fn at(&self, u: f64) -> (f64, f64) {
|
||
let p = &self.pts;
|
||
if p.is_empty() {
|
||
return (0.0, 0.0);
|
||
}
|
||
if p.len() == 1 {
|
||
return (p[0].1, p[0].2);
|
||
}
|
||
let blend = |a: &(f64, f64, f64, f64), b: &(f64, f64, f64, f64), x: f64| {
|
||
let x = x.clamp(0.0, 1.0);
|
||
let smooth = x * x * (3.0 - 2.0 * x);
|
||
let tension = ((a.3 + b.3) * 0.5).clamp(0.0, 1.0);
|
||
let w = smooth + (x - smooth) * tension;
|
||
(a.1 + (b.1 - a.1) * w, a.2 + (b.2 - a.2) * w)
|
||
};
|
||
let first = p[0];
|
||
let last = p[p.len() - 1];
|
||
if u < first.0 || u >= last.0 {
|
||
if !self.closed {
|
||
return if u < first.0 { (first.1, first.2) } else { (last.1, last.2) };
|
||
}
|
||
// Wrap from the last point to the first through the path's start.
|
||
let span = (self.len - last.0) + first.0;
|
||
let x = if u >= last.0 { u - last.0 } else { u + self.len - last.0 };
|
||
return blend(&last, &first, if span > 1e-9 { x / span } else { 0.0 });
|
||
}
|
||
let i = p.partition_point(|e| e.0 <= u).clamp(1, p.len() - 1);
|
||
let (a, b) = (&p[i - 1], &p[i]);
|
||
let span = b.0 - a.0;
|
||
blend(a, b, if span > 1e-9 { (u - a.0) / span } else { 0.0 })
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
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);
|
||
if flat.pts.len() < 2 {
|
||
return hard;
|
||
}
|
||
let mut pts: Vec<(f64, f64, f64, f64)> = sp
|
||
.feather
|
||
.iter()
|
||
.filter_map(|f| {
|
||
let u = flat.arc_of(f)?;
|
||
let r = f.radius * scale;
|
||
Some((u, r.max(0.0), (-r).max(0.0), f.tension))
|
||
})
|
||
.collect();
|
||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
let prof = Profile { pts, len: flat.len, closed: sp.closed };
|
||
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;
|
||
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];
|
||
for k in 0..flat.pts.len() - 1 {
|
||
let (a, b) = (flat.pts[k], flat.pts[k + 1]);
|
||
let (sa, sb) = (flat.s[k], flat.s[k + 1]);
|
||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||
let l2 = dx * dx + dy * dy;
|
||
let x0 = ((a[0].min(b[0]) - reach).floor().max(0.0)) as usize;
|
||
let x1 = ((a[0].max(b[0]) + reach).ceil().min(w as f64 - 1.0)).max(-1.0) as isize;
|
||
let y0 = ((a[1].min(b[1]) - reach).floor().max(0.0)) as usize;
|
||
let y1 = ((a[1].max(b[1]) + reach).ceil().min(h as f64 - 1.0)).max(-1.0) as isize;
|
||
if x1 < 0 || y1 < 0 {
|
||
continue;
|
||
}
|
||
for y in y0..=y1 as usize {
|
||
for x in x0..=x1 as usize {
|
||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||
let t = if l2 > 0.0 { (((px - a[0]) * dx + (py - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||
let (qx, qy) = (a[0] + dx * t - px, a[1] + dy * t - py);
|
||
let d = (qx * qx + qy * qy).sqrt() as f32;
|
||
let i = y * wu + x;
|
||
if d < dist[i] {
|
||
dist[i] = d;
|
||
arc[i] = (sa + (sb - sa) * t) as f32;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let ramp = |x: f64| {
|
||
let x = x.clamp(0.0, 1.0);
|
||
if linear { x } else { x * x * (3.0 - 2.0 * x) }
|
||
};
|
||
let mut out = Mask::new(w, h, 0.0);
|
||
for (i, o) in out.data.iter_mut().enumerate() {
|
||
let inside = hard.data[i] >= 0.5;
|
||
let d = dist[i] as f64;
|
||
if !d.is_finite() || d > reach {
|
||
// Farther from the path than any feather or expansion reaches.
|
||
*o = if inside { 1.0 } else { 0.0 };
|
||
continue;
|
||
}
|
||
// Signed distance to the expanded edge (negative inside).
|
||
let sd = if inside { -d } else { d } - exp;
|
||
let (ro, ri) = prof.at(arc[i] as f64);
|
||
let a = if sd > 0.0 {
|
||
if ro > 0.5 { ramp(1.0 - sd / ro) } else { (0.5 - sd).clamp(0.0, 1.0) }
|
||
} else if ri > 0.5 {
|
||
ramp(-sd / ri)
|
||
} else {
|
||
(0.5 - sd).clamp(0.0, 1.0)
|
||
};
|
||
*o = a as f32;
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn square() -> ShapePath {
|
||
// 100×100 square from (50,50) to (150,150), clockwise: top, right, bottom, left segments.
|
||
ShapePath::rect([100.0, 100.0], 100.0, 100.0)
|
||
}
|
||
|
||
fn at(m: &Mask, x: u32, y: u32) -> f32 {
|
||
m.data[(y * m.width + x) as usize]
|
||
}
|
||
|
||
#[test]
|
||
fn no_points_is_the_hard_mask() {
|
||
let m = variable_feather_coverage(&square(), &Mat3::IDENTITY, 200, 200, 0.0, false);
|
||
assert!(at(&m, 100, 100) > 0.99 && at(&m, 40, 100) < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn single_outer_point_feathers_all_around() {
|
||
let mut sp = square();
|
||
sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 0.0 }];
|
||
let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true);
|
||
// Above the top edge: 1 at the edge falling linearly to 0 at 20 px.
|
||
assert!((at(&m, 100, 39) - 0.475).abs() < 0.06, "{}", at(&m, 100, 39));
|
||
assert!(at(&m, 100, 28) < 0.01);
|
||
assert!(at(&m, 100, 60) > 0.99, "inside stays opaque");
|
||
// One point applies to the whole closed path.
|
||
assert!((at(&m, 161, 100) - 0.475).abs() < 0.06, "{}", at(&m, 161, 100));
|
||
}
|
||
|
||
#[test]
|
||
fn outer_and_inner_points_vary_along_the_path() {
|
||
let mut sp = square();
|
||
// Outer 20 px in the middle of the top edge, inner 20 px in the middle of the bottom edge.
|
||
sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 20.0, tension: 1.0 }, FeatherPoint { segment: 2, t: 0.5, radius: -20.0, tension: 1.0 }];
|
||
let m = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 0.0, true);
|
||
// Top: soft outside, hard inside.
|
||
assert!(at(&m, 100, 45) > 0.6 && at(&m, 100, 45) < 0.8, "{}", at(&m, 100, 45));
|
||
assert!(at(&m, 100, 52) > 0.99);
|
||
// Bottom: hard outside, soft inside (0 at the edge, 1 at 20 px in).
|
||
assert!(at(&m, 100, 152) < 0.01);
|
||
assert!((at(&m, 100, 140) - 0.475).abs() < 0.06, "{}", at(&m, 100, 140));
|
||
// Half way round (middle of the right edge): both radii are half way, 10 px.
|
||
assert!((at(&m, 154, 100) - 0.55).abs() < 0.05, "{}", at(&m, 154, 100));
|
||
assert!((at(&m, 145, 100) - 0.45).abs() < 0.05, "{}", at(&m, 145, 100));
|
||
}
|
||
|
||
#[test]
|
||
fn scale_and_expansion() {
|
||
let mut sp = square();
|
||
sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 10.0, tension: 0.0 }];
|
||
// Half resolution: the 10 px feather is 5 buffer pixels.
|
||
let m = variable_feather_coverage(&sp, &Mat3::scale(vec2(0.5, 0.5)), 100, 100, 0.0, true);
|
||
assert!(at(&m, 50, 22) > 0.4 && at(&m, 50, 22) < 0.6, "{}", at(&m, 50, 22));
|
||
let e = variable_feather_coverage(&sp, &Mat3::IDENTITY, 200, 200, 10.0, true);
|
||
assert!(at(&e, 100, 41) > 0.99, "expanded by 10 px: {}", at(&e, 100, 41));
|
||
assert!((at(&e, 100, 34) - 0.45).abs() < 0.05, "{}", at(&e, 100, 34));
|
||
}
|
||
}
|