Merge pull request #2 from storytold/puppet-rigging

Puppet tools: pin editing, rigging to nulls, follow-through; expression runtime fix
This commit is contained in:
BFlat 2026-10-04 00:26:24 +07:00 committed by GitHub
commit bb06a66083
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 972 additions and 77 deletions

View File

@ -317,3 +317,24 @@ fn run_script_round_trip() {
let (c, err) = call(&mut bare, "run_script", json!({"code": "1"}));
assert!(err && c[0]["text"].as_str().unwrap().contains("not available"), "{c:?}");
}
#[test]
fn puppet_rigging_over_mcp() {
let mut s = server();
let ex = |s: &mut McpServer, command: &str, params: Value| call_json(s, "execute_command", json!({"command": command, "params": params}));
ex(&mut s, "comp.new", json!({"name": "Rig", "width": 200, "height": 100, "duration": 2}));
let id = ex(&mut s, "layer.newSolid", json!({"width": 100, "height": 60, "color": "#3060c0"}))["layer"].clone();
let lead = ex(&mut s, "puppet.addPin", json!({"layer": id, "position": [10, 30]}))["pin"].clone();
let tail = ex(&mut s, "puppet.addPin", json!({"layer": id, "position": [90, 30]}))["pin"].clone();
let bend = ex(&mut s, "puppet.addPin", json!({"layer": id, "kind": "bend", "position": [50, 30]}))["pin"].clone();
// Turn and scale the Bend pin, select pins, make the tail trail the leader, rig the leader.
ex(&mut s, "puppet.setPin", json!({"layer": id, "pin": bend, "rotation": 45, "scale": 120}));
assert_eq!(ex(&mut s, "puppet.selectPins", json!({"layer": id, "pins": [lead, tail]}))["pins"], json!([lead, tail]));
assert_eq!(ex(&mut s, "puppet.follow", json!({"delay": 0.25}))["pins"], json!([{"pin": tail, "delay": 0.25}]));
let r = ex(&mut s, "paths.pointsFollowNulls", json!({"layer": id, "pins": [lead]}));
assert_eq!(r["nulls"].as_array().unwrap().len(), 1);
// The new commands are listed for agents.
let (c, _) = call(&mut s, "list_commands", json!({"filter": "puppet."}));
let text = c[0]["text"].as_str().unwrap();
assert!(text.contains("puppet.follow") && text.contains("puppet.selectPins"), "{text}");
}

View File

@ -56,6 +56,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
if s.state.text_edit.is_some() {
return s.execute("text.setSelection", json!({"select": "all"}));
}
// A puppet pin selected: every pin of that kind.
if let Some(r) = super::puppet::select_all_of_kind(s) {
return Ok(r);
}
if let Some(c) = s.active_comp() {
s.state.selected_layers = c.layers.iter().map(|l| l.id).collect();
}
@ -120,6 +124,10 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
return s.execute("mask.deleteVertices", json!({}));
}
// Puppet pins selected → delete the pins (not their layer).
if p.get("layers").is_none() && !super::puppet::selected_pins(s).is_empty() {
return s.execute("puppet.removePin", json!({}));
}
// Effects selected → remove them.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.remove", json!({}));

View File

@ -12,10 +12,17 @@
//!
//! The path is `path`/`prop` of `layer`, else the selected path property, else the layer's first
//! mask or shape path. Each command is one undo step.
//!
//! **Puppet pins** rig the same way (controllers drive the pins, or props ride along with a pin):
//! with `pins`, selected pins, or a layer that has Puppet pins but no path, Points Follow Nulls
//! makes a null per Position / Advanced pin and gives the pin's Position an expression following
//! it (`fromComp` of the null's anchor, so parented nulls rig limbs), and Nulls Follow Points makes
//! nulls whose Position follows each pin (`toComp` of its Position).
use effectcraft_effects::puppet::PinKind;
use effectcraft_geom::vec2;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemId, LayerId, Uid};
use effectcraft_project::{Comp, ItemId, Layer, LayerId, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, has_layers};
@ -85,13 +92,80 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
[q.x, q.y]
})
.collect();
// Seen from another layer (the nulls).
let reference = from_other_layer(comp, layer, uid).ok_or_else(|| bad(cmd, "can't reference that path"))?;
let prop_name = layer.props.name_path_of(uid).and_then(|n| n.split('/').rev().nth(1).map(str::to_string)).unwrap_or_else(|| pr.name.clone());
Ok(PathInfo { comp_pts, path, reference, layer_name: layer.name.clone(), prop_name, span: (layer.in_point.seconds(), layer.out_point.seconds()) })
}
/// An expression reference to `uid` of `layer` as another layer (the nulls) sees it.
fn from_other_layer(comp: &Comp, layer: &Layer, uid: Uid) -> Option<String> {
let mut other = layer.clone();
other.id = LayerId(u64::MAX);
other.name = "\u{0}".into();
let reference = super::link::reference(comp, &other, layer, uid, true).ok_or_else(|| bad(cmd, "can't reference that path"))?;
let prop_name = layer.props.name_path_of(uid).and_then(|n| n.split('/').rev().nth(1).map(str::to_string)).unwrap_or_else(|| pr.name.clone());
Ok(PathInfo { comp_pts, path, reference, layer_name: layer.name.clone(), prop_name, span: (layer.in_point.seconds(), layer.out_point.seconds()) })
super::link::reference(comp, &other, layer, uid, true)
}
/// A Puppet pin to rig: its layer, name, Position property, where it is now (comp space) and the
/// reference to its Position from another layer.
struct PinRig {
layer: LayerId,
layer_name: String,
name: String,
position: Uid,
comp_pt: [f64; 2],
reference: String,
}
/// The pins to rig, when the command targets Puppet pins: `pins` / `pin`, else the selected pins,
/// else (no path given) every pin of a layer that has pins but no mask or shape path. Bend,
/// Starch and Overlap pins have no Position and are skipped.
fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<PinRig>)>> {
if p.get("path").is_some() || p.get("prop").is_some() {
return Ok(None);
}
let named = p.get("pins").is_some() || p.get("pin").is_some();
let pins = if named || !super::puppet::selected_pins(s).is_empty() {
super::puppet::pins_p(s, p, cmd)?
} else {
if target(s, p, cmd).is_ok() {
return Ok(None);
}
let cid = comp_id(s, p)?;
let lid = match p.get("layer") {
Some(_) => super::layer_p(s, p, cmd)?.1,
None => match s.state.selected_layers.first() {
Some(l) => *l,
None => return Ok(None),
},
};
let all: Vec<_> =
s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| super::puppet::layer_pins(l).map(|g| (cid, lid, g.uid)).collect()).unwrap_or_default();
if all.is_empty() {
return Ok(None);
}
all
};
let cid = pins.first().map(|x| x.0).ok_or_else(|| bad(cmd, "no puppet pins"))?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let mut out = vec![];
for (_, lid, uid) in pins {
let Some(layer) = comp.layer(lid) else { continue };
let Some(g) = layer.props.find_group(uid) else { continue };
if !PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0)).moves() {
continue;
}
let Some(pos) = g.get("position") else { continue };
let v = ctx.value(layer, pos).as_vec2();
let q = ctx.layer_to_comp(layer).0.apply(vec2(v[0], v[1]));
let reference = from_other_layer(comp, layer, pos.uid).ok_or_else(|| bad(cmd, "can't reference that pin"))?;
out.push(PinRig { layer: lid, layer_name: layer.name.clone(), name: g.name.clone(), position: pos.uid, comp_pt: [q.x, q.y], reference });
}
if out.is_empty() {
return Err(bad(cmd, "no Position or Advanced puppet pins to rig (Bend, Starch and Overlap pins have no Position)"));
}
Ok(Some((cid, out)))
}
/// A new null at `at` (comp space) named `name`, made unique in the comp (expressions find
@ -113,6 +187,19 @@ fn new_null(s: &mut Session, cid: ItemId, name: &str, at: [f64; 2]) -> Result<(u
fn nulls_follow_points(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paths.nullsFollowPoints";
if let Some((cid, pins)) = pin_targets(s, p, C)? {
let nulls = super::app_more::grouped(s, "Nulls Follow Points", |s| {
let mut out = vec![];
for pin in &pins {
let (id, _) = new_null(s, cid, &format!("{}: {}", pin.layer_name, pin.name), pin.comp_pt)?;
let e = format!("var src = thisComp.layer({});\nvar p = src.toComp({});\n[p[0], p[1]]", quote(&pin.layer_name), pin.reference);
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?;
out.push(id);
}
Ok(out)
})?;
return Ok(json!({"nulls": nulls, "pins": pins.iter().map(|p| p.position).collect::<Vec<_>>()}));
}
let (cid, lid, uid) = target(s, p, C)?;
let pi = info(s, cid, lid, uid, C)?;
let nulls = super::app_more::grouped(s, "Nulls Follow Points", |s| {
@ -130,6 +217,19 @@ fn nulls_follow_points(s: &mut Session, p: &Value) -> Result<Value> {
fn points_follow_nulls(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paths.pointsFollowNulls";
if let Some((cid, pins)) = pin_targets(s, p, C)? {
let nulls = super::app_more::grouped(s, "Points Follow Nulls", |s| {
let mut out = vec![];
for pin in &pins {
let (id, name) = new_null(s, cid, &format!("{}: {}", pin.layer_name, pin.name), pin.comp_pt)?;
let e = format!("var n = thisComp.layer({});\nvar q = fromComp(n.toComp(n.transform.anchorPoint));\n[q[0], q[1]]", quote(&name));
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": pin.layer.0, "prop": pin.position, "expression": e}))?;
out.push(id);
}
Ok(out)
})?;
return Ok(json!({"nulls": nulls, "pins": pins.iter().map(|p| p.position).collect::<Vec<_>>()}));
}
let (cid, lid, uid) = target(s, p, C)?;
let pi = info(s, cid, lid, uid, C)?;
let nulls = super::app_more::grouped(s, "Points Follow Nulls", |s| {
@ -191,7 +291,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Points Follow Nulls",
[],
None,
"{layer?, path?|prop?} → a null per vertex; the path follows them (expression)",
"{layer?, path?|prop?, pins?: [uid | name…]} → a null per vertex (or Position / Advanced puppet pin); the path (pins) follow them (expressions)",
has_layers,
points_follow_nulls
),
@ -200,7 +300,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Nulls Follow Points",
[],
None,
"{layer?, path?|prop?} → a null per vertex following it (Position expressions)",
"{layer?, path?|prop?, pins?: [uid | name…]} → a null per vertex (or puppet pin) following it (Position expressions)",
has_layers,
nulls_follow_points
),

View File

@ -1,6 +1,10 @@
//! Puppet tools: pins on the layer's Puppet effect (Position, Advanced, Bend, Starch and
//! Overlap pins), mesh options, and a query of the current mesh for overlays and agents.
//!
//! Pins are selected like properties (`puppet.selectPins`; Edit ▸ Clear / Delete removes the
//! selected pins, not their layer). Follow-Through (`puppet.follow`) makes pins trail a leader pin
//! with a delay, for hair, cloth and overlapping action.
//!
//! Pin positions are layer space. A new pin is attached to the mesh under it: the click is
//! mapped back through the current deformation to the rest mesh (its hidden rest position), and
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects.
@ -9,7 +13,7 @@ use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
use effectcraft_effects::{Buf, Params};
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::Ids;
use effectcraft_project::{ItemId, LayerId, Node, PropGroup, Uid};
use effectcraft_project::{ItemId, Layer, LayerId, Node, PropGroup, Uid};
use effectcraft_render::{EvalCtx, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -162,25 +166,102 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"effect": fx, "mesh": mesh, "pin": pin, "rest": [target.map(|x| x.1).unwrap_or(at)[0], target.map(|x| x.1).unwrap_or(at)[1]]}))
}
/// The pin groups of a layer's Puppet effects, in stack order.
pub(crate) fn layer_pins(l: &Layer) -> impl Iterator<Item = &PropGroup> {
l.effects().into_iter().flat_map(|fx| fx.groups().filter(|g| puppet::is_puppet(g))).flat_map(puppet::meshes).flat_map(puppet::pins)
}
/// Selected puppet pins of the active comp: (layer, pin uid).
pub(crate) fn selected_pins(s: &Session) -> Vec<(LayerId, Uid)> {
let Some(c) = s.active_comp() else { return vec![] };
s.state.selected_props.iter().filter(|(l, u)| c.layer(*l).is_some_and(|l| layer_pins(l).any(|g| g.uid == *u))).copied().collect()
}
/// Edit ▸ Select All with puppet pins selected: every pin of the selected pins' kinds on their
/// layers. `None` when no pin is selected (Select All then selects layers).
pub(crate) fn select_all_of_kind(s: &mut Session) -> Option<Value> {
let sel = selected_pins(s);
let comp = s.active_comp()?;
let kind = |g: &PropGroup| PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0));
let mut kinds = vec![];
for (l, u) in &sel {
if let Some(g) = comp.layer(*l).and_then(|l| l.props.find_group(*u)) {
kinds.push((*l, kind(g)));
}
}
if kinds.is_empty() {
return None;
}
let all: Vec<(LayerId, Uid)> =
comp.layers.iter().flat_map(|l| layer_pins(l).filter(|g| kinds.contains(&(l.id, kind(g)))).map(|g| (l.id, g.uid)).collect::<Vec<_>>()).collect();
s.state.selected_props = all.clone();
Some(json!({ "pins": all.iter().map(|(_, u)| *u).collect::<Vec<_>>() }))
}
/// Find a pin group (uid or name) in the layer's Puppet effects.
fn pin_uid(s: &Session, cid: ItemId, lid: LayerId, key: &Value, cmd: &str) -> Result<Uid> {
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let fx = l.effects().ok_or_else(|| bad(cmd, "no effects"))?;
for g in fx.groups().filter(|g| puppet::is_puppet(g)) {
for m in puppet::meshes(g) {
for pin in puppet::pins(m) {
let hit = match key {
Value::Number(n) => n.as_u64() == Some(pin.uid),
Value::String(name) => &pin.name == name,
_ => false,
};
if hit {
return Ok(pin.uid);
}
let hit = |pin: &&PropGroup| match key {
Value::Number(n) => n.as_u64() == Some(pin.uid),
Value::String(name) => &pin.name == name,
_ => false,
};
layer_pins(l).find(hit).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no puppet pin {key}")))
}
/// Pins named by `pin` or `pins` (uids or names) on `layer`, else the selected pins (of `layer`
/// when given): (comp, layer, pin uid).
pub(crate) fn pins_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(ItemId, LayerId, Uid)>> {
let keys: Vec<Value> = match (p.get("pin"), p.get("pins")) {
(Some(k), _) => vec![k.clone()],
(None, Some(Value::Array(a))) => a.clone(),
(None, Some(_)) => return Err(bad(cmd, "`pins` must be an array of pin uids or names")),
(None, None) => vec![],
};
if !keys.is_empty() {
let (cid, lid) = layer_p(s, p, cmd)?;
return keys.iter().map(|k| pin_uid(s, cid, lid, k, cmd).map(|u| (cid, lid, u))).collect();
}
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let only = match p.get("layer") {
Some(_) => Some(layer_p(s, p, cmd)?.1),
None => None,
};
let sel: Vec<_> = selected_pins(s).into_iter().filter(|(l, _)| only.is_none_or(|o| o == *l)).map(|(l, u)| (cid, l, u)).collect();
if sel.is_empty() {
return Err(bad(cmd, "missing `pin` / `pins` (uids or names), and no puppet pins are selected"));
}
Ok(sel)
}
/// Select pins (the viewer's click / Shift-click on a pin). Plain: only these pins; `add`: add
/// them; `toggle`: flip each. An empty `pins` deselects every pin.
fn select_pins(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.selectPins";
let want = match p.get("pins") {
Some(Value::Array(a)) if a.is_empty() => vec![],
_ => pins_p(s, p, cmd)?,
};
let (add, toggle) = (super::b_p(p, "add").unwrap_or(false), super::b_p(p, "toggle").unwrap_or(false));
let st = &mut s.state;
if !add && !toggle {
st.selected_props.clear();
st.selected_keys.clear();
}
for (_, l, u) in want {
let at = st.selected_props.iter().position(|x| *x == (l, u));
match at {
Some(i) if toggle => {
st.selected_props.remove(i);
}
Some(_) => {}
None => st.selected_props.push((l, u)),
}
if !st.selected_layers.contains(&l) {
st.selected_layers.push(l);
}
}
Err(bad(cmd, format!("no puppet pin {key}")))
Ok(json!({ "pins": selected_pins(s).iter().map(|(_, u)| *u).collect::<Vec<_>>() }))
}
fn with_pin<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut PropGroup, Tick) -> Result<T>) -> Result<T> {
@ -237,19 +318,19 @@ fn set_pin(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.removePin";
let (cid, lid) = layer_p(s, p, cmd)?;
let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?;
let uid = pin_uid(s, cid, lid, &key, cmd)?;
s.edit("Delete Puppet Pin", None, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
if let Some(parent) = l.props.parent_of_mut(uid) {
parent.children.retain(|c| c.uid() != uid);
let pins = pins_p(s, p, "puppet.removePin")?;
let label = if pins.len() == 1 { "Delete Puppet Pin" } else { "Delete Puppet Pins" };
s.edit(label, None, |proj, st| {
for (cid, lid, uid) in &pins {
let l = layer_mut(proj, *cid, *lid)?;
if let Some(parent) = l.props.parent_of_mut(*uid) {
parent.children.retain(|c| c.uid() != *uid);
}
st.selected_props.retain(|(_, u)| u != uid);
}
st.selected_props.retain(|(_, u)| *u != uid);
Ok(())
})?;
Ok(Value::Null)
Ok(json!({ "removed": pins.len() }))
}
fn mesh_opts(s: &mut Session, p: &Value) -> Result<Value> {
@ -341,6 +422,43 @@ fn record_pin(s: &mut Session, p: &Value) -> Result<Value> {
let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?;
let uid = pin_uid(s, cid, lid, &key, cmd)?;
let rec = samples(p, cmd)?;
// Several selected pins dragged together (`pins`): each follows the drag's displacement from
// where it is when recording begins; one undo step.
if let Some(Value::Array(others)) = p.get("pins")
&& !others.is_empty()
{
let start = match p.get("start").and_then(Value::as_f64) {
Some(t) => Tick::from_seconds_f64(t),
None => s.time_of(cid),
};
let lt = layer_time(s, cid, lid, start)?;
let mut pins = vec![(uid, rec[0].1)];
for k in others {
let u = pin_uid(s, cid, lid, k, cmd)?;
if pins.iter().any(|(x, _)| *x == u) {
continue;
}
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let at = l.props.find_group(u).and_then(|g| g.get("position")).map(|pr| pr.value_at(lt).as_vec2());
pins.push((u, at.ok_or_else(|| bad(cmd, "only Position and Advanced pins record motion"))?));
}
let o = rec[0].1;
return super::app_more::grouped(s, "Record Puppet Pins", |s| {
let mut last = Value::Null;
for (u, base) in &pins {
let moved: Vec<Value> = rec.iter().map(|(t, q)| json!([t, base[0] + q[0] - o[0], base[1] + q[1] - o[1]])).collect();
let mut one = p.clone();
if let Some(m) = one.as_object_mut() {
m.remove("pins");
m.insert("pin".into(), json!(u));
m.insert("samples".into(), Value::Array(moved));
}
last = s.execute(cmd, one)?;
}
last["pins"] = json!(pins.iter().map(|(u, _)| *u).collect::<Vec<_>>());
Ok(last)
});
}
let opts = &s.state.puppet;
let speed = f_p(p, "speed").unwrap_or(opts.record_speed).clamp(1.0, 10_000.0) / 100.0;
// Smoothing 10 (the default) keeps the path within 1 layer pixel of the drag.
@ -406,6 +524,68 @@ fn record_pin(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"keys": n, "start": start.seconds(), "end": end.seconds(), "frames": nframes + 1}))
}
/// Follow-Through (hair, cloth, tails, overlapping action): each follower pin trails the leader
/// pin's motion by a delay, through an expression on its Position. The followers nearest the
/// leader trail least; with `cascade` the k-th nearest trails k × `delay`, otherwise all trail by
/// `delay`. `amount` (%) scales the motion they inherit; their own keyframes still add on top.
/// The leader is `leader`, else the first selected pin; the followers are `pins`, else the other
/// selected pins of the leader's layer. One undo step.
fn follow(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.follow";
let (cid, lid, leader, mut followers) = match p.get("leader") {
Some(k) => {
let (cid, lid) = layer_p(s, p, cmd)?;
let leader = pin_uid(s, cid, lid, k, cmd)?;
let rest: Vec<Uid> = pins_p(s, p, cmd)?.into_iter().filter(|(_, l, u)| *l == lid && *u != leader).map(|x| x.2).collect();
(cid, lid, leader, rest)
}
None => {
let sel = pins_p(s, p, cmd)?;
let (cid, lid, leader) = sel[0];
(cid, lid, leader, sel.iter().skip(1).filter(|(_, l, _)| *l == lid).map(|x| x.2).collect())
}
};
followers.dedup();
if followers.is_empty() {
return Err(bad(cmd, "select the leader pin first, then the pins that follow it (or pass `leader` and `pins`)"));
}
let delay = f_p(p, "delay").unwrap_or(0.1).clamp(0.0, 10.0);
let amount = f_p(p, "amount").unwrap_or(100.0) / 100.0;
let cascade = super::b_p(p, "cascade").unwrap_or(true);
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
// (Position uid, rest) of a pin that moves.
let moving = |uid: Uid| -> Result<(Uid, [f64; 2], String)> {
let g = layer.props.find_group(uid).ok_or_else(|| bad(cmd, "pin gone"))?;
if !PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0)).moves() {
return Err(bad(cmd, format!("`{}` has no Position (only Position and Advanced pins follow or lead)", g.name)));
}
let pos = g.get("position").ok_or_else(|| bad(cmd, "the pin has no Position"))?;
Ok((pos.uid, g.get("rest").map(|r| r.value.as_vec2()).unwrap_or([0.0; 2]), g.name.clone()))
};
let (lead_pos, lead_rest, lead_name) = moving(leader)?;
let reference = super::link::reference(comp, layer, layer, lead_pos, true).ok_or_else(|| bad(cmd, "can't reference the leader pin"))?;
let mut rigs = followers.iter().map(|u| moving(*u).map(|(pos, rest, _)| (*u, pos, rest))).collect::<Result<Vec<_>>>()?;
let dist = |r: [f64; 2]| (r[0] - lead_rest[0]).hypot(r[1] - lead_rest[1]);
rigs.sort_by(|a, b| dist(a.2).total_cmp(&dist(b.2)));
let out = super::app_more::grouped(s, "Follow-Through", |s| {
let mut out = vec![];
for (k, (pin, pos, _)) in rigs.iter().enumerate() {
let d = if cascade { delay * (k + 1) as f64 } else { delay };
let e = format!(
"// Follow-Through: trails {} by {d} s\nvar lead = {reference};\nvalue + (lead.valueAtTime(time - {d}) - [{}, {}]) * {amount}",
serde_json::to_string(&lead_name).unwrap_or_default(),
lead_rest[0],
lead_rest[1]
);
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": lid.0, "prop": pos, "expression": e}))?;
out.push(json!({"pin": pin, "delay": d}));
}
Ok(out)
})?;
Ok(json!({"leader": leader, "pins": out}))
}
/// Record Options (the Puppet tool's Record Options… dialog): `speed`, `smoothing`,
/// `useDraftDeformation`, `showMesh`. Returns the options.
fn record_options(s: &mut Session, p: &Value) -> Result<Value> {
@ -469,7 +649,16 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
set_pin
),
cmd!("puppet.removePin", "Delete Puppet Pin", [], None, "{layer?, pin: uid | name}", has_comp, remove_pin),
cmd!(
"puppet.removePin",
"Delete Puppet Pin",
[],
None,
"{layer?, pin?: uid | name, pins?: [uid | name…]} (default: the selected pins)",
has_comp,
remove_pin
),
cmd!("puppet.selectPins", "Select Puppet Pins", [], None, "{layer?, pins: [uid | name…] ([] deselects), add?, toggle?}", has_comp, select_pins),
cmd!(
"puppet.mesh",
"Puppet Mesh Options",
@ -485,10 +674,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Record Puppet Pin",
[],
None,
"{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, layer space), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}",
"{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, layer space), pins?: [uid | name…] (more pins that move by the same displacement), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}",
has_comp,
record_pin
),
cmd!(
"puppet.follow",
"Follow-Through...",
[],
None,
"{layer?, leader?: uid | name (default: the first selected pin), pins?: [uid | name…] (default: the other selected pins), delay? (s, 0.1), amount? (%, 100), cascade? (true: the k-th nearest pin trails k × delay)}",
has_comp,
follow
),
cmd!(
"puppet.recordOptions",
"Record Options...",

View File

@ -216,6 +216,127 @@ fn puppet_pin_kinds_and_mesh_options() {
assert_eq!(pin_count(&s, id), 4);
}
#[test]
fn puppet_pins_select_and_delete_without_touching_the_layer() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
let b = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap()["pin"].clone();
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30]})).unwrap();
// A new pin is selected: Delete removes it, and the layer stays.
assert_eq!(s.execute("edit.clear", json!({})).unwrap(), json!({"removed": 1}));
assert_eq!(pin_count(&s, id), 2);
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).is_some());
// Click, Shift-click (toggle) and add by name.
let sel = |s: &mut Session, q: Value| s.execute("puppet.selectPins", q).unwrap()["pins"].clone();
assert_eq!(sel(&mut s, json!({"layer": id, "pins": [a, b]})), json!([a, b]));
assert_eq!(sel(&mut s, json!({"layer": id, "pins": [b], "toggle": true})), json!([a]));
assert_eq!(sel(&mut s, json!({"layer": id, "pins": ["Puppet Pin 2"], "add": true})), json!([a, b]));
assert!(s.execute("puppet.selectPins", json!({"layer": id, "pins": ["Puppet Pin 9"]})).is_err());
// Deleting both is one undo step.
assert_eq!(s.execute("edit.clear", json!({})).unwrap(), json!({"removed": 2}));
assert_eq!(pin_count(&s, id), 0);
s.undo();
assert_eq!(pin_count(&s, id), 2);
// Nothing selected: removing needs a pin.
assert_eq!(sel(&mut s, json!({"layer": id, "pins": []})), json!([]));
assert!(s.execute("puppet.removePin", json!({"layer": id})).is_err());
}
#[test]
fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
let b = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap()["pin"].clone();
let st = s.execute("puppet.addPin", json!({"layer": id, "kind": "starch", "position": [50, 30]})).unwrap()["pin"].clone();
// Ctrl+A with a Position pin selected: every Position pin, not the Starch pin.
s.execute("puppet.selectPins", json!({"layer": id, "pins": [a]})).unwrap();
assert_eq!(s.execute("edit.selectAll", json!({})).unwrap()["pins"], json!([a, b]));
s.execute("puppet.selectPins", json!({"layer": id, "pins": [st]})).unwrap();
assert_eq!(s.execute("edit.selectAll", json!({})).unwrap()["pins"], json!([st]));
// Without pins selected, Select All selects layers as before.
s.execute("puppet.selectPins", json!({"layer": id, "pins": []})).unwrap();
assert_eq!(s.execute("edit.selectAll", json!({})).unwrap(), json!(1));
// Recording a drag of pin A with B selected too: B moves by the same displacement.
let steps = s.history.undo.len();
let samples: Vec<Value> = [0.0, 0.25, 0.5].iter().map(|t| json!([t, 10.0, 30.0 + 40.0 * t])).collect();
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [b], "samples": samples, "smoothing": 0})).unwrap();
assert_eq!(r["pins"], json!([a, b]));
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
let last = |s: &Session, pin: &Value| pin_keys(s, id, pin.as_u64().unwrap()).last().unwrap().1;
assert_eq!(last(&s, &a), [10.0, 50.0]);
assert_eq!(last(&s, &b), [90.0, 50.0]);
// Starch pins don't record.
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err());
}
fn expr_of(s: &Session, layer: u64, uid: u64) -> String {
let l = layer_of(s, layer);
l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default()
}
fn layer_of(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
}
#[test]
fn puppet_pins_rig_to_nulls_both_ways() {
let (mut s, id) = setup();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
let bend = s.execute("puppet.addPin", json!({"layer": id, "kind": "bend", "position": [50, 30]})).unwrap()["pin"].clone();
// Only a Bend pin selected: nothing to rig.
assert!(s.execute("paths.pointsFollowNulls", json!({})).is_err());
// No pin selected and no path: every Position pin of the layer gets a null on it, and the
// pin's Position follows the null; one undo step.
s.execute("puppet.selectPins", json!({"layer": id, "pins": []})).unwrap();
let steps = s.history.undo.len();
let r = s.execute("paths.pointsFollowNulls", json!({"layer": id})).unwrap();
assert_eq!(s.history.undo.len(), steps + 1);
let nulls: Vec<u64> = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
let pins: Vec<u64> = r["pins"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!((nulls.len(), pins.len()), (2, 2));
let solid = layer_of(&s, id).name;
let null = layer_of(&s, nulls[0]);
assert_eq!(null.name, format!("{solid}: Puppet Pin 1"));
// Layer (10, 30) is comp (60, 50).
assert_eq!(null.props.prop("transform/position").unwrap().value.components()[..2], [60.0, 50.0]);
let e = expr_of(&s, id, pins[0]);
assert!(e.contains(&format!("thisComp.layer(\"{solid}: Puppet Pin 1\")")) && e.contains("fromComp(n.toComp(n.transform.anchorPoint))"), "{e}");
// Nulls Follow Points: a null riding on a pin, through the pin's Position.
let r = s.execute("paths.nullsFollowPoints", json!({"layer": id, "pins": ["Puppet Pin 2"]})).unwrap();
let rider = layer_of(&s, r["nulls"][0].as_u64().unwrap());
let e = rider.props.prop("transform/position").unwrap().expr.as_ref().unwrap().text.clone();
assert!(e.contains(&format!("thisComp.layer(\"{solid}\")")) && e.contains("(\"Puppet Pin 2\")(\"Position\")") && e.contains("toComp"), "{e}");
assert!(s.execute("paths.nullsFollowPoints", json!({"layer": id, "pins": [bend]})).is_err());
// Trace Path still needs a path.
assert!(s.execute("paths.tracePath", json!({"layer": id})).is_err());
}
#[test]
fn puppet_follow_through_trails_the_leader_by_cascading_delays() {
let (mut s, id) = setup();
let lead = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
let far = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap()["pin"].clone();
let near = s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30]})).unwrap()["pin"].clone();
let pos_uid = |s: &Session, pin: &Value| layer_of(s, id).props.find_group(pin.as_u64().unwrap()).unwrap().get("position").unwrap().uid;
// Leader first, then the followers (in any order): nearer pins trail less.
s.execute("puppet.selectPins", json!({"layer": id, "pins": [lead, far, near]})).unwrap();
let steps = s.history.undo.len();
let r = s.execute("puppet.follow", json!({"delay": 0.2})).unwrap();
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
assert_eq!(r["leader"], lead);
assert_eq!(r["pins"], json!([{"pin": near, "delay": 0.2}, {"pin": far, "delay": 0.4}]));
let e = expr_of(&s, id, pos_uid(&s, &far));
assert!(e.contains("(\"Puppet Pin 1\")(\"Position\")") && e.contains("valueAtTime(time - 0.4)") && e.contains("- [10, 30]) * 1"), "{e}");
// Without cascade every follower trails by the same delay; `amount` scales the motion.
s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": [far, near], "delay": 0.25, "amount": 50, "cascade": false})).unwrap();
assert!(expr_of(&s, id, pos_uid(&s, &far)).contains("valueAtTime(time - 0.25)") && expr_of(&s, id, pos_uid(&s, &far)).contains("* 0.5"));
// Errors: no followers, or a pin without a Position.
assert!(s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": [lead]})).is_err());
let bend = s.execute("puppet.addPin", json!({"layer": id, "kind": "bend", "position": [30, 30]})).unwrap()["pin"].clone();
assert!(s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": [bend]})).is_err());
}
#[test]
fn project_with_paint_and_puppet_round_trips() {
let (mut s, id) = setup();

View File

@ -16,6 +16,8 @@ const PRELUDE: &str = include_str!("prelude.js");
/// Message of the error thrown when a request isn't answered yet (never shown to users).
const PENDING: &str = "\u{1}effectcraft: pending host request";
const MAX_SCRIPTS: usize = 4096;
/// The part of boa's stack-size limit error the runtime recovers from (see [`run`]).
const STACK_LIMIT: &str = "maximum stack size";
/// Memoized host answers and pending misses of one expression evaluation.
#[derive(Default)]
@ -392,13 +394,32 @@ fn position_line(msg: &str) -> Option<usize> {
}
/// Run `text` once on this thread's runtime.
///
/// boa leaves values on its VM stack when an exception unwinds through call frames, and every
/// pending host request does that (the request throws out of the object-model functions), so a
/// long-lived context eventually fails every run with "maximum stack size". When a run hits that
/// limit the context is replaced with a fresh one and the run retried once: each run starts from
/// `__begin` with memoized answers, so nothing carries over. A script that really overflows the
/// stack fails again on the fresh context and reports the error.
pub fn run(text: &str, b: &Begin) -> Run {
RUNTIME.with(|r| {
let mut r = r.borrow_mut();
match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) {
Ok(rt) => rt.run(text, b),
Err(e) => Run::Done(Err(e.clone())),
for fresh in [false, true] {
let out = match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) {
Ok(rt) => rt.run(text, b),
Err(e) => return Run::Done(Err(e.clone())),
};
match out {
Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => {
// Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit).
if let Some(old) = r.take() {
drop(ManuallyDrop::into_inner(old));
}
}
out => return out,
}
}
Run::Done(Err(format!("Error: {STACK_LIMIT}")))
})
}

View File

@ -1131,3 +1131,16 @@ fn project_and_time_globals() {
assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0);
assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov");
}
#[test]
fn thousands_of_host_reads_on_one_thread_keep_working() {
// Every pending host request unwinds through call frames, which leaks boa stack slots; the
// runtime replaces a context that fills up instead of failing every later expression.
let f = fx();
for i in 0..2000 {
let t = (i % 20) as f64 * 0.05;
assert_eq!(f.num("effect(\"Slider Control\")(\"Slider\")", t), 42.0, "evaluation {i}");
assert!(close(f.num("thisComp.layer(\"A\").transform.rotation.valueAtTime(1)", t), 90.0), "evaluation {i}");
assert_eq!(f.num("function a() { return effect(1)(1); } function b() { return a(); } b() + 1", t), 43.0, "evaluation {i}");
}
}

View File

@ -211,6 +211,75 @@ fn create_nulls_from_paths_expressions_evaluate() {
assert_eq!(o.result, json!("-20,-10,0,4"), "{:?}", o.error);
}
fn pin_at(s: &mut Session, id: u64, i: usize) -> [f64; 2] {
let info = s.execute("puppet.info", json!({"layer": id})).unwrap();
let v = &info["meshes"][0]["pins"][i]["position"];
[v[0].as_f64().unwrap(), v[1].as_f64().unwrap()]
}
/// A layer's Position now, with its expression applied (what renders).
fn position_now(s: &Session, layer: u64) -> [f64; 2] {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components();
[v[0], v[1]]
}
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6
}
#[test]
fn puppet_pin_rigs_follow_and_lead_with_live_expressions() {
let mut s = session();
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 100, "duration": 2, "frameRate": 30})).unwrap();
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
s.execute("puppet.selectPins", json!({"layer": id, "pins": []})).unwrap();
let r = s.execute("paths.pointsFollowNulls", json!({"layer": id})).unwrap();
let nulls: Vec<u64> = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
// Rigged at rest: the pins haven't moved. Layer (10, 30) is comp (60, 50).
assert!(near(pin_at(&mut s, id, 0), [10.0, 30.0]), "{:?}", pin_at(&mut s, id, 0));
// Moving the null moves its pin.
s.execute("prop.set", json!({"layer": nulls[0], "path": "transform/position", "value": [60, 80]})).unwrap();
assert!(near(pin_at(&mut s, id, 0), [10.0, 60.0]), "{:?}", pin_at(&mut s, id, 0));
// Parented nulls rig a limb: pin 2's null rides on pin 1's null.
s.execute("layer.setParent", json!({"layers": [nulls[1]], "parent": nulls[0]})).unwrap();
s.execute("prop.set", json!({"layer": nulls[0], "path": "transform/position", "value": [60, 90]})).unwrap();
assert!(near(pin_at(&mut s, id, 1), [90.0, 40.0]), "{:?}", pin_at(&mut s, id, 1));
// Nulls Follow Points: a null riding on pin 1, now at comp (60, 90).
let r = s.execute("paths.nullsFollowPoints", json!({"layer": id, "pins": ["Puppet Pin 1"]})).unwrap();
let rider = r["nulls"][0].as_u64().unwrap();
assert!(near(position_now(&s, rider), [60.0, 90.0]), "{:?}", position_now(&s, rider));
}
#[test]
fn puppet_follow_through_delays_the_leaders_motion() {
let mut s = session();
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 100, "duration": 2, "frameRate": 30})).unwrap();
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
let lead = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
// The leader drops 20 px between 0 and 0.5 s.
s.execute("puppet.movePin", json!({"layer": id, "pin": lead, "position": [10, 50], "time": 0.5})).unwrap();
s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": ["Puppet Pin 2", "Puppet Pin 3"], "delay": 0.2})).unwrap();
let y = |s: &mut Session, pin: usize, t: f64| {
let info = s.execute("puppet.info", json!({"layer": id, "time": t})).unwrap();
info["meshes"][0]["pins"][pin]["position"][1].as_f64().unwrap()
};
// At rest before anything moves; pin 2 trails 0.2 s and pin 3 (farther) 0.4 s.
assert_eq!((y(&mut s, 1, 0.0), y(&mut s, 2, 0.0)), (30.0, 30.0));
let (lead_y, p2, p3) = (y(&mut s, 0, 0.5), y(&mut s, 1, 0.5), y(&mut s, 2, 0.5));
assert!(lead_y > p2 && p2 > p3 && p3 > 30.0, "{lead_y} {p2} {p3}");
assert!((y(&mut s, 1, 0.7) - 50.0).abs() < 1e-6, "pin 2 arrives 0.2 s after the leader");
assert!(y(&mut s, 2, 0.7) < 50.0 - 1e-3);
assert!((y(&mut s, 2, 0.9) - 50.0).abs() < 1e-6, "pin 3 arrives 0.4 s after the leader");
}
#[test]
fn sockets_talk_tcp_behind_the_network_preference() {
use std::io::{BufRead, BufReader, Write};

View File

@ -237,6 +237,21 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
app.ui.status = e;
}
}
// Follow-Through… (select the leader pin, then Shift-click the pins that trail it).
let label = "Follow-Through...";
let r = Rect::from_min_size(pos2(r.max.x + 8.0, cy - 10.0), vec2(w, 20.0));
let resp = ui
.interact(r, egui::Id::new("puppet-follow"), egui::Sense::click())
.on_hover_text("Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)");
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
app.auto.add("header.puppet.follow", r, label);
if resp.clicked() {
let ctx = ui.ctx().clone();
if let Err(e) = crate::menus::invoke(app, &ctx, "puppet.follow", json!({})) {
app.ui.status = e;
}
}
r.max.x + 16.0
}

View File

@ -34,7 +34,12 @@ pub enum Icon {
Eraser,
RotoBrush,
RefineEdge,
/// Puppet Position Pin: a pushpin. The other pin tools add a badge at the lower right.
Puppet,
PuppetAdvanced,
PuppetBend,
PuppetStarch,
PuppetOverlap,
// switches & timeline
Eye,
Speaker,
@ -163,6 +168,12 @@ impl Pen16<'_> {
.collect();
self.line(&pts);
}
/// An upright pushpin centred on `cx`: a cap, a body flaring to its base and a needle.
fn pushpin(&self, cx: f32) {
self.fill(&[(cx - 3.0, 1.5), (cx + 3.0, 1.5), (cx + 2.0, 3.0), (cx - 2.0, 3.0)]);
self.closed(&[(cx - 2.0, 3.0), (cx + 2.0, 3.0), (cx + 2.4, 7.0), (cx + 4.0, 8.8), (cx - 4.0, 8.8), (cx - 2.4, 7.0)]);
self.line(&[(cx, 8.8), (cx, 14.8)]);
}
fn ellipse(&self, cx: f32, cy: f32, rx: f32, ry: f32) {
let n = 32;
let pts: Vec<(f32, f32)> = (0..n)
@ -294,11 +305,31 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.line(&[(8.0, 14.0), (9.5, 12.5)]);
pen.line(&[(11.0, 14.0), (12.5, 12.5)]);
}
Puppet => {
pen.circle(8.0, 3.6, 1.6);
pen.line(&[(8.0, 5.2), (8.0, 10.0)]);
pen.line(&[(3.5, 7.0), (8.0, 6.5), (12.5, 7.0)]);
pen.line(&[(5.5, 14.0), (8.0, 10.0), (10.5, 14.0)]);
Puppet => pen.pushpin(8.0),
PuppetAdvanced => {
// Rotation ring with its square scale handle.
pen.pushpin(6.5);
pen.circle(12.0, 12.0, 2.4);
pen.rect_fill(13.6, 11.2, 15.2, 12.8);
}
PuppetBend => {
// A curved arrow.
pen.pushpin(6.5);
pen.arc(11.8, 13.2, 3.0, 200.0, 340.0);
pen.fill(&[(14.9, 11.0), (15.4, 13.8), (12.9, 12.6)]);
}
PuppetStarch => {
// Stiff bars.
pen.pushpin(6.5);
for y in [10.5, 12.75, 15.0] {
pen.line(&[(10.5, y), (15.0, y)]);
}
}
PuppetOverlap => {
// Two overlapping sheets.
pen.pushpin(6.5);
pen.rect(10.0, 9.8, 13.4, 13.2);
pen.rect_fill(11.8, 11.6, 15.2, 15.0);
}
Eye => {
pen.line(&[(1.5, 8.0), (4.0, 5.0), (8.0, 3.8), (12.0, 5.0), (14.5, 8.0), (12.0, 11.0), (8.0, 12.2), (4.0, 11.0), (1.5, 8.0)]);

View File

@ -154,6 +154,15 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
Field::num("page", "Page", 1.0),
],
),
// Puppet tool ▸ Follow-Through.
"puppet.follow" if !has(p, &["delay", "amount", "cascade"]) => (
"Puppet Follow-Through".into(),
vec![
Field::num("delay", "Delay (s)", 0.1),
Field::num("amount", "Amount (%)", 100.0),
Field::bool("cascade", "Cascade (farther pins trail longer)", true),
],
),
// Puppet tool ▸ Record Options.
"puppet.recordOptions" if !has(p, &["speed", "smoothing", "useDraftDeformation", "showMesh"]) => {
let o = &s.state.puppet;

View File

@ -2,7 +2,8 @@
//!
//! - **Create Nulls From Paths** (Window ▸ Create Nulls From Paths): with a mask or shape path (or
//! a layer that has one) selected, Points Follow Nulls / Nulls Follow Points / Trace Path
//! (`paths.*` commands; Trace Path can loop).
//! (`paths.*` commands; Trace Path can loop). Puppet pins (selected, or a layer that has pins)
//! rig the same way with the first two.
//! - **VR Comp Editor** (Window ▸ VR Comp Editor): the project's VR environments (Composition ▸ VR
//! ▸ Create VR Environment); pick one and edit its 360 view's camera orientation (pan, tilt,
//! roll) with `comp.vr.setView`; open its output.
@ -37,7 +38,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let loop_id = egui::Id::new("createNulls.loop");
let mut looping: bool = ui.data(|d| d.get_temp(loop_id)).unwrap_or(false);
let ready = app.session.is_enabled("paths.nullsFollowPoints") && !app.session.state.selected_layers.is_empty();
ui.label(RichText::new("Select a layer with a mask or shape path (or the path property).").color(t.text_dim).small());
ui.label(RichText::new("Select a layer with a mask or shape path (or the path property), or Puppet pins.").color(t.text_dim).small());
ui.add_space(6.0);
let mut acts = vec![];
for (id, label, tip) in [

View File

@ -1,6 +1,7 @@
//! Puppet tools in the Composition viewer: the deformed mesh and pins of the selected layers'
//! Puppet effect, click to add a pin (`puppet.addPin`), drag a pin to move it
//! (`puppet.movePin`, merged into one undo step per drag).
//! Puppet effect, click to add a pin (`puppet.addPin`), click / Shift-click a pin to select it
//! (`puppet.selectPins`), drag a pin to move it (`puppet.movePin`), and drag an Advanced or Bend
//! pin's ring to rotate it or its square to scale it (`puppet.setPin`); each drag is one undo step.
use std::sync::Arc;
@ -17,6 +18,9 @@ use crate::EffectcraftApp;
/// Deformed meshes of a layer's Puppet effect: (mesh uid, mesh, deformed vertices, pins).
pub type Overlay = Arc<Vec<(u64, Arc<Mesh>, Vec<[f64; 2]>, Vec<Pin>)>>;
/// Rotation ring radius (screen points) of Advanced and Bend pins.
pub const RING: f32 = 9.0;
/// A pin drawn this frame.
#[derive(Clone, Copy, Debug)]
pub struct PinHit {
@ -24,6 +28,36 @@ pub struct PinHit {
pub pin: u64,
pub kind: PinKind,
pub pos: Pos2,
/// Rotation (°) and scale (1 = 100 %) now, for Advanced and Bend pins.
pub rotation: f64,
pub scale: f64,
/// The scale handle on the ring (Advanced and Bend pins).
pub handle: Option<Pos2>,
}
/// The part of a pin under the pointer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PinPart {
/// The pin itself (drag moves Position, Advanced, Starch and Overlap pins).
Center,
/// The rotation ring of an Advanced or Bend pin.
Ring,
/// The scale handle on that ring.
Scale,
}
/// The pin part under `p`, topmost pin first.
pub fn hit(hits: &[PinHit], p: Pos2) -> Option<(PinHit, PinPart)> {
hits.iter().rev().find_map(|h| {
let d = h.pos.distance(p);
match h.handle {
Some(s) if s.distance(p) < 5.0 => Some((*h, PinPart::Scale)),
Some(_) if d < 6.0 => Some((*h, PinPart::Center)),
Some(_) if (d - RING).abs() < 4.0 => Some((*h, PinPart::Ring)),
None if d < 8.0 => Some((*h, PinPart::Center)),
_ => None,
}
})
}
#[derive(Clone)]
@ -65,8 +99,31 @@ pub fn pin_position(mesh: &Mesh, def: &[[f64; 2]], p: &Pin) -> [f64; 2] {
}
}
/// Draw the mesh (when `show_mesh`) and pins of `layer`; returns pin hit targets.
pub fn draw(painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, layer: &Layer, ov: &Overlay, show_mesh: bool, selected: &[u64]) -> Vec<PinHit> {
/// Pin colours by kind: Position yellow, Starch red, Overlap blue, Advanced blue-green, Bend
/// orange-brown.
pub fn pin_color(kind: PinKind) -> Color32 {
match kind {
PinKind::Position => Color32::from_rgb(0xf0, 0xd0, 0x40),
PinKind::Starch => Color32::from_rgb(0xe0, 0x50, 0x50),
PinKind::Overlap => Color32::from_rgb(0x40, 0x90, 0xf0),
PinKind::Advanced => Color32::from_rgb(0x30, 0xc8, 0xb4),
PinKind::Bend => Color32::from_rgb(0xc8, 0x82, 0x3c),
}
}
/// Draw the mesh (when `show_mesh`) and pins of `layer` (the pin under `hover` is drawn larger);
/// returns pin hit targets.
#[allow(clippy::too_many_arguments)]
pub fn draw(
painter: &egui::Painter,
map: &ViewerMap,
ectx: &EvalCtx,
layer: &Layer,
ov: &Overlay,
show_mesh: bool,
selected: &[u64],
hover: Option<Pos2>,
) -> Vec<PinHit> {
let (m, _) = l2c(ectx, layer);
let scr = |q: [f64; 2]| {
let c = m.apply(gv2(q[0], q[1]));
@ -84,25 +141,39 @@ pub fn draw(painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, layer: &La
}
}
for pn in pins {
let pos = scr(pin_position(mesh, def, pn));
let at = pin_position(mesh, def, pn);
let pos = scr(at);
let sel = selected.contains(&pn.uid);
let col = match pn.kind {
PinKind::Starch => Color32::from_rgb(0xe0, 0x50, 0x50),
PinKind::Overlap => Color32::from_rgb(0x40, 0x90, 0xf0),
PinKind::Bend => Color32::from_rgb(0xf0, 0x90, 0x30),
_ => Color32::from_rgb(0xf0, 0xd0, 0x40),
};
let col = pin_color(pn.kind);
if matches!(pn.kind, PinKind::Starch | PinKind::Overlap) {
// Extent ring (layer pixels → screen).
let r = (scr([pn.rest[0] + pn.extent, pn.rest[1]]) - scr(pn.rest)).length();
painter.circle_stroke(pos, r.max(2.0), Stroke::new(1.0, col.gamma_multiply(0.6)));
}
// Advanced and Bend pins: the rotation ring with a square scale handle at the pin's
// rotation (the layer's own orientation on screen).
let mut handle = None;
if matches!(pn.kind, PinKind::Bend | PinKind::Advanced) {
painter.circle_stroke(pos, 9.0, Stroke::new(1.5, col));
painter.circle_stroke(pos, RING, Stroke::new(1.5, col));
let (s, c) = pn.rotation.to_radians().sin_cos();
let dir = (scr([at[0] + c, at[1] + s]) - pos).normalized();
if dir.x.is_finite() && dir.y.is_finite() {
let hp = pos + dir * RING;
painter.rect_filled(egui::Rect::from_center_size(hp, egui::vec2(5.0, 5.0)), 0.0, col);
painter.rect_stroke(
egui::Rect::from_center_size(hp, egui::vec2(5.0, 5.0)),
0.0,
Stroke::new(1.0, Color32::BLACK),
egui::StrokeKind::Outside,
);
handle = Some(hp);
}
}
painter.circle_filled(pos, if sel { 5.5 } else { 4.5 }, if sel { col } else { col.gamma_multiply(0.85) });
painter.circle_stroke(pos, if sel { 5.5 } else { 4.5 }, Stroke::new(1.0, Color32::BLACK));
hits.push(PinHit { layer: layer.id, pin: pn.uid, kind: pn.kind, pos });
// Pins grow under the pointer.
let r = if sel { 5.5 } else { 4.5 } + if hover.is_some_and(|h| h.distance(pos) < 8.0) { 1.5 } else { 0.0 };
painter.circle_filled(pos, r, if sel { col } else { col.gamma_multiply(0.85) });
painter.circle_stroke(pos, r, Stroke::new(1.0, Color32::BLACK));
hits.push(PinHit { layer: layer.id, pin: pn.uid, kind: pn.kind, pos, rotation: pn.rotation, scale: pn.scale, handle });
}
}
hits

View File

@ -4,6 +4,7 @@
//! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and
//! camera/light wireframes.
use effectcraft_engine::effects::puppet::PinKind;
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::project::{Layer, LayerId};
use effectcraft_engine::render::EvalCtx;
@ -121,12 +122,36 @@ enum Gesture {
pin: u64,
inv: Mat3,
},
/// Rotating an Advanced / Bend pin by its ring: the pin's screen centre, the pointer's last
/// angle (radians) and the rotation so far (°, accumulated so it can pass a full turn).
PuppetRotate {
layer: LayerId,
pin: u64,
center: Pos2,
last: f32,
rotation: f64,
},
/// Scaling an Advanced / Bend pin by its square: the pin's screen centre, the press distance
/// from it and the scale at the press (1 = 100 %).
PuppetScale {
layer: LayerId,
pin: u64,
center: Pos2,
start: f32,
scale: f64,
},
/// Puppet tools: a marquee selecting pins (dragged outside the layer's art, or Alt-dragged).
PinMarquee {
start: Pos2,
},
/// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds
/// since it began, layer space) and becomes keyframes on release (`puppet.recordPin`); the
/// current time runs along while recording.
PuppetRecord {
layer: LayerId,
pin: u64,
/// Other selected pins of the layer, moving with the dragged one.
others: Vec<u64>,
inv: Mat3,
began: f64,
cti: Tick,
@ -801,11 +826,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(l) = comp.layer(*lid)
&& let Some(ov) = super::puppet_tool::overlay(app, &ctx, cid, l, time)
{
pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props));
let hover = ui.input(|i| i.pointer.hover_pos());
pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
}
}
for h in &pin_hits {
app.auto.add(&format!("viewer.puppetPin.{}", h.pin), Rect::from_center_size(h.pos, vec2(10.0, 10.0)), "Puppet pin");
if let Some(hp) = h.handle {
let ring = h.pos + (h.pos - hp);
app.auto.add(&format!("viewer.puppetPin.{}.rotate", h.pin), Rect::from_center_size(ring, vec2(6.0, 6.0)), "Rotate puppet pin");
app.auto.add(&format!("viewer.puppetPin.{}.scale", h.pin), Rect::from_center_size(hp, vec2(6.0, 6.0)), "Scale puppet pin");
}
}
}
@ -826,13 +857,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tool::Dolly => egui::CursorIcon::ResizeVertical,
t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair,
_ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair,
t if t.puppet_kind().is_some() => {
if pin_hits.iter().any(|h| h.pos.distance(hp) < 8.0) {
egui::CursorIcon::Move
} else {
egui::CursorIcon::Crosshair
}
}
t if t.puppet_kind().is_some() => match super::puppet_tool::hit(&pin_hits, hp) {
Some((_, super::puppet_tool::PinPart::Ring)) => egui::CursorIcon::Alias,
Some((_, super::puppet_tool::PinPart::Scale)) => egui::CursorIcon::ResizeNwSe,
Some((h, _)) if h.kind == PinKind::Bend => egui::CursorIcon::PointingHand,
Some(_) => egui::CursorIcon::Move,
None => egui::CursorIcon::Crosshair,
},
_ => {
if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
egui::CursorIcon::Move
@ -944,13 +975,36 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
})
}),
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
t if t.puppet_kind().is_some() => pin_hits.iter().rev().find(|h| h.pos.distance(press) < 8.0).and_then(|h| {
t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => {
// Off the pins: Alt or a press outside the art drags a marquee that selects pins.
(mods.alt || pick(app, &ectx, cpt, false).is_none()).then_some(Gesture::PinMarquee { start: press })
}
t if t.puppet_kind().is_some() => super::puppet_tool::hit(&pin_hits, press).and_then(|(h, part)| {
use super::puppet_tool::PinPart;
let angle = (press - h.pos).angle();
match part {
PinPart::Ring => return Some(Gesture::PuppetRotate { layer: h.layer, pin: h.pin, center: h.pos, last: angle, rotation: h.rotation }),
PinPart::Scale => {
let start = h.pos.distance(press).max(1.0);
return Some(Gesture::PuppetScale { layer: h.layer, pin: h.pin, center: h.pos, start, scale: h.scale });
}
// A Bend pin has no position: it turns and scales by its ring only.
PinPart::Center if h.kind == PinKind::Bend => return None,
PinPart::Center => {}
}
let l = comp.layer(h.layer)?;
let inv = l2c(&ectx, l).0.inverse()?;
if mods.command {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let began = ui.input(|i| i.time);
return Some(Gesture::PuppetRecord { layer: h.layer, pin: h.pin, inv, began, cti: time, samples: vec![(0.0, [lp.x, lp.y])] });
// Dragging one of several selected pins records them all.
let sel = &app.session.state.selected_props;
let others: Vec<u64> = if sel.contains(&(h.layer, h.pin)) {
pin_hits.iter().filter(|o| o.layer == h.layer && o.pin != h.pin && sel.contains(&(o.layer, o.pin))).map(|o| o.pin).collect()
} else {
vec![]
};
return Some(Gesture::PuppetRecord { layer: h.layer, pin: h.pin, others, inv, began, cti: time, samples: vec![(0.0, [lp.x, lp.y])] });
}
Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv })
}),
@ -1188,7 +1242,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let _ = app.session.execute("mask.setVertex", params);
}
Gesture::PuppetRecord { layer, pin, inv, began, cti, mut samples } => {
Gesture::PuppetRecord { layer, pin, others, inv, began, cti, mut samples } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let now = ui.input(|i| i.time) - began;
if samples.last().is_none_or(|s| now > s.0) {
@ -1199,9 +1253,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.session.set_time(cti + Tick::from_seconds_f64(now / speed));
painter.circle_filled(pos, 6.0, Color32::from_rgb(0xe0, 0x30, 0x30));
painter.text(pos + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, "Recording", Tokens::ui(11.0), Color32::from_rgb(0xff, 0x60, 0x60));
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::PuppetRecord { layer, pin, inv, began, cti, samples }));
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::PuppetRecord { layer, pin, others, inv, began, cti, samples }));
ui.ctx().request_repaint();
}
Gesture::PuppetRotate { layer, pin, center, last, rotation } => {
// Accumulate the turn since the last frame (wrapped to ±180°) so a drag can go
// past a full revolution; Shift snaps to 15°.
let a = (pos - center).angle();
let mut d = (a - last) as f64;
d = (d + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let rotation = rotation + d.to_degrees();
let deg = if mods.shift { (rotation / 15.0).round() * 15.0 } else { rotation };
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::PuppetRotate { layer, pin, center, last: a, rotation }));
if let Err(e) = app.session.execute("puppet.setPin", json!({"layer": layer.0, "pin": pin, "rotation": deg, "merge": merge})) {
app.ui.status = e.to_string();
}
}
Gesture::PuppetScale { layer, pin, center, start, scale } => {
// Shift snaps the scale to 5 % steps.
let pct = scale * (pos.distance(center) / start) as f64 * 100.0;
let pct = if mods.shift { (pct / 5.0).round() * 5.0 } else { pct }.max(1.0);
if let Err(e) = app.session.execute("puppet.setPin", json!({"layer": layer.0, "pin": pin, "scale": pct, "merge": merge})) {
app.ui.status = e.to_string();
}
}
Gesture::PuppetPin { layer, pin, inv } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
if let Err(e) = app.session.execute("puppet.movePin", json!({"layer": layer.0, "pin": pin, "position": [lp.x, lp.y], "merge": merge})) {
@ -1224,7 +1299,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
q["merge"] = json!(merge);
let _ = app.session.execute("track.setPoint", q);
}
Gesture::Marquee { start } => {
Gesture::Marquee { start } | Gesture::PinMarquee { start } => {
let r = Rect::from_two_pos(start, pos);
painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
@ -1234,9 +1309,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.drag_stopped() {
if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) {
match g {
Gesture::PuppetRecord { layer, pin, cti, samples, .. } => {
Gesture::PinMarquee { start } => {
// Pins inside the box, per layer; Shift adds to the selection.
let r = Rect::from_two_pos(start, pos);
let mut first = !mods.shift;
let mut layers: Vec<LayerId> = pin_hits.iter().map(|h| h.layer).collect();
layers.dedup();
if first && pin_hits.iter().all(|h| !r.contains(h.pos)) {
let _ = app.session.execute("puppet.selectPins", json!({"pins": []}));
}
for l in layers {
let pins: Vec<u64> = pin_hits.iter().filter(|h| h.layer == l && r.contains(h.pos)).map(|h| h.pin).collect();
if !pins.is_empty() {
let _ = app.session.execute("puppet.selectPins", json!({"layer": l.0, "pins": pins, "add": !first}));
first = false;
}
}
}
Gesture::PuppetRecord { layer, pin, others, cti, samples, .. } => {
let pts: Vec<serde_json::Value> = samples.iter().map(|(t, p)| json!([t, p[0], p[1]])).collect();
if let Err(e) = app.session.execute("puppet.recordPin", json!({"layer": layer.0, "pin": pin, "samples": pts, "start": cti.seconds()})) {
let q = json!({"layer": layer.0, "pin": pin, "pins": others, "samples": pts, "start": cti.seconds()});
if let Err(e) = app.session.execute("puppet.recordPin", q) {
app.ui.status = e.to_string();
}
}
@ -1333,8 +1426,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ov::end_free_transform(&ctx);
}
t if t.puppet_kind().is_some() => {
if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0)
&& let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
// Click selects the pin; Shift-click adds or removes it.
let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
} else if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
&& let Some(inv) = l2c(&ectx, l).0.inverse()
{
let lp = inv.apply(gv2(cpt[0], cpt[1]));

View File

@ -173,7 +173,11 @@ impl Tool {
Tool::Eraser => Icon::Eraser,
Tool::RotoBrush => Icon::RotoBrush,
Tool::RefineEdge => Icon::RefineEdge,
Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Icon::Puppet,
Tool::Puppet => Icon::Puppet,
Tool::PuppetStarch => Icon::PuppetStarch,
Tool::PuppetBend => Icon::PuppetBend,
Tool::PuppetAdvanced => Icon::PuppetAdvanced,
Tool::PuppetOverlap => Icon::PuppetOverlap,
}
}
pub fn from_name(s: &str) -> Option<Tool> {

View File

@ -400,3 +400,105 @@ fn reference_axes_toggle_from_the_grid_menu() {
assert!(!h.state().session.prefs.three_d.show_reference_axes);
assert!(h.state().auto.find("viewer.referenceAxes").is_none());
}
/// A Box layer pinned with two Position pins and a Bend pin in the middle (Bend tool active).
fn puppet_harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut h = harness();
let s = &mut h.state_mut().session;
// No full-frame Plate behind the Box: the comp around the Box is empty, like the canvas
// around a character.
s.execute("edit.clear", json!({"layers": ["Plate"]})).unwrap();
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
let id = s.state.selected_layers[0].0;
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 40]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [70, 40]})).unwrap();
let bend = s.execute("puppet.addPin", json!({"layer": id, "kind": "bend", "position": [40, 40]})).unwrap()["pin"].as_u64().unwrap();
h.state_mut().ui.tool = Tool::PuppetBend;
h.run_steps(3);
(h, id, bend)
}
fn pin_value(h: &Harness<'_, EffectcraftApp>, layer: u64, pin: u64, prop: &str) -> f64 {
let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone();
l.props.find_group(pin).unwrap().get(prop).unwrap().value.as_f64()
}
#[test]
fn puppet_bend_pin_rotates_by_its_ring_and_scales_by_its_square() {
let (mut h, id, bend) = puppet_harness();
let center = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let ring = rect(&h, &format!("viewer.puppetPin.{bend}.rotate")).center();
let square = rect(&h, &format!("viewer.puppetPin.{bend}.scale")).center();
// At 0° the square is on the right of the ring, the rotate target opposite it.
assert!(square.x > center.x + 7.0 && (square.y - center.y).abs() < 0.5, "{square:?} {center:?}");
// Drag the ring a quarter turn clockwise (left → top): +90°, one undo step.
let undo_before = h.state().session.history.undo.len();
drag(&mut h, ring, center + vec2(0.0, -(center.x - ring.x)));
let r = pin_value(&h, id, bend, "rotation");
assert!((r - 90.0).abs() < 1.0, "rotation {r}");
assert_eq!(h.state().session.history.undo.len(), undo_before + 1);
// The square follows the rotation (now below the pin; the bend carries the pin itself a
// little); dragging it twice as far from the pin scales 200 %.
let center = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let square = rect(&h, &format!("viewer.puppetPin.{bend}.scale")).center();
assert!(square.y > center.y + 7.0, "{square:?}");
drag(&mut h, square, center + (square - center) * 2.0);
let sc = pin_value(&h, id, bend, "scale");
assert!((sc - 200.0).abs() < 2.0, "scale {sc}");
}
#[test]
fn puppet_pin_click_selects_and_delete_removes_only_the_pins() {
let (mut h, id, bend) = puppet_harness();
let pin = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::<Vec<_>>();
// Clicking a pin selects it (instead of adding a pin on top of it).
let at = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
click(&mut h, at);
assert_eq!(pin(&h), vec![bend]);
let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap();
assert_eq!(info["meshes"][0]["pins"].as_array().unwrap().len(), 3, "no pin added");
// Delete removes the pin; the layer stays.
h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap();
assert_eq!(info["meshes"][0]["pins"].as_array().unwrap().len(), 2);
assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_some());
}
#[test]
fn puppet_marquee_selects_pins_and_alt_drag_works_over_the_art() {
let (mut h, _id, bend) = puppet_harness();
let p1 = rect(&h, &format!("viewer.puppetPin.{bend}")).center();
let pins: Vec<u64> = h.state().auto.previous.iter().filter_map(|e| e.id.strip_prefix("viewer.puppetPin.").and_then(|r| r.parse().ok())).collect();
assert_eq!(pins.len(), 3, "{pins:?}");
// A box around every pin, started on empty canvas (outside all art).
let comp = rect(&h, "viewer.comp");
drag(&mut h, comp.min + vec2(4.0, 4.0), comp.max - vec2(4.0, 4.0));
let mut sel: Vec<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
sel.sort();
let mut want = pins.clone();
want.sort();
assert_eq!(sel, want);
// Alt-drag a small box over the art (around the Bend pin only).
h.input_mut().events.push(Event::PointerMoved(p1 - vec2(6.0, 6.0)));
h.input_mut().events.push(Event::PointerButton {
pos: p1 - vec2(6.0, 6.0),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::ALT,
});
h.step();
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(p1 - vec2(6.0, 6.0) + vec2(12.0, 12.0) * (i as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton {
pos: p1 + vec2(6.0, 6.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::ALT,
});
h.run_steps(2);
let sel: Vec<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
assert_eq!(sel, vec![bend]);
}

View File

@ -295,6 +295,22 @@ that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript
* `puppet.recordPin {"layer": "#1", "pin": "Puppet Pin 1", "samples": [[t, x, y]…]}` records a
drag (t = seconds since it began, layer space) into Position keys at the comp frame rate from the
current time; `puppet.recordOptions {speed, smoothing, useDraftDeformation, showMesh}`.
* `puppet.selectPins {"layer": "#1", "pins": ["Puppet Pin 1"], "add"?, "toggle"?}` selects pins
like the viewer's click / Shift-click (`pins: []` deselects); Edit ▸ Clear (Delete) then removes
the selected pins, not their layer (`puppet.removePin {pins?}`). Advanced and Bend pins turn by
their ring and scale by its square in the viewer (Shift: 15° / 5 % steps; automation ids
`viewer.puppetPin.<uid>.rotate` / `.scale`), or `puppet.setPin {rotation, scale}`. A drag
outside the art (or Alt-drag) marquee-selects pins; Edit ▸ Select All with a pin selected selects
every pin of that kind; `puppet.recordPin {..., "pins": [...]}` records several pins moving
together (⌘/Ctrl-drag one of several selected pins).
* Rigging pins: `paths.pointsFollowNulls {"layer": "#1", "pins"?: [...]}` gives each Position /
Advanced pin a null that drives it (parent the nulls to each other to build limbs);
`paths.nullsFollowPoints {..., "pins": [...]}` makes nulls that ride on pins (parent props to
them). Without `pins` they use the selected pins, or every pin of a layer with no path.
* Follow-through (hair, cloth, tails): `puppet.follow {"leader": "Puppet Pin 1", "pins": [...],
"delay": 0.1, "amount": 100, "cascade": true}` makes the pins trail the leader's motion (the
k-th nearest by k × delay with `cascade`) through Position expressions; without `leader` /
`pins` the first selected pin leads the other selected pins.
* `effect.plugins.load {"path": "x.wasm"}` / `effect.plugins.list`: WebAssembly effect plug-ins
([plugins.md](plugins.md)), then `effect.apply` by id like a built-in.

View File

@ -132,7 +132,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates (CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) |
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |