mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 03:28:22 -06:00
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:
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user