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
parent c406b71cba
commit bbaee1fa0f
33 changed files with 1915 additions and 50 deletions

View File

@ -87,6 +87,9 @@ fn apply_settings(c: &mut Comp, p: &Value) {
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = (n as u32).clamp(2, 64);
}
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
}
}
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
@ -249,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Composition...",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}",
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}",
always,
new_comp
),
@ -258,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composition Settings...",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}",
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}",
has_comp,
settings
),

View File

@ -86,7 +86,7 @@ fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
let mut outs = pts(p.get("outTangents"));
ins.resize(n, [0.0; 2]);
outs.resize(n, [0.0; 2]);
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let uid = s.edit("New Mask", None, |proj, st| {
let mut next = proj.next_id;
@ -114,6 +114,7 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
sp.vertices.insert(i, point);
sp.in_tangents.insert(i, tin);
sp.out_tangents.insert(i, tout);
sp.feather_after_insert(i, None);
Ok(i)
})?;
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
@ -241,6 +242,7 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
sp.vertices.remove(i);
sp.in_tangents.remove(i);
sp.out_tangents.remove(i);
sp.feather_after_remove(i);
}
}
if sp.vertices.len() < 3 {
@ -324,9 +326,199 @@ pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option<us
sp.vertices.insert(at, m);
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
sp.feather_after_insert(at, Some(t));
Some(at)
}
/// Nearest (segment, t, distance) of a path to a point (layer space), sampled per segment.
pub(crate) fn nearest_on_path(sp: &ShapePath, q: [f64; 2]) -> Option<(usize, f64, f64)> {
let n = sp.vertices.len();
let mut best: Option<(usize, f64, f64)> = None;
for seg in 0..sp.segment_count() {
let j = (seg + 1) % n;
let p0 = sp.vertices[seg];
let p3 = sp.vertices[j];
let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]);
let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]);
let at = |t: f64| {
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]]
};
let dist = |t: f64| {
let p = at(t);
(p[0] - q[0]).hypot(p[1] - q[1])
};
let (mut bt, mut bd) = (0.0, f64::INFINITY);
for k in 0..=64 {
let t = k as f64 / 64.0;
let d = dist(t);
if d < bd {
(bt, bd) = (t, d);
}
}
// Refine by bisection-style narrowing.
let mut step = 1.0 / 64.0;
for _ in 0..20 {
step *= 0.5;
for t in [bt - step, bt + step] {
let t = t.clamp(0.0, 1.0);
let d = dist(t);
if d < bd {
(bt, bd) = (t, d);
}
}
}
if best.is_none_or(|b| bd < b.2) {
best = Some((seg, bt, bd));
}
}
best
}
/// Whether a layer-space point is inside a closed path.
fn inside_path(sp: &ShapePath, q: [f64; 2]) -> bool {
sp.closed && {
let cov = effectcraft_path::fill_coverage(
&[effectcraft_path::to_kurbo(sp)],
&effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-q[0] + 0.5, -q[1] + 0.5)),
1,
1,
effectcraft_path::FillRule::NonZero,
);
cov.data[0] >= 0.5
}
}
/// Point of segment `seg` at parameter `t`.
fn point_on(sp: &ShapePath, seg: usize, t: f64) -> Option<[f64; 2]> {
let n = sp.vertices.len();
if seg >= sp.segment_count() {
return None;
}
let j = (seg + 1) % n;
let (p0, p3) = (sp.vertices[seg], sp.vertices[j]);
let o = sp.out_tangents.get(seg).copied().unwrap_or([0.0; 2]);
let i = sp.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
let (p1, p2) = ([p0[0] + o[0], p0[1] + o[1]], [p3[0] + i[0], p3[1] + i[1]]);
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);
Some([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 feather_index(p: &Value, sp: &ShapePath, c: &str) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (feather point, 0-based)"))? as usize;
if i >= sp.feather.len() {
return Err(bad(c, format!("no feather point {i} (the mask has {})", sp.feather.len())));
}
Ok(i)
}
/// Mask Feather tool: add a feather point on the path, by `segment`/`t` or at the path position
/// nearest `point` (layer space). Without `radius`, a `point` off the path sets it from its
/// distance (outside the mask = outer feather, inside = inner).
fn feather_add(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.add";
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0));
let q = pt(p.get("point"));
let radius = super::f_p(p, "radius");
let tension = super::f_p(p, "tension").unwrap_or(0.0).clamp(0.0, 100.0) / 100.0;
let (uid, i) = edit_path(s, p, c, |sp| {
let (seg, t, r) = match (seg, q) {
(Some(seg), _) => {
if seg >= sp.segment_count() {
return Err(bad(c, format!("no segment {seg}")));
}
(seg, t.unwrap_or(0.5), radius.unwrap_or(0.0))
}
(None, Some(q)) => {
let (seg, t, d) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?;
(seg, t, radius.unwrap_or(if inside_path(sp, q) { -d } else { d }))
}
(None, None) => return Err(bad(c, "give `segment` (and `t`) or `point`")),
};
sp.feather.push(effectcraft_keyframe::FeatherPoint { segment: seg, t, radius: r, tension });
Ok(sp.feather.len() - 1)
})?;
Ok(json!({"mask": uid, "index": i}))
}
/// Drag a feather point: move it along the path (`segment`/`t`, or nearest `point`) and/or set
/// its `radius` (signed: negative = inner) and `tension` (%).
fn feather_set(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.set";
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let t = super::f_p(p, "t").map(|v| v.clamp(0.0, 1.0));
let q = pt(p.get("point"));
let radius = super::f_p(p, "radius");
let tension = super::f_p(p, "tension").map(|v| v.clamp(0.0, 100.0) / 100.0);
let toward = pt(p.get("toward"));
let (_, out) = edit_path(s, p, c, |sp| {
let i = feather_index(p, sp, c)?;
if let Some(q) = q {
let (sg, tt, _) = nearest_on_path(sp, q).ok_or_else(|| bad(c, "the path has no segments"))?;
sp.feather[i].segment = sg;
sp.feather[i].t = tt;
}
if let Some(sg) = seg {
if sg >= sp.segment_count() {
return Err(bad(c, format!("no segment {sg}")));
}
sp.feather[i].segment = sg;
}
if let Some(t) = t {
sp.feather[i].t = t;
}
if let Some(r) = radius {
sp.feather[i].radius = r;
}
// Dragging the radius handle: the distance from the point's path position to `toward`,
// outer outside the mask, inner inside.
if let Some(w) = toward {
let f = sp.feather[i];
let at = point_on(sp, f.segment, f.t).ok_or_else(|| bad(c, "the feather point is off the path"))?;
let d = (w[0] - at[0]).hypot(w[1] - at[1]);
sp.feather[i].radius = if inside_path(sp, w) { -d } else { d };
}
if let Some(k) = tension {
sp.feather[i].tension = k;
}
Ok(serde_json::to_value(sp.feather[i]).unwrap_or(Value::Null))
})?;
Ok(out)
}
fn feather_remove(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.remove";
let all = super::b_p(p, "all").unwrap_or(false);
let (_, n) = edit_path(s, p, c, |sp| {
if all {
sp.feather.clear();
} else {
let i = feather_index(p, sp, c)?;
sp.feather.remove(i);
}
Ok(sp.feather.len())
})?;
Ok(json!({"remaining": n}))
}
fn feather_list(s: &mut Session, p: &Value) -> Result<Value> {
let c = "mask.featherPoint.list";
let (cid, lid) = layer_p(s, p, c)?;
let key = p.get("mask").cloned().ok_or_else(|| bad(c, "missing `mask`"))?;
let t = s.time();
let mut props = s.project.comp(cid).and_then(|cc| cc.layer(lid)).ok_or(EngineError::NoComp)?.props.clone();
let lt = s.project.comp(cid).and_then(|cc| cc.layer(lid)).map(|l| l.layer_time(t)).unwrap_or(t);
let g = path_group(&mut props, &key).ok_or_else(|| bad(c, "no such mask"))?;
let Some(KV::Path(sp)) = g.get("path").map(|pr| pr.value_at(lt)) else { return Err(bad(c, "mask has no path")) };
Ok(
json!({"points": sp.feather.iter().map(|f| json!({"segment": f.segment, "t": f.t, "radius": f.radius, "tension": f.tension * 100.0})).collect::<Vec<_>>()}),
)
}
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
let seg =
@ -370,6 +562,26 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex),
cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex),
cmd!(
"mask.featherPoint.add",
"Add Mask Feather Point",
[],
None,
"{layer?, mask, segment?, t?: 0..1, point?: [x,y] (layer space), radius? (px, negative = inner), tension? (%)} → {index}",
has_comp,
feather_add
),
cmd!(
"mask.featherPoint.set",
"Move Mask Feather Point",
[],
None,
"{layer?, mask, index, segment?, t?, point? (slide along the path), radius?, toward?: [x,y] (radius from the drag point, layer space), tension? (%), merge?}",
has_comp,
feather_set
),
cmd!("mask.featherPoint.remove", "Delete Mask Feather Point", [], None, "{layer?, mask, index? | all?}", has_comp, feather_remove),
crate::query!("mask.featherPoint.list", "Mask Feather Points", "{layer?, mask}", feather_list),
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
]

View File

@ -33,6 +33,7 @@ mod query;
mod render_queue;
pub mod roto_cmds;
mod settings;
mod shape_stroke;
mod stubs;
mod styles;
mod text_anim;
@ -127,6 +128,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(animation::specs());
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
v.extend(key_transform::specs());
v.extend(file_more::specs());
v.extend(lottie::specs());

View File

@ -0,0 +1,221 @@
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
//! commands here set several of them in one undo step and add the groups to strokes saved before
//! they existed.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
fn is_stroke(g: &PropGroup) -> bool {
matches!(g.match_id.as_str(), "stroke" | "gstroke")
}
/// `{layer?, prop?: stroke uid}` → the stroke; without `prop`, the first stroke of the layer (a
/// selected stroke property wins).
fn stroke_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
if let Some(uid) = p.get("prop").and_then(Value::as_u64) {
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
if !is_stroke(g) {
return Err(bad(cmd, format!("`{}` is not a Stroke or Gradient Stroke", g.name)));
}
return Ok((cid, lid, uid));
}
let contents = l.props.sub("contents").ok_or_else(|| bad(cmd, "not a shape layer"))?;
// A selected stroke (or a property inside one), else the first stroke in the contents.
let mut found = None;
for (sl, su) in &s.state.selected_props {
if *sl == lid
&& let Some(g) = find_stroke_containing(contents, *su)
{
found = Some(g);
break;
}
}
if found.is_none() {
let mut first = None;
walk_groups(contents, &mut |g| {
if first.is_none() && is_stroke(g) {
first = Some(g.uid);
}
});
found = first;
}
found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, "the layer has no stroke"))
}
fn walk_groups(g: &PropGroup, f: &mut impl FnMut(&PropGroup)) {
for sub in g.groups() {
f(sub);
walk_groups(sub, f);
}
}
fn find_stroke_containing(contents: &PropGroup, uid: Uid) -> Option<Uid> {
let mut out = None;
walk_groups(contents, &mut |g| {
if out.is_none() && is_stroke(g) && (g.uid == uid || g.find(uid).is_some() || g.find_group(uid).is_some()) {
out = Some(g.uid);
}
});
out
}
fn dashes_add(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.dashes.add";
let (cid, lid, uid) = stroke_ref(s, p, c)?;
s.edit("Add Dash", None, |proj, _| {
let mut next = proj.next_id;
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
// Before the first "+", Dash is 0 (no dashes): the first "+" turns it on.
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
let mut ids = Ids(&mut next);
let added = if dash <= 0.0 {
if let Some(p) = d.get_mut("dash") {
p.value = KV::Scalar(10.0);
}
"dash"
} else {
let Some((dm, gm)) = EXTRA_DASHES.iter().find(|(dm, _)| d.get(dm).is_none()) else {
return Err(bad(c, "a stroke has at most three dash/gap pairs"));
};
let k = if *dm == "dash2" { 2 } else { 3 };
// Insert before Offset, which stays last.
let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap_or(d.children.len());
let dv = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(10.0);
let gv = d.get("gap").map(|p| p.value.as_f64()).filter(|g| *g > 0.0).unwrap_or(dv);
d.children.insert(at, ids.prop(gm, &format!("Gap {k}"), KV::Scalar(gv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
d.children.insert(at, ids.prop(dm, &format!("Dash {k}"), KV::Scalar(dv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
dm
};
proj.next_id = next;
Ok(json!({"added": added}))
})
}
fn dashes_remove(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.dashes.remove";
let (cid, lid, uid) = stroke_ref(s, p, c)?;
s.edit("Remove Dash", None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
for (dm, gm) in EXTRA_DASHES.iter().rev() {
if d.get(dm).is_some() {
d.children.retain(|n| n.match_id() != *dm && n.match_id() != *gm);
return Ok(json!({"removed": dm}));
}
}
// The last "−" turns dashes off.
if let Some(p) = d.get_mut("dash") {
p.value = KV::Scalar(0.0);
p.keys.clear();
}
Ok(json!({"removed": "dash"}))
})
}
/// Set `fields` (`param name → (match id, kind)`) of the stroke's `group`, creating the group.
fn set_group(s: &mut Session, p: &Value, c: &str, group: &str, fields: &[(&str, &str, bool)]) -> Result<Value> {
let (cid, lid, uid) = stroke_ref(s, p, c)?;
let t = s.time();
let label = if group == "taper" { "Stroke Taper" } else { "Stroke Wave" };
s.edit(label, merge_p(p), |proj, _| {
let mut next = proj.next_id;
{
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
if g.sub(group).is_none() {
let mut ids = Ids(&mut next);
let ng = if group == "taper" { build::stroke_taper(&mut ids) } else { build::stroke_wave(&mut ids) };
g.children.push(ng.into());
}
}
proj.next_id = next;
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let g = l.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let sub = g.sub_mut(group).ok_or_else(|| bad(c, "group vanished"))?;
let mut out = serde_json::Map::new();
for (param, m, is_enum) in fields {
let Some(v) = p.get(*param) else { continue };
let pr = sub.get_mut(m).ok_or_else(|| bad(c, format!("no {m}")))?;
let nv = if *is_enum {
let opts: &[&str] = if *m == "units" && group == "taper" { &["pixels", "percent"] } else { &["pixels", "cycles"] };
let i = match v {
Value::String(s) => {
opts.iter().position(|o| o.eq_ignore_ascii_case(s)).ok_or_else(|| bad(c, format!("`{param}` is one of {opts:?}")))? as u32
}
Value::Number(n) => n.as_u64().unwrap_or(0).min(opts.len() as u64 - 1) as u32,
_ => return Err(bad(c, format!("bad `{param}`"))),
};
KV::Enum(i)
} else {
let x = v.as_f64().ok_or_else(|| bad(c, format!("`{param}` must be a number")))?;
let x = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { x.clamp(min, max) } else { x.max(0.0) };
KV::Scalar(x)
};
pr.set_value_at(lt, nv.clone());
out.insert(param.to_string(), nv.to_json());
}
Ok(json!({"stroke": uid, group: Value::Object(out)}))
})
}
fn taper(s: &mut Session, p: &Value) -> Result<Value> {
set_group(
s,
p,
"shape.stroke.taper",
"taper",
&[
("units", "units", true),
("startLength", "startLength", false),
("endLength", "endLength", false),
("startWidth", "startWidth", false),
("endWidth", "endWidth", false),
("startEase", "startEase", false),
("endEase", "endEase", false),
],
)
}
fn wave(s: &mut Session, p: &Value) -> Result<Value> {
set_group(
s,
p,
"shape.stroke.wave",
"wave",
&[("amount", "amount", false), ("units", "units", true), ("wavelength", "wavelength", false), ("cycles", "cycles", false), ("phase", "phase", false)],
)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
cmd!(
"shape.stroke.taper",
"Stroke Taper",
[],
None,
"{layer?, prop?: stroke uid, units?: pixels|percent, startLength?, endLength?, startWidth? (%), endWidth? (%), startEase? (%), endEase? (%)}",
has_layers,
taper
),
cmd!(
"shape.stroke.wave",
"Stroke Wave",
[],
None,
"{layer?, prop?: stroke uid, amount? (%), units?: pixels|cycles, wavelength?, cycles?, phase? (°)}",
has_layers,
wave
),
]
}

View File

@ -224,7 +224,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
*t = [r.x, r.y];
}
}
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));

View File

@ -634,6 +634,8 @@ mod tests_stubs;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_tail;
#[cfg(test)]
mod tests_text;
#[cfg(test)]
mod tests_text_edit;

View File

@ -105,6 +105,7 @@ pub fn transform_path(p: &ShapePath, h: &trk::Homography) -> ShapePath {
in_tangents: p.vertices.iter().zip(&p.in_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
out_tangents: p.vertices.iter().zip(&p.out_tangents).map(|(v, t)| h.apply_tangent(*v, *t)).collect(),
closed: p.closed,
feather: p.feather.clone(),
}
}

View File

@ -0,0 +1,221 @@
//! M13.5 long-tail parity: stroke taper/wave/dashes, and the other small gaps (commands, undo,
//! serde).
use serde_json::{Value, json};
use crate::Session;
use effectcraft_project::{LayerId, Project};
pub(crate) fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Tail", "width": w, "height": h, "frameRate": 30, "duration": 2})).unwrap();
s
}
pub(crate) fn roundtrip(s: &Session) -> Project {
Project::from_json(&s.project.to_json()).unwrap()
}
/// A horizontal open path 160 px long across a 200×100 comp, 10 px red stroke and no fill.
fn line_layer(s: &mut Session) -> (u64, u64) {
let r = s.execute("shape.newPath", json!({"vertices": [[20, 50], [180, 50]], "fill": false, "stroke": [1, 0, 0], "strokeWidth": 10})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
let stroke = l.props.find_group(r["group"].as_u64().unwrap()).unwrap().sub("contents").unwrap().sub("stroke").unwrap().uid;
(lid, stroke)
}
fn column(s: &Session, x: u32) -> f32 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
(0..img.height).map(|y| img.data[(y * img.width + x) as usize][3]).sum()
}
#[test]
fn stroke_taper_wave_commands_render_undo_and_serde() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
assert!((column(&s, 30) - 10.0).abs() < 0.6);
let r = s.execute("shape.stroke.taper", json!({"layer": lid, "startLength": 40, "startWidth": 0})).unwrap();
assert_eq!(r["stroke"], json!(stroke));
assert!((column(&s, 30) - 2.5).abs() < 0.8, "tapered start: {}", column(&s, 30));
// Percent units.
s.execute("shape.stroke.taper", json!({"layer": lid, "units": "percent", "startLength": 25})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8, "25 % of 160 px = 40 px: {}", column(&s, 30));
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!((column(&s, 30) - 10.0).abs() < 0.6, "undo restores the plain stroke");
s.execute("edit.redo", json!({})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8);
// Wave in cycles.
s.execute("shape.stroke.wave", json!({"layer": lid, "amount": 100, "units": "cycles", "cycles": 4})).unwrap();
assert!(column(&s, 120) < 0.6, "trough at 100 px (2.5 cycles): {}", column(&s, 120));
// Values are ordinary animatable properties that survive save/load.
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
let g = l.props.find_group(stroke).unwrap();
assert_eq!(g.prop("wave/cycles").unwrap().value.as_f64(), 4.0);
assert_eq!(g.prop("taper/startLength").unwrap().value.as_f64(), 40.0);
assert!(s.execute("shape.stroke.wave", json!({"layer": lid, "units": "furlongs"})).is_err());
}
#[test]
fn dashes_plus_minus_buttons() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
let add = |s: &mut Session| s.execute("shape.dashes.add", json!({"layer": lid, "prop": stroke})).unwrap()["added"].as_str().unwrap().to_string();
assert_eq!(add(&mut s), "dash");
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash"), "value": 20})).unwrap();
assert_eq!(add(&mut s), "dash2");
assert_eq!(add(&mut s), "dash3");
assert!(s.execute("shape.dashes.add", json!({"layer": lid})).is_err(), "three pairs at most");
let names = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
l.props.find_group(stroke).unwrap().sub("dashes").unwrap().children.iter().map(|n| n.match_id().to_string()).collect::<Vec<_>>()
};
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "dash3", "gap3", "offset"]);
// Dash 20, Gap 20 (= dash), Dash 2 20, Gap 2 20…: set Dash 2 to 5.
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap"), "value": 10})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash2"), "value": 5})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap2"), "value": 15})).unwrap();
s.execute("shape.dashes.remove", json!({"layer": lid})).unwrap();
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "offset"]);
// Pattern 20 on, 10 off, 5 on, 15 off from x = 20 (butt caps).
assert!(column(&s, 30) > 9.0 && column(&s, 45) < 0.1 && column(&s, 52) > 9.0 && column(&s, 60) < 0.1 && column(&s, 80) > 9.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(names(&s).len(), 7);
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
assert_eq!(l.props.find_group(stroke).unwrap().prop("dashes/dash2").unwrap().value.as_f64(), 5.0);
}
fn dash_uid(s: &Session, lid: u64, stroke: u64, m: &str) -> Value {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
json!(l.props.find_group(stroke).unwrap().sub("dashes").unwrap().get(m).unwrap().uid)
}
/// A white 200×200 solid in a 200×200 comp with a 100×100 rectangular mask (50…150).
fn masked_solid(s: &mut Session) -> (u64, u64) {
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("mask.new", json!({"layer": l, "vertices": [[50, 50], [150, 50], [150, 150], [50, 150]], "closed": true})).unwrap();
(l, r["mask"].as_u64().unwrap())
}
fn render_at(s: &Session, t: f64) -> effectcraft_raster::Image {
let cid = s.active_comp_id().unwrap();
s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), Default::default())
}
fn alpha_at(s: &Session, x: u32, y: u32, t: f64) -> f32 {
let img = render_at(s, t);
img.data[(y * img.width + x) as usize][3]
}
#[test]
fn variable_mask_feather_points() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap();
assert!(alpha_at(&s, 100, 45, 0.0) < 0.01);
// Mask Feather tool: click on the path and drag out above the top edge → an outer point of 20 px.
let r = s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 30]})).unwrap();
assert_eq!(r["index"], json!(0));
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
let p0 = &pts["points"][0];
assert_eq!(p0["segment"], json!(0));
assert!((p0["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && (p0["radius"].as_f64().unwrap() - 20.0).abs() < 1e-3, "{pts}");
let soft = alpha_at(&s, 100, 39, 0.0);
assert!((soft - 0.475).abs() < 0.06, "{soft}");
// An inner point dragged inside the bottom edge.
s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 130], "tension": 100})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert!((pts["points"][1]["radius"].as_f64().unwrap() + 20.0).abs() < 1e-3, "{pts}");
assert!((alpha_at(&s, 100, 140, 0.0) - 0.475).abs() < 0.08, "{}", alpha_at(&s, 100, 140, 0.0));
assert!(alpha_at(&s, 100, 152, 0.0) < 0.01);
// Drag the radius; undo; redo.
s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 40})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) > 0.7);
s.execute("edit.undo", json!({})).unwrap();
assert!((alpha_at(&s, 100, 39, 0.0) - soft).abs() < 0.08);
s.execute("edit.redo", json!({})).unwrap();
// Inserting a vertex keeps the points where they were on the path.
let before = alpha_at(&s, 100, 39, 0.0);
s.execute("mask.insertVertex", json!({"layer": l, "mask": m, "segment": 0, "t": 0.25})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert_eq!(pts["points"][0]["segment"], json!(1));
assert!((pts["points"][0]["t"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-6);
assert!((alpha_at(&s, 100, 39, 0.0) - before).abs() < 0.02);
// Serde: the points travel in the Mask Path value.
let back = roundtrip(&s);
let lay = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(l)).unwrap();
let effectcraft_keyframe::Value::Path(sp) = &lay.props.find_group(m).unwrap().get("path").unwrap().value else { panic!() };
assert_eq!(sp.feather.len(), 2);
s.execute("mask.featherPoint.remove", json!({"layer": l, "mask": m, "all": true})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) < 0.01);
assert!(s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 1})).is_err());
}
#[test]
fn mask_motion_blur_ramps_across_the_motion() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
// 30 px in 30 frames: 1 px per frame; with a 360° shutter at frame 15 the right edge sweeps
// 165…166, so pixel 165 is half covered.
let verts: Vec<Value> = (0..4).map(|i| json!({"layer": l, "mask": m, "index": i})).collect();
s.execute("mask.moveVertices", json!({"vertices": verts, "delta": [30, 0]})).unwrap();
let a = alpha_at(&s, 165, 100, 0.5);
assert!(a > 0.3 && a < 0.7, "{a}");
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap();
let hard = alpha_at(&s, 165, 100, 0.5);
assert!(!(0.3..0.7).contains(&hard), "{hard}");
}
#[test]
fn adaptive_sample_limit_drives_layer_samples() {
assert_eq!(effectcraft_render::adaptive_samples(0.5, 16, 128), 16);
assert_eq!(effectcraft_render::adaptive_samples(100.0, 16, 128), 100);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 128), 128);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 9999), 256);
let mut s = comp(400, 100);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
// 100 px per frame (keys 3 frames apart, 300 px).
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [50, 50]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.1, "value": [350, 50]})).unwrap();
let max_step = |s: &Session| {
let img = render_at(s, 1.0 / 30.0);
let row: Vec<f32> = (0..img.width).map(|x| img.data[(50 * img.width + x) as usize][3]).collect();
row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max)
};
s.execute("comp.settings", json!({"adaptiveSampleLimit": 16})).unwrap();
let coarse = max_step(&s);
s.execute("comp.settings", json!({"adaptiveSampleLimit": 256})).unwrap();
let fine = max_step(&s);
assert!(coarse > 0.05, "16 samples over 100 px leave visible steps: {coarse}");
assert!(fine < 0.03, "≈100 samples give a smooth ramp: {fine}");
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 256);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 16);
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 16);
// Older projects without the field read the After Effects default.
fn strip(v: &mut Value) {
match v {
Value::Object(o) => {
o.remove("motion_blur_adaptive_limit");
o.values_mut().for_each(strip);
}
Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
let mut v: Value = serde_json::from_str(&s.project.to_json()).unwrap();
strip(&mut v);
let json = v.to_string();
assert!(!json.contains("motion_blur_adaptive_limit"));
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
}

View File

@ -292,6 +292,7 @@ fn split_segment_keeps_curve_shape() {
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
closed: false,
feather: Vec::new(),
};
let seg = |sp: &ShapePath, i: usize, t: f64| {
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);

View File

@ -469,7 +469,13 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
v.resize(n, [0.0; 2]);
v
};
Value::Path(effectcraft_keyframe::ShapePath { vertices: points.clone(), in_tangents: fit(ins), out_tangents: fit(outs), closed: *closed })
Value::Path(effectcraft_keyframe::ShapePath {
vertices: points.clone(),
in_tangents: fit(ins),
out_tangents: fit(outs),
closed: *closed,
feather: Vec::new(),
})
}
_ => return Err("Error: expression result must be a path".into()),
},

View File

@ -14,7 +14,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, Kerning, ParaStyle, StyleRun};
pub use value::{Gradient, Justify, ShapePath, TextDoc, Value};
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]

View File

@ -10,15 +10,79 @@ pub struct ShapePath {
pub in_tangents: Vec<[f64; 2]>,
pub out_tangents: Vec<[f64; 2]>,
pub closed: bool,
/// Variable-width mask feather points (Mask Feather tool). They travel with the path, so a
/// Mask Path keyframe carries them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub feather: Vec<FeatherPoint>,
}
/// A mask feather point: a position on the path (segment `segment`, from vertex `segment` to the
/// next, at Bezier parameter `t`), a signed feather `radius` in pixels (positive = outer
/// feather, negative = inner) and a `tension` 0–1 (0: the radius blends smoothly to the
/// neighbouring points, 1: linearly).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct FeatherPoint {
pub segment: usize,
pub t: f64,
pub radius: f64,
#[serde(default)]
pub tension: f64,
}
impl ShapePath {
pub fn polygon(points: &[[f64; 2]], closed: bool) -> ShapePath {
ShapePath { vertices: points.to_vec(), in_tangents: vec![[0.0; 2]; points.len()], out_tangents: vec![[0.0; 2]; points.len()], closed }
ShapePath {
vertices: points.to_vec(),
in_tangents: vec![[0.0; 2]; points.len()],
out_tangents: vec![[0.0; 2]; points.len()],
closed,
feather: Vec::new(),
}
}
pub fn len(&self) -> usize {
self.vertices.len()
}
/// Number of segments (vertex to next vertex, the closing one included).
pub fn segment_count(&self) -> usize {
let n = self.vertices.len();
if self.closed { n } else { n.saturating_sub(1) }
}
/// Keep feather points in place after a vertex was inserted at `i` (segments from `i` on
/// shift by one; a split of segment `i - 1` at parameter `split` re-parameterises its points).
pub fn feather_after_insert(&mut self, i: usize, split: Option<f64>) {
for f in &mut self.feather {
if i > 0 && f.segment == i - 1 {
if let Some(t) = split.filter(|t| *t > 0.0 && *t < 1.0) {
if f.t < t {
f.t /= t;
} else {
f.segment += 1;
f.t = (f.t - t) / (1.0 - t);
}
}
} else if f.segment >= i {
f.segment += 1;
}
}
}
/// Keep feather points after vertex `i` was removed: its two segments merge.
pub fn feather_after_remove(&mut self, i: usize) {
let segs = self.segment_count();
self.feather.retain_mut(|f| {
if i == 0 && f.segment == 0 && !self.closed {
return false;
}
if i > 0 && f.segment == i - 1 {
f.t *= 0.5;
} else if f.segment == i && i > 0 {
f.segment = i - 1;
f.t = 0.5 + f.t * 0.5;
} else if f.segment > i || (i == 0 && f.segment > 0) {
f.segment -= 1;
}
f.segment < segs.max(1)
});
}
pub fn is_empty(&self) -> bool {
self.vertices.is_empty()
}
@ -36,6 +100,7 @@ impl ShapePath {
in_tangents: vec![[-rx * K, 0.0], [0.0, -ry * K], [rx * K, 0.0], [0.0, ry * K]],
out_tangents: vec![[rx * K, 0.0], [0.0, ry * K], [-rx * K, 0.0], [0.0, -ry * K]],
closed: true,
feather: Vec::new(),
}
}
fn lerp(&self, o: &ShapePath, t: f64) -> ShapePath {
@ -48,6 +113,29 @@ impl ShapePath {
in_tangents: l(&self.in_tangents, &o.in_tangents),
out_tangents: l(&self.out_tangents, &o.out_tangents),
closed: self.closed,
// Feather points interpolate when both keys have the same points.
feather: if self.feather.len() == o.feather.len() {
self.feather
.iter()
.zip(&o.feather)
.map(|(a, b)| FeatherPoint {
segment: if t < 1.0 { a.segment } else { b.segment },
t: if a.segment == b.segment {
a.t + (b.t - a.t) * t
} else if t < 1.0 {
a.t
} else {
b.t
},
radius: a.radius + (b.radius - a.radius) * t,
tension: a.tension + (b.tension - a.tension) * t,
})
.collect()
} else if t < 1.0 {
self.feather.clone()
} else {
o.feather.clone()
},
}
}
}

View File

@ -49,6 +49,7 @@ pub(crate) fn to_path(j: &Json) -> Option<Value> {
out_tangents: fit(pts("o")),
vertices,
closed: o.get("c").and_then(Json::as_bool).unwrap_or(false),
feather: Vec::new(),
}))
}
@ -137,11 +138,17 @@ fn stroke_fields(o: &mut Out) {
o.raw("lc", json!(o.e("cap") + 1));
o.raw("lj", json!(o.e("join") + 1));
o.raw("ml", json!(o.f("miter")));
let used = |g: Option<&PropGroup>, m: &str| g.and_then(|g| g.get(m)).is_some_and(|p| p.is_animated() || p.value.as_f64().abs() > 1e-9);
let (taper, wave) = (o.g.sub("taper"), o.g.sub("wave"));
if used(taper, "startLength") || used(taper, "endLength") || used(wave, "amount") {
let msg = format!("{}: stroke Taper and Wave have no Lottie equivalent (uniform width exported)", o.label);
o.ex.warn(msg);
}
if let Some(d) = o.g.sub("dashes") {
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
if dash > 0.0 || d.any_animated() {
let mut list = vec![];
for (n, m) in [("d", "dash"), ("g", "gap"), ("o", "offset")] {
for (n, m) in [("d", "dash"), ("g", "gap"), ("d", "dash2"), ("g", "gap2"), ("d", "dash3"), ("g", "gap3"), ("o", "offset")] {
if let Some(p) = d.get(m) {
let v = export_prop(o.ex, p, &anim::num, &format!("{} ▸ Dashes ▸ {}", o.label, p.name));
list.push(json!({"n": n, "nm": p.name, "v": v}));

View File

@ -280,6 +280,7 @@ fn shape_contents_roundtrip() {
in_tangents: vec![[0.0, 0.0], [-10.0, 0.0], [0.0, 0.0]],
out_tangents: vec![[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]],
closed: true,
feather: Vec::new(),
},
);
let grad = Gradient {

268
crates/path/src/feather.rs Normal file
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));
}
}

View File

@ -191,7 +191,7 @@ fn from_cubics(cs: &[[P; 4]], closed: bool) -> ShapePath {
v[j] = c[3];
}
}
ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed }
ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed, feather: Vec::new() }
}
/// Exactly the same outline with `total` vertices (≥ the current count), the extra vertices
@ -272,7 +272,13 @@ fn rotate(p: &ShapePath, k: usize) -> ShapePath {
return p.clone();
}
let r = |v: &Vec<P>| (0..n).map(|i| v[(i + k) % n]).collect();
ShapePath { vertices: r(&p.vertices), in_tangents: r(&p.in_tangents), out_tangents: r(&p.out_tangents), closed: p.closed }
ShapePath {
vertices: r(&p.vertices),
in_tangents: r(&p.in_tangents),
out_tangents: r(&p.out_tangents),
closed: p.closed,
feather: p.feather.iter().map(|f| effectcraft_keyframe::FeatherPoint { segment: (f.segment + n - k % n) % n, ..*f }).collect(),
}
}
/// The same path traversed the other way (closed paths keep vertex 0 first).
@ -287,6 +293,8 @@ pub fn reversed(p: &ShapePath) -> ShapePath {
in_tangents: idx.iter().map(|i| p.out_tangents[*i]).collect(),
out_tangents: idx.iter().map(|i| p.in_tangents[*i]).collect(),
closed: p.closed,
// Feather points sit on segments that reversal renumbers: they are not carried.
feather: Vec::new(),
}
}
@ -447,6 +455,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap
in_tangents: lin(&a.in_tangents, &b.in_tangents),
out_tangents: lin(&a.out_tangents, &b.out_tangents),
closed: a.closed,
feather: a.feather.clone(),
};
}
// Procrustes: b ≈ s R (a − ca) + cb.
@ -468,7 +477,7 @@ pub fn interpolate(a: &ShapePath, b: &ShapePath, u: f64, o: &InterpOpts) -> Shap
let (th_u, s_u) = (theta * u, s.max(1e-9).powf(u));
let c_u = lerp(ca, cb, u);
let w = (o.bending_resistance / 100.0).clamp(0.0, 1.0);
let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed };
let mut out = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: a.closed, feather: a.feather.clone() };
for i in 0..n {
// Rigid path: a's vertex carried by the interpolated similarity, plus the residual
// (b − fully transformed a) blended in linearly.

View File

@ -6,10 +6,12 @@
//! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`].
pub mod boolean;
pub mod feather;
mod fit;
pub mod interp;
pub mod offset;
pub mod ops;
pub mod varstroke;
use effectcraft_geom::Mat3;
use effectcraft_keyframe::ShapePath;
@ -73,7 +75,13 @@ pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
match *el {
PathEl::MoveTo(p) => {
flush(&mut cur, &mut out);
cur = Some(ShapePath { vertices: vec![[p.x, p.y]], in_tangents: vec![[0.0; 2]], out_tangents: vec![[0.0; 2]], closed: false });
cur = Some(ShapePath {
vertices: vec![[p.x, p.y]],
in_tangents: vec![[0.0; 2]],
out_tangents: vec![[0.0; 2]],
closed: false,
feather: Vec::new(),
});
last = p;
}
PathEl::LineTo(p) => {
@ -251,11 +259,22 @@ pub struct StrokeStyle {
pub miter: f64,
/// Dash pattern (dash, gap, …) and offset.
pub dash: Option<(Vec<f64>, f64)>,
/// Stroke Taper (variable width at the ends).
pub taper: Option<varstroke::Taper>,
/// Stroke Wave (periodic width).
pub wave: Option<varstroke::Wave>,
}
impl StrokeStyle {
/// Whether the width varies along the path (Taper or Wave active).
pub fn is_variable(&self) -> bool {
self.taper.as_ref().is_some_and(|t| t.is_active()) || self.wave.as_ref().is_some_and(|w| w.is_active())
}
}
impl Default for StrokeStyle {
fn default() -> Self {
StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None }
StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None, taper: None, wave: None }
}
}
@ -264,6 +283,9 @@ pub fn stroke_outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) ->
if style.width <= 0.0 {
return None;
}
if style.is_variable() {
return varstroke::outline(paths, style, res_scale);
}
let mut all = BezPath::new();
for p in paths {
all.extend(p.elements().iter().copied());

View File

@ -0,0 +1,399 @@
//! Variable-width strokes: the shape Stroke's **Taper** (start/end length, width and ease) and
//! **Wave** (amount, wavelength or cycles, phase), with dashes cut along the same arc length.
//!
//! The stroke is built as a union of positively-wound primitives: one trapezoid per flattened
//! segment (its two ends at the local widths), a disc at every bend (round joins) and the caps.
//! Filled with the non-zero rule the union is exact, whatever the width profile does.
use kurbo::{BezPath, PathEl, Point, Shape, Vec2};
use crate::{Cap, StrokeStyle};
/// Stroke Taper. Lengths are pixels, or fractions of each path's length when `percent`; widths and
/// eases are fractions (0–1). A width of 0 tapers to a point.
#[derive(Clone, Debug, PartialEq)]
pub struct Taper {
pub percent: bool,
pub start_len: f64,
pub end_len: f64,
pub start_width: f64,
pub end_width: f64,
pub start_ease: f64,
pub end_ease: f64,
}
impl Taper {
pub fn is_active(&self) -> bool {
(self.start_len > 0.0 && self.start_width < 1.0) || (self.end_len > 0.0 && self.end_width < 1.0)
}
}
/// Stroke Wave: the width oscillates between `1 - amount` and 1 of the stroke width.
#[derive(Clone, Debug, PartialEq)]
pub struct Wave {
/// 0–1.
pub amount: f64,
/// Units: `Some(cycles)` = cycles along each path, else `wavelength` in pixels.
pub cycles: Option<f64>,
pub wavelength: f64,
/// Degrees.
pub phase: f64,
}
impl Wave {
pub fn is_active(&self) -> bool {
self.amount > 0.0 && self.cycles.map_or(self.wavelength > 0.0, |c| c > 0.0)
}
}
/// Ease profile from the tip (`u = 0`) to full width (`u = 1`): linear at ease 0, a quarter
/// circle (bulging outwards) at ease 1.
fn ease(u: f64, e: f64) -> f64 {
let u = u.clamp(0.0, 1.0);
let round = (1.0 - (1.0 - u) * (1.0 - u)).max(0.0).sqrt();
u + (round - u) * e.clamp(0.0, 1.0)
}
/// Width factor (0–1) at arc length `s` of a path `len` long.
pub fn width_factor(taper: Option<&Taper>, wave: Option<&Wave>, s: f64, len: f64) -> f64 {
let mut k = 1.0;
if let Some(t) = taper.filter(|t| t.is_active()) {
let (mut ls, mut le) = if t.percent { (t.start_len * len, t.end_len * len) } else { (t.start_len, t.end_len) };
ls = ls.max(0.0);
le = le.max(0.0);
// Overlapping tapers share the path in proportion.
if ls + le > len && ls + le > 0.0 {
let f = len / (ls + le);
ls *= f;
le *= f;
}
let sw = t.start_width.clamp(0.0, 1.0);
let ew = t.end_width.clamp(0.0, 1.0);
if ls > 0.0 && s < ls {
k = f64::min(k, sw + (1.0 - sw) * ease(s / ls, t.start_ease));
}
if le > 0.0 && s > len - le {
k = f64::min(k, ew + (1.0 - ew) * ease((len - s) / le, t.end_ease));
}
}
if let Some(w) = wave.filter(|w| w.is_active()) {
let lambda = match w.cycles {
Some(c) => len / c,
None => w.wavelength,
};
if lambda > 0.0 {
let ph = std::f64::consts::TAU * s / lambda + w.phase.to_radians();
k *= 1.0 - w.amount.clamp(0.0, 1.0) * (0.5 - 0.5 * ph.cos());
}
}
k
}
/// A flattened subpath: points and their cumulative arc lengths.
struct Poly {
pts: Vec<Point>,
s: Vec<f64>,
closed: bool,
}
impl Poly {
fn len(&self) -> f64 {
self.s.last().copied().unwrap_or(0.0)
}
/// Point at arc length `t` (clamped), and the index of the segment it lies on.
fn at(&self, t: f64) -> (Point, usize) {
let i = self.s.partition_point(|&x| x <= t).clamp(1, self.pts.len() - 1);
let (a, b) = (self.s[i - 1], self.s[i]);
let u = if b > a { ((t - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 };
(self.pts[i - 1].lerp(self.pts[i], u), i)
}
}
/// Flatten each subpath, then subdivide so no segment is longer than `step`.
fn flatten(path: &BezPath, tol: f64, step: f64) -> Vec<Poly> {
let mut out: Vec<Poly> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
let mut closed = false;
let flush = |cur: &mut Vec<Point>, closed: bool, out: &mut Vec<Poly>| {
let mut pts: Vec<Point> = Vec::with_capacity(cur.len());
for &p in cur.iter() {
if pts.last().is_none_or(|q: &Point| q.distance(p) > 1e-9) {
pts.push(p);
}
}
if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) > 1e-9 {
pts.push(pts[0]);
}
cur.clear();
if pts.len() < 2 {
return;
}
// Subdivide.
let mut fine = vec![pts[0]];
for w in pts.windows(2) {
let d = w[0].distance(w[1]);
let n = (d / step).ceil().max(1.0) as usize;
for k in 1..=n {
fine.push(w[0].lerp(w[1], k as f64 / n as f64));
}
}
let mut s = Vec::with_capacity(fine.len());
let mut acc = 0.0;
s.push(0.0);
for w in fine.windows(2) {
acc += w[0].distance(w[1]);
s.push(acc);
}
out.push(Poly { pts: fine, s, closed });
};
kurbo::flatten(path.elements().iter().copied(), tol, |el| match el {
PathEl::MoveTo(p) => {
flush(&mut cur, closed, &mut out);
closed = false;
cur.push(p);
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
closed = true;
flush(&mut cur, closed, &mut out);
closed = false;
}
_ => {}
});
flush(&mut cur, closed, &mut out);
out
}
/// Arc-length intervals that are "on" for a dash pattern on a path `len` long.
pub fn dash_intervals(pattern: &[f64], offset: f64, len: f64) -> Vec<(f64, f64)> {
let pat: Vec<f64> = pattern.iter().map(|v| v.max(0.0)).collect();
let pat = if pat.len() % 2 == 1 { [pat.clone(), pat].concat() } else { pat };
let period: f64 = pat.iter().sum();
if pat.len() < 2 || period <= 1e-9 || len <= 0.0 {
return vec![(0.0, len)];
}
// Pattern position at s = 0.
let mut pos = offset.rem_euclid(period);
let mut idx = 0;
while pos >= pat[idx] {
pos -= pat[idx];
idx = (idx + 1) % pat.len();
}
let mut s = 0.0;
let mut out = Vec::new();
let mut guard = 0usize;
while s < len && guard < 1_000_000 {
guard += 1;
let seg = pat[idx] - pos;
let e = (s + seg).min(len);
if idx % 2 == 0 && e >= s {
out.push((s, e));
}
s += seg;
pos = 0.0;
idx = (idx + 1) % pat.len();
}
out
}
/// Append `p` to `out`, wound positively.
fn push_pos(out: &mut BezPath, p: BezPath) {
let p = if p.area() < 0.0 { p.reverse_subpaths() } else { p };
out.extend(p.elements().iter().copied());
}
fn quad(out: &mut BezPath, pts: [Point; 4]) {
let mut p = BezPath::new();
p.move_to(pts[0]);
p.line_to(pts[1]);
p.line_to(pts[2]);
p.line_to(pts[3]);
p.close_path();
if p.area().abs() > 1e-12 {
push_pos(out, p);
}
}
fn disc(out: &mut BezPath, c: Point, r: f64) {
if r > 1e-6 {
push_pos(out, kurbo::Circle::new(c, r).to_path(0.05));
}
}
/// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale).
pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option<BezPath> {
if style.width <= 0.0 {
return None;
}
let rs = res_scale.max(0.01);
let mut out = BezPath::new();
let half = style.width / 2.0;
for path in paths {
// Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path.
let rough = flatten(path, 0.1 / rs, f64::INFINITY);
for poly0 in rough {
let len0 = poly0.len();
if len0 <= 0.0 {
continue;
}
let step = (0.5 / rs).max(len0 / 20_000.0);
let mut bp = BezPath::new();
bp.move_to(poly0.pts[0]);
for p in &poly0.pts[1..] {
bp.line_to(*p);
}
let poly = flatten(&bp, 0.1 / rs, step).pop()?;
let poly = Poly { closed: poly0.closed, ..poly };
let len = poly.len();
let wf = |s: f64| half * width_factor(style.taper.as_ref(), style.wave.as_ref(), s, len);
let intervals = match &style.dash {
Some((d, off)) if d.iter().any(|v| *v > 0.0) => dash_intervals(d, *off, len),
_ => vec![(0.0, len)],
};
let whole = intervals.len() == 1 && intervals[0].0 <= 0.0 && intervals[0].1 >= len;
for (a, b) in intervals {
// The piece's points: interpolated ends plus every vertex strictly inside.
let (pa, ia) = poly.at(a);
let (pb, ib) = poly.at(b);
let mut pts = vec![(pa, a)];
for i in ia..ib {
pts.push((poly.pts[i], poly.s[i]));
}
pts.push((pb, b));
pts.dedup_by(|x, y| x.0.distance(y.0) < 1e-9);
if pts.len() < 2 {
// A zero-length dash: only its caps show.
if matches!(style.cap, Cap::Round) {
disc(&mut out, pa, wf(a));
}
continue;
}
let closed = whole && poly.closed;
let dir = |i: usize| (pts[i + 1].0 - pts[i].0).normalize();
for i in 0..pts.len() - 1 {
let d = dir(i);
let n = Vec2::new(-d.y, d.x);
let (p0, s0) = pts[i];
let (p1, s1) = pts[i + 1];
let (w0, w1) = (wf(s0), wf(s1));
quad(&mut out, [p0 + n * w0, p1 + n * w1, p1 - n * w1, p0 - n * w0]);
// Joins: a disc where the direction turns.
if i > 0 && dir(i - 1).dot(d) < 0.9999 {
disc(&mut out, p0, w0);
}
}
if closed {
disc(&mut out, pts[0].0, wf(0.0));
continue;
}
// Caps.
let last = pts.len() - 1;
let ends = [(pts[0].0, wf(pts[0].1), -dir(0)), (pts[last].0, wf(pts[last].1), dir(last - 1))];
for (p, w, outward) in ends {
match style.cap {
Cap::Butt => {}
Cap::Round => disc(&mut out, p, w),
Cap::Square => {
let n = Vec2::new(-outward.y, outward.x);
let q = p + outward * w;
quad(&mut out, [p + n * w, q + n * w, q - n * w, p - n * w]);
}
}
}
}
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FillRule, fill_coverage};
use effectcraft_geom::Mat3;
fn line(len: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((10.0, 50.0));
p.line_to((10.0 + len, 50.0));
p
}
fn column(m: &effectcraft_raster::Mask, x: usize) -> f32 {
(0..m.height as usize).map(|y| m.data[y * m.width as usize + x]).sum()
}
#[test]
fn taper_width_profile() {
let t = Taper { percent: false, start_len: 50.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 };
assert!(width_factor(Some(&t), None, 0.0, 200.0).abs() < 1e-9);
assert!((width_factor(Some(&t), None, 25.0, 200.0) - 0.5).abs() < 1e-9);
assert!((width_factor(Some(&t), None, 100.0, 200.0) - 1.0).abs() < 1e-9);
// Ease bulges the profile outwards.
let e = Taper { start_ease: 1.0, ..t.clone() };
assert!(width_factor(Some(&e), None, 25.0, 200.0) > 0.8);
// Percent units and overlapping tapers.
let p = Taper { percent: true, start_len: 0.75, end_len: 0.75, start_width: 0.0, end_width: 0.0, ..t };
assert!((width_factor(Some(&p), None, 100.0, 200.0) - 1.0).abs() < 1e-9, "meet in the middle at full width");
assert!(width_factor(Some(&p), None, 50.0, 200.0) < 0.6);
}
#[test]
fn wave_width_profile() {
let w = Wave { amount: 1.0, cycles: None, wavelength: 40.0, phase: 0.0 };
assert!((width_factor(None, Some(&w), 0.0, 200.0) - 1.0).abs() < 1e-9);
assert!(width_factor(None, Some(&w), 20.0, 200.0).abs() < 1e-9);
let c = Wave { cycles: Some(5.0), ..w.clone() };
assert!(width_factor(None, Some(&c), 20.0, 200.0).abs() < 1e-9, "5 cycles over 200 px = 40 px");
let ph = Wave { phase: 180.0, ..w };
assert!(width_factor(None, Some(&ph), 0.0, 200.0).abs() < 1e-9);
}
#[test]
fn tapered_line_coverage_grows_along_the_path() {
let t = Taper { percent: false, start_len: 100.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 };
let st = StrokeStyle { width: 20.0, taper: Some(t), ..Default::default() };
let o = outline(&[line(150.0)], &st, 1.0).unwrap();
let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero);
let (c1, c2, c3) = (column(&m, 30), column(&m, 70), column(&m, 140));
assert!((c1 - 4.0).abs() < 1.0, "20% along the taper → 4 px: {c1}");
assert!((c2 - 12.0).abs() < 1.0, "{c2}");
assert!((c3 - 20.0).abs() < 0.6, "{c3}");
let area: f32 = m.data.iter().sum();
assert!((area - (100.0 * 10.0 + 50.0 * 20.0)).abs() < 40.0, "{area}");
}
#[test]
fn dash_intervals_multi_segment() {
let iv = dash_intervals(&[10.0, 5.0, 2.0, 3.0], 0.0, 45.0);
assert_eq!(iv, vec![(0.0, 10.0), (15.0, 17.0), (20.0, 30.0), (35.0, 37.0), (40.0, 45.0)]);
let off = dash_intervals(&[10.0, 10.0], 5.0, 30.0);
assert_eq!(off, vec![(0.0, 5.0), (15.0, 25.0)]);
}
#[test]
fn waved_dashed_stroke_has_gaps() {
let st = StrokeStyle {
width: 10.0,
dash: Some((vec![20.0, 10.0], 0.0)),
wave: Some(Wave { amount: 0.5, cycles: None, wavelength: 30.0, phase: 0.0 }),
..Default::default()
};
let o = outline(&[line(150.0)], &st, 1.0).unwrap();
let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero);
assert!(column(&m, 41) > 9.0, "dash at full width (wave crest): {}", column(&m, 41));
assert!(column(&m, 35) < 0.01, "gap");
assert!(column(&m, 55) > 4.0 && column(&m, 55) < 6.5, "wave trough ≈ half width: {}", column(&m, 55));
}
#[test]
fn closed_tapered_path_is_continuous() {
let r = crate::rect([60.0, 60.0], [50.0, 50.0], 0.0);
let st = StrokeStyle { width: 6.0, wave: Some(Wave { amount: 0.2, cycles: Some(4.0), wavelength: 0.0, phase: 0.0 }), ..Default::default() };
let o = outline(&[r], &st, 1.0).unwrap();
let m = fill_coverage(&[o], &Mat3::IDENTITY, 100, 100, FillRule::NonZero);
assert!(m.data.iter().all(|v| *v <= 1.0001));
let area: f32 = m.data.iter().sum();
assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}");
}
}

View File

@ -438,8 +438,35 @@ pub fn shape_stroke(ids: &mut Ids, color: [f64; 4], width: f64) -> PropGroup {
.with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)),
)
.with(stroke_taper(ids))
.with(stroke_wave(ids))
}
/// Stroke ▸ Taper (After Effects 17.1+): width ramps at the start and end of each path.
pub fn stroke_taper(ids: &mut Ids) -> PropGroup {
ids.group("taper", "Taper")
.with(ids.prop("units", "Length Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Percent"])))
.with(ids.prop("startLength", "Start Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("endLength", "End Length", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("startWidth", "Start Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("endWidth", "End Width", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("startEase", "Start Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("endEase", "End Ease", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
}
/// Stroke ▸ Wave: the width oscillates along the path.
pub fn stroke_wave(ids: &mut Ids) -> PropGroup {
ids.group("wave", "Wave")
.with(ids.prop("amount", "Amount", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Pixels", "Cycles"])))
.with(ids.prop("wavelength", "Wavelength", Value::Scalar(20.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("cycles", "Cycles", Value::Scalar(10.0)).with_ui(slider(0.0, 1000.0, 0.0, 50.0, 1)))
.with(ids.prop("phase", "Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
}
/// Match ids of the optional Dash/Gap pairs After Effects adds with the Dashes "+" button.
pub const EXTRA_DASHES: [(&str, &str); 2] = [("dash2", "gap2"), ("dash3", "gap3")];
pub fn shape_gradient_fill(ids: &mut Ids, radial: bool, start: [f64; 2], end: [f64; 2], g: Gradient) -> PropGroup {
indexed(ids.group("gfill", "Gradient Fill 1"))
.with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden))
@ -475,6 +502,8 @@ pub fn shape_gradient_stroke(ids: &mut Ids, radial: bool, start: [f64; 2], end:
.with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)),
)
.with(stroke_taper(ids))
.with(stroke_wave(ids))
}
pub fn shape_trim(ids: &mut Ids, start: f64, end: f64, offset: f64) -> PropGroup {

View File

@ -194,7 +194,12 @@ pub struct Comp {
pub markers: Vec<Marker>,
pub shutter_angle: f64,
pub shutter_phase: f64,
/// Samples Per Frame: the minimum motion-blur samples (and the count for 3D layers).
pub motion_blur_samples: u32,
/// Adaptive Sample Limit (Composition Settings ▸ Advanced): the most samples a 2D layer gets
/// when it moves far within the shutter.
#[serde(default = "adaptive_limit_default")]
pub motion_blur_adaptive_limit: u32,
#[serde(default)]
pub renderer: Renderer,
/// Comp switches (timeline toolbar).
@ -227,6 +232,10 @@ fn yes() -> bool {
true
}
fn adaptive_limit_default() -> u32 {
128
}
impl Comp {
pub fn new(width: u32, height: u32, frame_rate: FrameRate, duration: Tick) -> Comp {
Comp {
@ -243,6 +252,7 @@ impl Comp {
shutter_angle: 180.0,
shutter_phase: -90.0,
motion_blur_samples: 16,
motion_blur_adaptive_limit: 128,
renderer: Renderer::Classic3D,
hide_shy: false,
enable_motion_blur: true,

View File

@ -522,6 +522,13 @@ impl PropGroup {
})
}
/// Parent group of the node with `uid`.
pub fn parent_of(&self, uid: Uid) -> Option<&PropGroup> {
if self.children.iter().any(|c| c.uid() == uid) {
return Some(self);
}
self.groups().find_map(|g| g.parent_of(uid))
}
/// Parent group of the node with `uid`.
pub fn parent_of_mut(&mut self, uid: Uid) -> Option<&mut PropGroup> {
if self.children.iter().any(|c| c.uid() == uid) {
return Some(self);

View File

@ -941,13 +941,51 @@ impl<'a> Renderer<'a> {
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Motion-blur sub-samples for a layer (1 = no motion blur).
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer) -> usize {
if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur {
if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
} else {
1
/// Whether a layer is motion blurred.
fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
}
/// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D
/// layer gets about one sample per output pixel its corners travel during the shutter,
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
/// does not move within the shutter is drawn once.
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize {
if !self.mb_on(ctx, layer) {
return 1;
}
let travel = self.shutter_travel(ctx, layer, buf);
if travel < 1e-3 {
return 1;
}
if self.opts.draft {
return 4;
}
adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit)
}
/// Largest distance (output pixels) a corner of the layer buffer moves during the shutter,
/// measured along the path at a few points.
fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 {
let fd = ctx.comp.frame_duration().seconds();
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
let (w, h) = (buf.img.width as f64, buf.img.height as f64);
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)];
const PROBES: usize = 8;
let mut prev: Option<Vec<effectcraft_geom::Vec2>> = None;
let mut travel = vec![0.0f64; corners.len()];
for i in 0..PROBES {
let f = phase + angle * i as f64 / (PROBES - 1) as f64;
let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf);
let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect();
if let Some(p) = &prev {
for (k, (a, b)) in p.iter().zip(&pts).enumerate() {
travel[k] += (b.x - a.x).hypot(b.y - a.y);
}
}
prev = Some(pts);
}
travel.into_iter().fold(0.0, f64::max)
}
/// Canvas pixels that compositing `buf` for `layer` can change: the transformed buffer's
@ -957,7 +995,7 @@ impl<'a> Renderer<'a> {
/// A quantised buffer placed pixel-exactly (integer translation, Normal, 100%) over a blank
/// canvas leaves exactly its own (quantised) pixels: nothing to re-quantise.
fn footprint(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, opacity: f32, blank: bool) -> Region {
if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer) > 1 {
if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer, buf) > 1 {
return Region::Full;
}
let m = self.buf_matrix(ctx, layer, buf);
@ -992,7 +1030,7 @@ impl<'a> Renderer<'a> {
/// How a processed layer buffer lands in the output: one buffer → output matrix, or one per
/// motion-blur sub-sample (averaged), with the layer's sampling filter and dissolve seed.
pub fn placement(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Placement {
let samples = self.mb_samples(ctx, layer);
let samples = self.mb_samples(ctx, layer, buf);
let (sampling, seed) = (self.sampling(layer), layer.id.0 as u32);
if samples <= 1 {
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
@ -1302,6 +1340,14 @@ pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image {
}
/// Convenience: render one frame of a comp with no footage source.
/// Adaptive motion-blur sample count for a layer whose corners travel `travel` output pixels
/// during the shutter: one sample per pixel, clamped to Samples Per Frame … Adaptive Sample Limit.
pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize {
let lo = per_frame.clamp(2, 64) as f64;
let hi = (limit.clamp(16, 256) as f64).max(lo);
travel.ceil().clamp(lo, hi) as usize
}
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
}

View File

@ -54,9 +54,17 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
let path = effectcraft_path::to_kurbo(&sp);
let (w, h) = (buf.img.width, buf.img.height);
let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale));
let mut cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero);
let exp = ctx.f(layer, g, "expansion", 0.0);
if exp.abs() > 0.01 {
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
// Variable-width feather (Mask Feather tool points): expansion and the per-point feather
// are applied together from the signed distance to the path.
let variable = !sp.feather.is_empty();
let mut cov = if variable {
effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear)
} else {
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero)
};
if exp.abs() > 0.01 && !variable {
let ring = effectcraft_path::stroke_coverage(
std::slice::from_ref(&path),
&StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() },
@ -71,7 +79,6 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
if feather[0] > 0.0 || feather[1] > 0.0 {
let img = cov.to_image();
let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. });
let b = if linear {
// One box pass: a straight ramp `feather` pixels wide across the edge.
let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 });

View File

@ -12,7 +12,7 @@ use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use rayon::prelude::*;
@ -37,6 +37,8 @@ enum Paint {
end: [f64; 2],
rule: FillRule,
stroke: Option<StrokeStyle>,
/// Radial gradients: Highlight Length (−100…100 %) and Highlight Angle (degrees).
highlight: (f64, f64),
},
}
@ -102,7 +104,35 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
if dash <= 0.0 {
return None;
}
let mut pat = vec![dash, if gap > 0.0 { gap } else { dash }];
// Dash 2 / Gap 2, Dash 3 / Gap 3 (added with the Dashes "+" button).
for (dm, gm) in effectcraft_project::build::EXTRA_DASHES {
if d.get(dm).is_none() {
break;
}
let dv = ctx.f(layer, d, dm, 0.0);
pat.push(dv);
pat.push(if d.get(gm).is_some() { ctx.f(layer, d, gm, 0.0) } else { dv });
}
Some((pat, ctx.f(layer, d, "offset", 0.0)))
});
let taper = g.sub("taper").map(|t| varstroke::Taper {
percent: ctx.e(layer, t, "units") == 1,
start_len: ctx.f(layer, t, "startLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
end_len: ctx.f(layer, t, "endLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
start_width: ctx.f(layer, t, "startWidth", 100.0) / 100.0,
end_width: ctx.f(layer, t, "endWidth", 100.0) / 100.0,
start_ease: ctx.f(layer, t, "startEase", 0.0) / 100.0,
end_ease: ctx.f(layer, t, "endEase", 0.0) / 100.0,
});
let wave = g.sub("wave").map(|w| varstroke::Wave {
amount: ctx.f(layer, w, "amount", 0.0) / 100.0,
cycles: (ctx.e(layer, w, "units") == 1).then(|| ctx.f(layer, w, "cycles", 10.0)),
wavelength: ctx.f(layer, w, "wavelength", 20.0),
phase: ctx.f(layer, w, "phase", 0.0),
});
StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
@ -110,6 +140,8 @@ fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
taper: taper.filter(|t| t.is_active()),
wave: wave.filter(|w| w.is_active()),
}
}
@ -220,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
rule: rule(ctx, layer, g),
stroke,
highlight: (ctx.f(layer, g, "highlightLength", 0.0), ctx.f(layer, g, "highlightAngle", 0.0)),
},
slots: live.clone(),
xf: Mat3::IDENTITY,
@ -359,6 +392,54 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
(draws, live)
}
/// Gradient position of a point: linear along start→end, radial by distance from the start over
/// the radius |end − start|. A radial Highlight shifts the focal point towards
/// `highlight_length` % of the radius at `highlight_angle` from the start→end axis (a focal
/// two-point gradient: t is measured from the focal point to the circle along the ray).
#[derive(Clone, Copy, Debug)]
pub(crate) struct GradientRamp {
radial: bool,
s: [f64; 2],
d: [f64; 2],
len2: f64,
focal: Option<[f64; 2]>,
}
impl GradientRamp {
pub(crate) fn new(radial: bool, start: [f64; 2], end: [f64; 2], highlight: (f64, f64)) -> Self {
let d = [end[0] - start[0], end[1] - start[1]];
let len2 = (d[0] * d[0] + d[1] * d[1]).max(1e-9);
let (hl, ha) = highlight;
let focal = (radial && hl.abs() > 1e-6).then(|| {
let r = len2.sqrt();
let a = d[1].atan2(d[0]) + ha.to_radians();
let dist = r * (hl / 100.0).clamp(-0.99, 0.99);
[start[0] + dist * a.cos(), start[1] + dist * a.sin()]
});
GradientRamp { radial, s: start, d, len2, focal }
}
pub(crate) fn t(&self, x: f64, y: f64) -> f64 {
let (vx, vy) = (x - self.s[0], y - self.s[1]);
if !self.radial {
return (vx * self.d[0] + vy * self.d[1]) / self.len2;
}
let Some(f) = self.focal else { return ((vx * vx + vy * vy) / self.len2).sqrt() };
// Ray from the focal point through p meets the circle (centre s, radius r) at λ.
let (px, py) = (x - f[0], y - f[1]);
let dist = (px * px + py * py).sqrt();
if dist < 1e-12 {
return 0.0;
}
let (ux, uy) = (px / dist, py / dist);
let (cx, cy) = (f[0] - self.s[0], f[1] - self.s[1]);
let b = ux * cx + uy * cy;
let c = cx * cx + cy * cy - self.len2;
let lambda = -b + (b * b - c).max(0.0).sqrt();
if lambda <= 1e-12 { 1.0 } else { dist / lambda }
}
}
/// Pixel rectangle of the layer buffer (`x0, y0, width, height`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PxRect {
@ -486,16 +567,12 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) {
match &d.paint {
Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color),
Paint::Gradient { g, radial, start, end, .. } => {
Paint::Gradient { g, radial, start, end, highlight, .. } => {
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
let ramp = GradientRamp::new(*radial, *start, *end, *highlight);
paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| {
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
let (vx, vy) = (p.x - sx, p.y - sy);
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
g.sample(t)
g.sample(ramp.t(p.x, p.y))
});
}
}

View File

@ -340,3 +340,87 @@ fn shape_perf_repeater_60_copies_1080p() {
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms");
}
fn line_path(ids: &mut Ids, from: [f64; 2], to: [f64; 2]) -> PropGroup {
let sp = effectcraft_keyframe::ShapePath {
vertices: vec![from, to],
in_tangents: vec![[0.0; 2]; 2],
out_tangents: vec![[0.0; 2]; 2],
closed: false,
feather: Vec::new(),
};
build::shape_path(ids, sp)
}
fn column_alpha(img: &Image, x: i64) -> f32 {
(0..img.height as i64).map(|y| alpha(img, x, y)).sum()
}
#[test]
fn stroke_taper_narrows_the_ends() {
// A horizontal line from x = 20 to 180 (layer −80…80), 20 px wide, tapered over 40 px at both ends.
let (p, cid) = shape_comp(|ids| {
let mut st = build::shape_stroke(ids, RED, 20.0);
set(&mut st, "taper/startLength", Value::Scalar(40.0));
set(&mut st, "taper/endLength", Value::Scalar(40.0));
set(&mut st, "taper/startWidth", Value::Scalar(0.0));
set(&mut st, "taper/endWidth", Value::Scalar(50.0));
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
});
let img = frame(&p, cid);
let near_start = column_alpha(&img, 30); // 10 px along → 25 % of 20 px
let mid = column_alpha(&img, 100);
let near_end = column_alpha(&img, 169); // 10 px before the end → (50 % + 50 % × 25 %) of 20 px
assert!((near_start - 5.0).abs() < 1.0, "{near_start}");
assert!((mid - 20.0).abs() < 0.6, "{mid}");
assert!((near_end - 12.5).abs() < 1.0, "{near_end}");
}
#[test]
fn stroke_wave_and_multi_dashes() {
let (p, cid) = shape_comp(|ids| {
let mut st = build::shape_stroke(ids, RED, 10.0);
set(&mut st, "wave/amount", Value::Scalar(100.0));
set(&mut st, "wave/wavelength", Value::Scalar(40.0));
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
});
let img = frame(&p, cid);
assert!(column_alpha(&img, 40) < 0.5, "trough (20 px along): {}", column_alpha(&img, 40));
assert!(column_alpha(&img, 60) > 9.0, "crest (40 px along): {}", column_alpha(&img, 60));
// Dash 20, Gap 10, Dash 2 = 5, Gap 2 = 15: on 0–20, 30–35, 50–70…
let (p, cid) = shape_comp(|ids| {
let mut st = build::shape_stroke(ids, RED, 10.0);
set(&mut st, "dashes/dash", Value::Scalar(20.0));
set(&mut st, "dashes/gap", Value::Scalar(10.0));
let d = st.sub_mut("dashes").unwrap();
d.children.insert(2, ids.prop("gap2", "Gap 2", Value::Scalar(15.0)).into());
d.children.insert(2, ids.prop("dash2", "Dash 2", Value::Scalar(5.0)).into());
vec![line_path(ids, [-80.0, 0.0], [80.0, 0.0]), st]
});
let img = frame(&p, cid);
let on = |s: i64| column_alpha(&img, 20 + s) > 9.0;
assert!(on(10) && !on(25) && on(32) && !on(40) && on(60), "{:?}", (0..80).map(|s| on(s) as u8).collect::<Vec<_>>());
}
#[test]
fn radial_gradient_highlight_moves_the_focal_point() {
let build_with = |hl: f64| {
shape_comp_sized(200, 200, move |ids| {
let mut gf = build::shape_gradient_fill(ids, true, [0.0, 0.0], [80.0, 0.0], Gradient::default());
set(&mut gf, "highlightLength", Value::Scalar(hl));
set(&mut gf, "highlightAngle", Value::Scalar(90.0));
vec![build::shape_rect(ids, [200.0, 200.0], [0.0, 0.0], 0.0), gf]
})
};
let (p0, c0) = build_with(0.0);
let (p1, c1) = build_with(50.0);
let (a, b) = (frame(&p0, c0), frame(&p1, c1));
// Without a highlight the centre is white; a 50 % highlight at 90° (down the +y axis, 90° from
// the start→end axis) puts the white focus 40 px below the centre and darkens the centre.
assert!(a.get(100, 100)[0] > 0.98);
assert!(b.get(100, 140)[0] > 0.97, "focal point: {:?}", b.get(100, 140));
assert!(b.get(100, 100)[0] < 0.8, "{:?}", b.get(100, 100));
// The outer circle is unchanged: points just outside it are black in both.
assert!(a.get(100, 182)[0] < 0.03 && b.get(100, 182)[0] < 0.03 && b.get(100, 17)[0] < 0.03);
}

View File

@ -228,7 +228,7 @@ fn anchor_grouping_line_rotates_around_line_centre() {
fn mask(p: &mut Project, l: &mut Layer, pts: &[[f64; 2]], closed: bool) {
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed };
let sp = ShapePath { vertices: pts.to_vec(), in_tangents: vec![[0.0; 2]; pts.len()], out_tangents: vec![[0.0; 2]; pts.len()], closed, feather: Vec::new() };
let g = build::mask(&mut ids, "Mask 1", sp, MaskMode::None, [255, 255, 0]);
p.next_id = next;
let masks = l.props.group_mut("masks").unwrap();

View File

@ -162,6 +162,8 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("stroke", "join", "ADBE Vector Stroke Line Join"),
("stroke", "miter", "ADBE Vector Stroke Miter Limit"),
("stroke", "dashes", "ADBE Vector Stroke Dashes"),
("stroke", "taper", "ADBE Vector Stroke Taper"),
("stroke", "wave", "ADBE Vector Stroke Wave"),
("trim", "start", "ADBE Vector Trim Start"),
("trim", "end", "ADBE Vector Trim End"),
("trim", "offset", "ADBE Vector Trim Offset"),

View File

@ -130,6 +130,7 @@ fn item(s: &Session, a: &J) -> R {
"hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending,
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
"motionBlurSamplesPerFrame": c.motion_blur_samples,
"motionBlurAdaptiveSampleLimit": c.motion_blur_adaptive_limit,
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),

View File

@ -85,6 +85,7 @@ pub struct CompDraft {
pub shutter_angle: f64,
pub shutter_phase: f64,
pub samples: u32,
pub adaptive_limit: u32,
pub advanced_3d: bool,
pub tab: Tab,
/// Timecode text being edited (start, duration).
@ -108,6 +109,7 @@ impl Default for CompDraft {
shutter_angle: 180.0,
shutter_phase: -90.0,
samples: 16,
adaptive_limit: 128,
advanced_3d: false,
tab: Tab::Basic,
start_tc: None,
@ -294,6 +296,9 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens) -> Option<serde_js
ui.label("Samples Per Frame:");
ui.add(egui::DragValue::new(&mut d.samples).range(2..=64));
ui.end_row();
ui.label("Adaptive Sample Limit:");
ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256));
ui.end_row();
});
}
Tab::Renderer => {
@ -325,7 +330,7 @@ pub fn params(d: &CompDraft) -> serde_json::Value {
json!({
"name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps,
"duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit,
"renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" },
})
}

View File

@ -66,6 +66,7 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
shutter_angle: c.shutter_angle,
shutter_phase: c.shutter_phase,
samples: c.motion_blur_samples,
adaptive_limit: c.motion_blur_adaptive_limit,
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
..Default::default()
};

View File

@ -1393,6 +1393,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
// Mask mode + inverted inline.
if let Some(g) = layer.props.find_group(*uid)
&& let GroupKind::Mask { mode, inverted, .. } = g.kind
@ -2002,6 +2003,37 @@ fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
}
}
/// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair).
#[allow(clippy::too_many_arguments)]
fn dash_buttons(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
x: f32,
cy: f32,
actions: &mut Vec<(String, serde_json::Value)>,
) {
if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") {
return;
}
let Some(stroke) = layer.props.parent_of(uid).map(|g| g.uid) else { return };
let t = app.tokens;
for (i, (sym, id, tip)) in
[("+", "shape.dashes.add", "Add a Dash or Gap"), ("\u{2212}", "shape.dashes.remove", "Remove a Dash or Gap")].into_iter().enumerate()
{
let r = Rect::from_center_size(pos2(x + 12.0 + i as f32 * 20.0, cy), vec2(16.0, 16.0));
let resp = ui.interact(r, egui::Id::new(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip);
p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, sym, Tokens::ui(12.0), if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.{}", if i == 0 { "addDash" } else { "removeDash" }), r, tip);
if resp.clicked() {
actions.push((id.into(), json!({"layer": layer.id.0, "prop": stroke})));
}
}
}
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
#[allow(clippy::too_many_arguments)]
fn text_anim_popups(

View File

@ -41,6 +41,8 @@ pub(crate) struct PathInfo {
pub m: Mat3,
pub sp: ShapePath,
pub is_mask: bool,
/// Mask feather points' radius handles on screen (feather point index, handle position).
pub feather: Vec<(usize, Pos2)>,
}
fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] {
@ -93,7 +95,7 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
let (m, _) = super::viewer::l2c(ectx, l);
let is_mask = matches!(g.kind, GroupKind::Mask { .. });
let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) };
Some(PathInfo { layer, uid, m, sp, is_mask })
Some(PathInfo { layer, uid, m, sp, is_mask, feather: Vec::new() })
}
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
@ -178,10 +180,55 @@ pub(crate) fn draw_paths(
{
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
}
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask });
let feather = if is_mask { draw_feather_points(painter, map, &sp, &pm, mc) } else { Vec::new() };
for (i, h) in &feather {
app.auto.add(&format!("viewer.mask.{uid}.feather.{i}"), Rect::from_center_size(*h, vec2(8.0, 8.0)), &name);
}
paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask, feather });
}
}
/// Mask feather points: a hollow dot on the path and a radius handle along the normal (outwards for
/// outer feather, inwards for inner), joined by a line. Returns the handles' screen positions.
fn draw_feather_points(painter: &egui::Painter, map: &ViewerMap, sp: &ShapePath, pm: &Mat3, col: Color32) -> Vec<(usize, Pos2)> {
let mut out = Vec::new();
let scr = |p: [f64; 2]| {
let c = pm.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
};
let inside = |q: [f64; 2]| {
// Even-odd test against the vertex polygon (good enough to orient the handle).
let v = &sp.vertices;
let mut c = false;
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]) + a[0] {
c = !c;
}
}
c
};
for (i, f) in sp.feather.iter().enumerate() {
let Some(c) = segment(sp, f.segment) else { continue };
let at = cubic(c[0], c[1], c[2], c[3], f.t);
let (t0, t1) = ((f.t - 1e-3).max(0.0), (f.t + 1e-3).min(1.0));
let (a, b) = (cubic(c[0], c[1], c[2], c[3], t0), cubic(c[0], c[1], c[2], c[3], t1));
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy).max(1e-9);
let mut n = [-dy / len, dx / len];
if inside([at[0] + n[0] * 2.0, at[1] + n[1] * 2.0]) {
n = [-n[0], -n[1]];
}
let h = [at[0] + n[0] * f.radius, at[1] + n[1] * f.radius];
let (ps, hs) = (scr(at), scr(h));
painter.line_segment([ps, hs], Stroke::new(1.0, col));
painter.circle_stroke(ps, 3.5, Stroke::new(1.0, col));
painter.circle_filled(hs, 3.0, col);
out.push((i, hs));
}
out
}
// ---------------------------------------------------------------- motion paths
/// A keyframe dot or spatial tangent handle on a motion path.
@ -382,8 +429,8 @@ pub(crate) enum Drag {
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] },
/// Mask Feather tool: drag away from the path to feather the mask.
Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 },
/// Mask Feather tool: drag a feather point's radius handle (comp → layer matrix `inv`).
Feather { layer: LayerId, mask: u64, index: usize, inv: Mat3 },
/// A guide dragged in the viewer.
Guide { index: usize, vertical: bool },
/// Drawing the region of interest.
@ -436,13 +483,26 @@ pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option<D
None
}
/// Mask Feather tool: start on a mask path.
pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), map, press, 10.0)?;
let l = ectx.comp.layer(layer)?;
let pr = l.masks()?.find_group(uid)?.get("feather")?;
let _ = app;
Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press })
/// Mask Feather tool: press on a feather point's handle to drag it, or on a mask path to add a
/// feather point there and drag its radius out.
pub(crate) fn begin_feather(app: &mut EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option<Drag> {
let _ = ectx;
for p in paths.iter().filter(|p| p.is_mask) {
if let Some((i, _)) = p.feather.iter().find(|(_, h)| h.distance(press) < 7.0) {
return Some(Drag::Feather { layer: p.layer, mask: p.uid, index: *i, inv: p.m.inverse()? });
}
}
let masks: Vec<PathInfo> = paths.iter().filter(|p| p.is_mask).cloned().collect();
let (layer, uid, seg, t) = segment_at(&masks, map, press, 10.0)?;
let inv = masks.iter().find(|p| p.layer == layer && p.uid == uid)?.m.inverse()?;
let r = app.session.execute("mask.featherPoint.add", json!({"layer": layer.0, "mask": uid, "segment": seg, "t": t, "radius": 0}));
match r {
Ok(v) => Some(Drag::Feather { layer, mask: uid, index: v["index"].as_u64()? as usize, inv }),
Err(e) => {
app.ui.status = e.to_string();
None
}
}
}
/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp
@ -513,10 +573,13 @@ pub(crate) fn update(
}
Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes })
}
Drag::Feather { layer, prop, start, press } => {
let amt = (pos.distance(press) / zoom.max(1e-3)) as f64;
let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]);
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge}));
Drag::Feather { layer, mask, index, inv } => {
let p = inv.apply(gv2(cpt[0], cpt[1]));
let _ = (pos, zoom);
let q = json!({"layer": layer.0, "mask": mask, "index": index, "toward": [p.x, p.y], "merge": merge});
if let Err(e) = app.session.execute("mask.featherPoint.set", q) {
app.ui.status = e.to_string();
}
None
}
Drag::Guide { index, .. } => {

View File

@ -295,3 +295,41 @@ fn timeline_alt_drag_scales_a_key_group_in_time() {
assert_eq!(ts[0], 0.0);
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
}
#[test]
fn mask_feather_tool_adds_and_drags_feather_points() {
let mut h = harness();
// A mask on the Box (80×80 at the comp centre, comp 280…360 × 140…220): layer 10…70.
let (box_id, mask) = {
let s = &mut h.state_mut().session;
let b = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
let m = s.execute("mask.new", json!({"layer": b.0, "vertices": [[10, 10], [70, 10], [70, 70], [10, 70]], "closed": true})).unwrap()["mask"]
.as_u64()
.unwrap();
s.execute("layer.select", json!({"layers": [b.0]})).unwrap();
(b, m)
};
h.state_mut().ui.tool = Tool::MaskFeather;
h.run_steps(3);
// Press on the top edge (comp 320,150) and drag 12 px up: an outer feather point of ≈12 px.
let from = screen(&h, [320.0, 150.0]);
let z = rect(&h, "viewer.comp").width() / 640.0;
drag(&mut h, from, from - vec2(0.0, 12.0 * z));
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r - 12.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"][0]["segment"], json!(0));
// The handle is registered; dragging it inwards makes it an inner feather point.
h.run_steps(2);
let handle = rect(&h, &format!("viewer.mask.{mask}.feather.0")).center();
let to = screen(&h, [320.0, 160.0]);
drag(&mut h, handle, to);
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
let r = pts["points"][0]["radius"].as_f64().unwrap();
assert!((r + 10.0).abs() < 1.5, "{pts}");
assert_eq!(pts["points"].as_array().unwrap().len(), 1);
// One drag = one undo step each.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
let pts = h.state_mut().session.execute("mask.featherPoint.list", json!({"layer": box_id.0, "mask": mask})).unwrap();
assert!(pts["points"][0]["radius"].as_f64().unwrap() > 0.0);
}