M13.16: puppet Follow-Through — puppet.follow (Puppet tool bar ▸ Follow-Through…) makes pins trail a leader pin for hair, cloth and overlapping action: each follower's Position adds the leader's displacement from rest delayed by delay (the k-th nearest by k × delay with Cascade), scaled by Amount, keeping its own keyframes; leader = the first selected pin, followers = the others; one undo step; reachable over MCP (execute_command)

This commit is contained in:
bflatastic
2026-10-04 00:19:26 +07:00
parent 6ec177de83
commit a02bcc8441
8 changed files with 172 additions and 2 deletions
+21
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}");
}
+73 -1
View File
@@ -2,7 +2,8 @@
//! 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).
//! 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
@@ -523,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> {
@@ -615,6 +678,15 @@ pub fn specs() -> Vec<CommandSpec> {
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...",
+25
View File
@@ -312,6 +312,31 @@ fn puppet_pins_rig_to_nulls_both_ways() {
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();
+24
View File
@@ -256,6 +256,30 @@ fn puppet_pin_rigs_follow_and_lead_with_live_expressions() {
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};
+15
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
}
+9
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;
+4
View File
@@ -273,6 +273,10 @@ that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript
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.
+1 -1
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.13–M13.15), 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 |