Files
effectcraft/crates/engine/src/commands/paths.rs
T
vangel76andClaude Opus 5.5 fb5a5e9ac0 #315: Front / Bevel / Side / Back Color for extruded shapes and text
Problem: In Advanced 3D, an extruded shape or text layer was one mesh in
its paint colour. After Effects colours the front, bevel, side and back
of an extrusion separately (shape Contents ▸ Add ▸ Front/Bevel/Side/Back,
text Animate ▸ Front/Bevel/Side/Back), and EffectCraft had none of it.

Fix: The extruder can split its mesh by surface (front cap, bevel rings,
walls, back cap). Shape layers get Front / Bevel / Side / Back contents
items (layer.addShapeItem kinds frontColor…backColor, one item per
group, colouring every paint of the group and its nested groups; the
innermost wins). Text animators get Front / Bevel / Side / Back Color,
blended over the character's fill or stroke by the selection. Paints
with a surface colour render as one mesh per surface. Menus, the
Contents Add: pop-up and the menu translations list them. The other
surface material properties (Ambient, Diffuse, Specular, Metal,
Reflection, ...) remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:05:36 +02:00

478 lines
20 KiB
Rust

//! Layer ▸ Mask and Shape Path (RotoBezier, Convert To Bezier Path, Group / Ungroup Shapes, Set
//! First Vertex, Free Transform Points) and Layer ▸ Mask ▸ Motion Blur, Feather Falloff and Hide
//! Locked Masks.
use effectcraft_keyframe::{ShapePath, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
use serde_json::{Value, json};
use super::layer_menu::target_masks;
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p};
use crate::{EngineError, Result, Session, VertexRef, cmd};
// ---------------------------------------------------------------- RotoBezier
/// Automatic (RotoBezier) tangents: each vertex's tangents follow the line through its
/// neighbours, a third of the way to them (Catmull-Rom); open ends get none.
pub(crate) fn roto_smooth(sp: &mut ShapePath) {
let n = sp.vertices.len();
sp.in_tangents.resize(n, [0.0; 2]);
sp.out_tangents.resize(n, [0.0; 2]);
for i in 0..n {
let (prev, next) = if sp.closed {
((i + n - 1) % n, (i + 1) % n)
} else if i == 0 || i + 1 == n {
sp.in_tangents[i] = [0.0; 2];
sp.out_tangents[i] = [0.0; 2];
continue;
} else {
(i - 1, i + 1)
};
let (a, b) = (sp.vertices[prev], sp.vertices[next]);
let d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0];
sp.out_tangents[i] = d;
sp.in_tangents[i] = [-d[0], -d[1]];
}
}
/// Whether a mask uses RotoBezier tangents.
pub(crate) fn is_roto(g: &PropGroup) -> bool {
matches!(g.kind, GroupKind::Mask { roto_bezier: true, .. })
}
/// Apply `f` to a mask path's static value and every key.
fn map_path(g: &mut PropGroup, f: &dyn Fn(&mut ShapePath)) {
let Some(pr) = g.get_mut("path") else { return };
if let KV::Path(sp) = &mut pr.value {
f(sp);
}
for k in &mut pr.keys {
if let KV::Path(sp) = &mut k.value {
f(sp);
}
}
}
/// Mask targets: masks of the selected vertices, else [`target_masks`].
fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
if p.get("mask").is_none() && p.get("layer").is_none() && !s.state.selected_vertices.is_empty() {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let mut v: Vec<(LayerId, Uid)> = s.state.selected_vertices.iter().map(|v| (v.layer, v.mask)).collect();
v.sort();
v.dedup();
return Ok((cid, v));
}
// A shape layer's Path item by uid.
if let Some(uid) = p.get("mask").and_then(Value::as_u64) {
let (cid, lid) = layer_p(s, p, "path")?;
if s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find_group(uid)).is_some_and(|g| g.match_id == "path") {
return Ok((cid, vec![(lid, uid)]));
}
}
target_masks(s, p)
}
fn edit_mask_groups(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut PropGroup) -> Result<()>) -> Result<Value> {
let (cid, targets) = masks_for(s, p)?;
if targets.is_empty() {
return Err(bad(label, "no masks: select a layer with masks (or mask vertices)"));
}
let n = targets.len();
s.edit(label, None, |proj, _| {
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
if let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) {
f(g)?;
}
}
Ok(())
})?;
Ok(json!({"masks": n}))
}
fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, targets) = masks_for(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let all_on = targets.iter().all(|(l, u)| comp.layer(*l).and_then(|l| l.masks()).and_then(|m| m.find_group(*u)).is_some_and(is_roto));
let on = b_p(p, "value").unwrap_or(!all_on);
edit_mask_groups(s, p, "RotoBezier", |g| {
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
*roto_bezier = on;
}
if on {
map_path(g, &roto_smooth);
}
Ok(())
})?;
Ok(json!({"rotoBezier": on}))
}
fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
*roto_bezier = false;
}
Ok(())
})
}
// ---------------------------------------------------------------- mask options
fn mask_motion_blur(s: &mut Session, p: &Value) -> Result<Value> {
let mode = match str_p(p, "mode").unwrap_or("") {
"sameAsLayer" | "same" => MaskMotionBlur::SameAsLayer,
"on" => MaskMotionBlur::On,
"off" => MaskMotionBlur::Off,
_ => return Err(bad("layer.mask.motionBlur", "mode: sameAsLayer|on|off")),
};
edit_mask_groups(s, p, "Mask Motion Blur", |g| {
if let GroupKind::Mask { motion_blur, .. } = &mut g.kind {
*motion_blur = mode;
}
Ok(())
})
}
fn feather_falloff(s: &mut Session, p: &Value) -> Result<Value> {
let mode = match str_p(p, "mode").unwrap_or("") {
"smooth" => FeatherFalloff::Smooth,
"linear" => FeatherFalloff::Linear,
_ => return Err(bad("layer.mask.featherFalloff", "mode: smooth|linear")),
};
edit_mask_groups(s, p, "Mask Feather Falloff", |g| {
if let GroupKind::Mask { feather_falloff, .. } = &mut g.kind {
*feather_falloff = mode;
}
Ok(())
})
}
fn hide_locked(s: &mut Session, p: &Value) -> Result<Value> {
s.state.hide_locked_masks = b_p(p, "value").unwrap_or(!s.state.hide_locked_masks);
Ok(json!(s.state.hide_locked_masks))
}
// ---------------------------------------------------------------- vertices
fn has_one_vertex(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_vertices.len() == 1 { Ok(()) } else { Err("select one mask vertex".into()) }
}
fn set_first_vertex(s: &mut Session, p: &Value) -> Result<Value> {
let v = match (p.get("layer"), p.get("mask"), p.get("index").and_then(Value::as_u64)) {
(Some(_), Some(m), Some(i)) => {
let (_, lid) = layer_p(s, p, "path.setFirstVertex")?;
let mask = m.as_u64().ok_or_else(|| bad("path.setFirstVertex", "`mask` is the mask uid"))?;
VertexRef { layer: lid, mask, index: i as usize }
}
_ => *s
.state
.selected_vertices
.first()
.filter(|_| s.state.selected_vertices.len() == 1)
.ok_or_else(|| bad("path.setFirstVertex", "select one mask vertex"))?,
};
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
s.edit("Set First Vertex", None, |proj, st| {
let l = layer_mut(proj, cid, v.layer)?;
let g = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(v.mask)).ok_or_else(|| bad("path.setFirstVertex", "no such mask"))?;
let k = v.index;
let mut err = None;
map_path(g, &|sp| {
if k < sp.vertices.len() && sp.closed {
sp.vertices.rotate_left(k);
let n = sp.in_tangents.len();
sp.in_tangents.rotate_left(k.min(n));
let n = sp.out_tangents.len();
sp.out_tangents.rotate_left(k.min(n));
}
});
if let Some(KV::Path(sp)) = g.get("path").map(|p| &p.value)
&& !sp.closed
{
err = Some(bad("path.setFirstVertex", "open paths always start at their first vertex"));
}
if let Some(e) = err {
return Err(e);
}
st.selected_vertices = vec![VertexRef { index: 0, ..v }];
Ok(())
})?;
Ok(json!({"first": v.index}))
}
/// Free Transform Points: scale / rotate / move the selected vertices (or whole masks) about an
/// anchor (default: the centre of their bounding box), at the current time.
fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
let scale = match p.get("scale") {
Some(Value::Number(n)) => {
let k = n.as_f64().unwrap_or(100.0) / 100.0;
[k, k]
}
Some(Value::Array(a)) if a.len() >= 2 => [a[0].as_f64().unwrap_or(100.0) / 100.0, a[1].as_f64().unwrap_or(100.0) / 100.0],
_ => [1.0, 1.0],
};
let rot = f_p(p, "rotation").unwrap_or(0.0).to_radians();
let off =
p.get("offset").and_then(Value::as_array).map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
let off = off.unwrap_or([0.0; 2]);
let anchor =
p.get("anchor").and_then(Value::as_array).map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
if scale == [1.0, 1.0] && rot == 0.0 && off == [0.0, 0.0] {
return Err(bad("path.freeTransform", "pass scale (% or [x%, y%]), rotation (deg) and/or offset [dx, dy]; anchor? [x, y]"));
}
let (cid, targets) = masks_for(s, p)?;
let selected: Vec<VertexRef> = s.state.selected_vertices.clone();
let t = s.time();
let (c, sn) = (rot.cos(), rot.sin());
let lin = |v: [f64; 2]| {
let x = v[0] * scale[0];
let y = v[1] * scale[1];
[x * c - y * sn, x * sn + y * c]
};
let n = s.edit("Free Transform Points", None, |proj, _| {
let mut n = 0;
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let Some(g) = l.props.find_group_mut(*uid).filter(|g| super::mask::is_path_group(g)) else { continue };
let roto = is_roto(g);
let Some(pr) = g.get_mut("path") else { continue };
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
let idx: Vec<usize> = {
let sel: Vec<usize> =
selected.iter().filter(|v| v.layer == *lid && v.mask == *uid).map(|v| v.index).filter(|i| *i < sp.vertices.len()).collect();
if sel.is_empty() { (0..sp.vertices.len()).collect() } else { sel }
};
if idx.is_empty() {
continue;
}
let a = anchor.unwrap_or_else(|| {
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for &i in &idx {
let v = sp.vertices[i];
lo = [lo[0].min(v[0]), lo[1].min(v[1])];
hi = [hi[0].max(v[0]), hi[1].max(v[1])];
}
[(lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0]
});
for &i in &idx {
let v = sp.vertices[i];
let q = lin([v[0] - a[0], v[1] - a[1]]);
sp.vertices[i] = [q[0] + a[0] + off[0], q[1] + a[1] + off[1]];
if let Some(tn) = sp.in_tangents.get_mut(i) {
*tn = lin(*tn);
}
if let Some(tn) = sp.out_tangents.get_mut(i) {
*tn = lin(*tn);
}
n += 1;
}
if roto {
roto_smooth(&mut sp);
}
pr.set_value_at(lt, KV::Path(sp));
}
Ok(n)
})?;
Ok(json!({"vertices": n}))
}
// ---------------------------------------------------------------- shape groups
const SHAPE_ITEMS: &[&str] = &[
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
"wiggle", "merge", "front", "bevel", "side", "back",
];
/// Selected shape items (contents entries) of shape layers: (layer, uid).
fn selected_shape_items(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut out = vec![];
if let Some(Value::Array(a)) = p.get("items") {
let (_, lid) = layer_p(s, p, "path.groupShapes")?;
out.extend(a.iter().filter_map(Value::as_u64).map(|u| (lid, u)));
} else {
for (lid, uid) in &s.state.selected_props {
let Some(l) = comp.layer(*lid).filter(|l| matches!(l.source, LayerSource::Shape)) else { continue };
if l.props
.sub("contents")
.and_then(|c| c.find_group(*uid))
.is_some_and(|g| g.uid != l.props.sub("contents").map(|c| c.uid).unwrap_or(0) && SHAPE_ITEMS.contains(&g.match_id.as_str()))
{
out.push((*lid, *uid));
}
}
}
Ok((cid, out))
}
fn has_shape_items(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let comp = s.active_comp().ok_or("no composition")?;
let ok = s.state.selected_props.iter().any(|(l, u)| {
comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| SHAPE_ITEMS.contains(&g.match_id.as_str()))
});
if ok { Ok(()) } else { Err("select shape items (Contents ▸ …) in the timeline".into()) }
}
fn group_shapes(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, items) = selected_shape_items(s, p)?;
let Some(lid) = items.first().map(|x| x.0) else { return Err(bad("path.groupShapes", "select shape items to group")) };
let uids: Vec<Uid> = items.iter().filter(|(l, _)| *l == lid).map(|(_, u)| *u).collect();
let g = s.edit("Group Shapes", None, |proj, st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.groupShapes", "not a shape layer"))?;
let parent = contents.parent_of_mut(uids[0]).ok_or_else(|| bad("path.groupShapes", "no such shape item"))?;
if !uids.iter().all(|u| parent.children.iter().any(|c| c.uid() == *u)) {
return Err(bad("path.groupShapes", "the shape items must be in the same group"));
}
let at = parent.children.iter().position(|c| uids.contains(&c.uid())).unwrap_or(0);
let mut moved = vec![];
parent.children.retain(|c| {
if uids.contains(&c.uid()) {
moved.push(c.clone());
false
} else {
true
}
});
let n = parent.children.iter().filter(|c| c.match_id() == "group").count() + 1;
let groups: Vec<PropGroup> = moved.into_iter().filter_map(|c| if let Node::Group(g) = c { Some(g) } else { None }).collect();
let g = build::shape_group(&mut Ids(&mut next), &format!("Group {n}"), groups);
let uid = g.uid;
parent.children.insert(at.min(parent.children.len()), Node::Group(g));
proj.next_id = next;
st.selected_props = vec![(lid, uid)];
Ok(uid)
})?;
Ok(json!({"group": g}))
}
/// Add `d` to every positional value in a shape item subtree (shape positions, path vertices,
/// group positions, gradient points, twist centres) — static values and keys.
fn offset_items(g: &mut PropGroup, d: [f64; 2]) {
let shift = |v: &mut KV| match v {
KV::Vec2(a) => {
a[0] += d[0];
a[1] += d[1];
}
KV::Path(sp) => {
for q in &mut sp.vertices {
q[0] += d[0];
q[1] += d[1];
}
}
_ => {}
};
let props: &[&str] = match g.match_id.as_str() {
"rect" | "ellipse" | "star" => &["position"],
"path" => &["path"],
"gfill" | "gstroke" => &["start", "end"],
"twist" => &["center"],
_ => &[],
};
for m in props {
if let Some(pr) = g.get_mut(m) {
shift(&mut pr.value);
for k in &mut pr.keys {
shift(&mut k.value);
}
}
}
if g.match_id == "group"
&& let Some(pr) = g.sub_mut("transform").and_then(|t| t.get_mut("position"))
{
shift(&mut pr.value);
for k in &mut pr.keys {
shift(&mut k.value);
}
}
}
fn ungroup_shapes(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, items) = selected_shape_items(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let groups: Vec<(LayerId, Uid)> = items
.into_iter()
.filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| g.match_id == "group"))
.collect();
if groups.is_empty() {
return Err(bad("path.ungroupShapes", "select a shape group to ungroup"));
}
let n = groups.len();
s.edit("Ungroup Shapes", None, |proj, st| {
let mut sel = vec![];
for (lid, uid) in &groups {
let l = layer_mut(proj, cid, *lid)?;
let Some(contents) = l.props.sub_mut("contents") else { continue };
let Some(parent) = contents.parent_of_mut(*uid) else { continue };
let Some(i) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
let Node::Group(g) = parent.children.remove(i) else { continue };
// The group's transform must be a plain (static) move: it is baked into the items.
let tr = g.sub("transform");
let get = |m: &str| tr.and_then(|t| t.get(m));
let animated = tr.is_some_and(|t| t.any_animated());
let v2 = |m: &str, d: [f64; 2]| get(m).map(|p| p.value.as_vec2()).unwrap_or(d);
let f = |m: &str, d: f64| get(m).map(|p| p.value.as_f64()).unwrap_or(d);
let (a, pos, sc) = (v2("anchor", [0.0; 2]), v2("position", [0.0; 2]), v2("scale", [100.0; 2]));
if animated || sc != [100.0, 100.0] || f("rotation", 0.0) != 0.0 || f("skew", 0.0) != 0.0 || f("opacity", 100.0) != 100.0 {
return Err(bad(
"path.ungroupShapes",
format!("`{}` has a scale, rotation, skew, opacity or animated transform: reset it before ungrouping", g.name),
));
}
let d = [pos[0] - a[0], pos[1] - a[1]];
let mut inner: Vec<Node> = g.sub("contents").map(|c| c.children.clone()).unwrap_or_default();
for c in &mut inner {
if let Node::Group(ig) = c {
if d != [0.0, 0.0] {
offset_items(ig, d);
}
sel.push((*lid, ig.uid));
}
}
for (k, c) in inner.into_iter().enumerate() {
parent.children.insert(i + k, c);
}
}
st.selected_props = sel;
Ok(())
})?;
Ok(json!({"ungrouped": n}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier),
cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier),
cmd!("path.groupShapes", "Group Shapes", ["Layer"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes),
cmd!("path.ungroupShapes", "Ungroup Shapes", ["Layer"], Some("Cmd+Shift+G"), "{layer?, items?: [group uid]}", has_shape_items, ungroup_shapes),
cmd!(
"path.setFirstVertex",
"Set First Vertex",
["Layer", "Mask and Shape Path"],
None,
"{layer?, mask?: uid, index?}",
has_one_vertex,
set_first_vertex
),
cmd!(
"path.freeTransform",
"Free Transform Points",
["Layer", "Mask and Shape Path"],
None,
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}",
has_layers,
free_transform
),
cmd!("layer.mask.motionBlur", "Motion Blur", [], None, "{layer?, mask?, mode: sameAsLayer|on|off}", has_layers, mask_motion_blur),
cmd!("layer.mask.featherFalloff", "Feather Falloff", [], None, "{layer?, mask?, mode: smooth|linear}", has_layers, feather_falloff),
cmd!("layer.mask.hideLocked", "Hide Locked Masks", ["Layer", "Mask"], None, "{value?}", has_comp, hide_locked),
]
}