From a02bcc844105886b8c4eee2f9d7080a87cd43027 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 00:19:26 +0700 Subject: [PATCH] =?UTF-8?q?M13.16:=20puppet=20Follow-Through=20=E2=80=94?= =?UTF-8?q?=20puppet.follow=20(Puppet=20tool=20bar=20=E2=96=B8=20Follow-Th?= =?UTF-8?q?rough=E2=80=A6)=20makes=20pins=20trail=20a=20leader=20pin=20for?= =?UTF-8?q?=20hair,=20cloth=20and=20overlapping=20action:=20each=20followe?= =?UTF-8?q?r's=20Position=20adds=20the=20leader's=20displacement=20from=20?= =?UTF-8?q?rest=20delayed=20by=20`delay`=20(the=20k-th=20nearest=20by=20k?= =?UTF-8?q?=20=C3=97=20delay=20with=20Cascade),=20scaled=20by=20Amount,=20?= =?UTF-8?q?keeping=20its=20own=20keyframes;=20leader=20=3D=20the=20first?= =?UTF-8?q?=20selected=20pin,=20followers=20=3D=20the=20others;=20one=20un?= =?UTF-8?q?do=20step;=20reachable=20over=20MCP=20(execute=5Fcommand)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/automation/src/tests.rs | 21 ++++++++ crates/engine/src/commands/puppet.rs | 74 +++++++++++++++++++++++++++- crates/engine/src/tests_paint.rs | 25 ++++++++++ crates/script/src/tests_ui_more.rs | 24 +++++++++ crates/ui-egui/src/header.rs | 15 ++++++ crates/ui-egui/src/panels/forms.rs | 9 ++++ docs/agents.md | 4 ++ docs/parity.md | 2 +- 8 files changed, 172 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 9504094a0..947066651 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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}"); +} diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index cfa2d549b..737cc7cf0 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -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 { 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 { + 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 = 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::>>()?; + 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 { @@ -615,6 +678,15 @@ pub fn specs() -> Vec { 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...", diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index 7f50d56f8..d6b5f9f1a 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -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(); diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs index 92164d471..228a58016 100644 --- a/crates/script/src/tests_ui_more.rs +++ b/crates/script/src/tests_ui_more.rs @@ -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}; diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 2b7f37591..3e70ee60e 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -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 } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 2bbe2f437..bc7800950 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -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; diff --git a/docs/agents.md b/docs/agents.md index 0984c822d..cd2f773a6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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. diff --git a/docs/parity.md b/docs/parity.md index 6165318b8..61bc90c07 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |