M13.5: stroke taper/wave/multi-dashes, radial gradient highlight, variable-width mask feather, adaptive motion-blur samples

Shapes (SHP-2): Stroke Taper (pixels/percent start/end length, width, ease) and
Wave (amount, pixels/cycles, wavelength, phase) rendered as a variable-width
outline; Dash 2/Gap 2/Dash 3/Gap 3 (shape.dashes.add/remove, timeline +/-
buttons); radial gradient Highlight Length/Angle as a focal gradient.
Masks (MSK-1): mask feather points on the Mask Path (mask.featherPoint.*,
Mask Feather tool add/drag), per-point signed radius and tension, rendered
from the signed distance to the path; carried through vertex edits.
Motion blur (LYR-10): Adaptive Sample Limit comp setting; 2D layers take one
sample per pixel of shutter travel between Samples Per Frame and the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 23:22:53 -04:00
co-authored by Claude Opus 5.5
parent c406b71cba
commit bbaee1fa0f
33 changed files with 1915 additions and 50 deletions
+268
View File
@@ -0,0 +1,268 @@
//! 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));
}
}