effectcraft/crates/path/src/feather.rs

269 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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));
}
}