From 244cee46877d7916bc9680ae5191bf0b3a0a1a60 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 00:19:22 +0700 Subject: [PATCH 1/5] =?UTF-8?q?M8.1:=20expressions=20keep=20working=20on?= =?UTF-8?q?=20long-lived=20threads=20=E2=80=94=20boa=20leaves=20values=20o?= =?UTF-8?q?n=20its=20VM=20stack=20when=20an=20exception=20unwinds=20throug?= =?UTF-8?q?h=20call=20frames=20(every=20pending=20host=20request=20does),?= =?UTF-8?q?=20so=20after=20tens=20to=20hundreds=20of=20evaluations=20that?= =?UTF-8?q?=20read=20layers=20or=20effects=20every=20expression=20on=20the?= =?UTF-8?q?=20thread=20failed=20with=20"maximum=20stack=20size";=20a=20run?= =?UTF-8?q?=20that=20hits=20the=20limit=20now=20gets=20a=20fresh=20context?= =?UTF-8?q?=20and=20is=20retried=20once=20(regression=20test:=206000=20hos?= =?UTF-8?q?t-reading=20evaluations=20on=20one=20thread)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/expr/src/runtime.rs | 27 ++++++++++++++++++++++++--- crates/expr/src/tests.rs | 13 +++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index fe45c268..24416b89 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -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 { } /// 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}"))) }) } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index ac06485e..1f8ae7a2 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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}"); + } +} From 6869cff51bcc34b1fc1d0f25426f08bf51c52a60 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 00:19:24 +0700 Subject: [PATCH 2/5] =?UTF-8?q?M13.14:=20puppet=20pin=20editing=20?= =?UTF-8?q?=E2=80=94=20click=20/=20Shift-click=20selects=20pins=20(puppet.?= =?UTF-8?q?selectPins),=20a=20drag=20from=20empty=20canvas=20or=20Alt-drag?= =?UTF-8?q?=20marquee-selects=20them,=20Select=20All=20with=20a=20pin=20se?= =?UTF-8?q?lected=20takes=20every=20pin=20of=20that=20kind,=20Delete=20rem?= =?UTF-8?q?oves=20the=20selected=20pins=20instead=20of=20their=20layer;=20?= =?UTF-8?q?Advanced=20and=20Bend=20pins=20rotate=20by=20their=20ring=20and?= =?UTF-8?q?=20scale=20by=20its=20square=20(Shift:=2015=C2=B0=20/=205=20%?= =?UTF-8?q?=20steps,=20one=20undo=20step=20per=20drag);=20=E2=8C=98/Ctrl-d?= =?UTF-8?q?ragging=20one=20of=20several=20selected=20pins=20records=20them?= =?UTF-8?q?=20all;=20pins=20in=20their=20kind's=20colour,=20larger=20under?= =?UTF-8?q?=20the=20pointer;=20pushpin=20icons=20for=20the=20five=20pin=20?= =?UTF-8?q?tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/commands/edit.rs | 8 + crates/engine/src/commands/puppet.rs | 178 +++++++++++++++++++---- crates/engine/src/tests_paint.rs | 53 +++++++ crates/ui-egui/src/icons.rs | 41 +++++- crates/ui-egui/src/panels/puppet_tool.rs | 101 +++++++++++-- crates/ui-egui/src/panels/viewer.rs | 129 +++++++++++++--- crates/ui-egui/src/state.rs | 6 +- crates/ui-egui/tests/ui_viewer.rs | 102 +++++++++++++ docs/agents.md | 8 + 9 files changed, 562 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 9b003d4d..a36b02d4 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -56,6 +56,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result { 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 { 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!({})); diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index 890e1091..7490354c 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -1,6 +1,9 @@ //! 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). +//! //! 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 +12,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 +165,102 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { 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 { + 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 { + 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::>()).collect(); + s.state.selected_props = all.clone(); + Some(json!({ "pins": all.iter().map(|(_, u)| *u).collect::>() })) +} + /// 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 { 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). +fn pins_p(s: &Session, p: &Value, cmd: &str) -> Result> { + let keys: Vec = 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 { + 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::>() })) } fn with_pin(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut PropGroup, Tick) -> Result) -> Result { @@ -237,19 +317,19 @@ fn set_pin(s: &mut Session, p: &Value) -> Result { } fn remove_pin(s: &mut Session, p: &Value) -> Result { - 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 { @@ -341,6 +421,43 @@ fn record_pin(s: &mut Session, p: &Value) -> Result { 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 = 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::>()); + 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. @@ -469,7 +586,16 @@ pub fn specs() -> Vec { 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,7 +611,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index 9407ddd1..50b447a1 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -216,6 +216,59 @@ 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 = [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()); +} + #[test] fn project_with_paint_and_puppet_round_trips() { let (mut s, id) = setup(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ac9a028e..de2c2b9d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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)]); diff --git a/crates/ui-egui/src/panels/puppet_tool.rs b/crates/ui-egui/src/panels/puppet_tool.rs index 3ec7bba8..da2c3aad 100644 --- a/crates/ui-egui/src/panels/puppet_tool.rs +++ b/crates/ui-egui/src/panels/puppet_tool.rs @@ -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<[f64; 2]>, Vec)>>; +/// 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, +} + +/// 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 { +/// 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, +) -> Vec { 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 diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cb897125..ff798b0e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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, 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 = 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 = 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 = 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 = 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])); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 57434526..c0b10624 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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 { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 157c5a3a..9cde7bf0 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -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::>(); + // 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 = 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 = 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 = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect(); + assert_eq!(sel, vec![bend]); +} diff --git a/docs/agents.md b/docs/agents.md index 330f24a9..eb607799 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -261,6 +261,14 @@ 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..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). * `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. From 6ec177de83758096ce84a8e730f89039f84568a3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 00:19:25 +0700 Subject: [PATCH 3/5] =?UTF-8?q?M13.15:=20rig=20puppet=20pins=20to=20nulls?= =?UTF-8?q?=20=E2=80=94=20Points=20Follow=20Nulls=20/=20Nulls=20Follow=20P?= =?UTF-8?q?oints=20(Window=20=E2=96=B8=20Create=20Nulls=20From=20Paths,=20?= =?UTF-8?q?paths.*=20{pins=3F})=20take=20selected=20pins,=20named=20pins,?= =?UTF-8?q?=20or=20every=20pin=20of=20a=20layer=20with=20no=20path:=20a=20?= =?UTF-8?q?null=20per=20Position=20/=20Advanced=20pin=20drives=20its=20Pos?= =?UTF-8?q?ition=20(fromComp=20of=20the=20null's=20anchor,=20so=20parented?= =?UTF-8?q?=20nulls=20rig=20limbs),=20or=20rides=20on=20it=20(toComp=20of?= =?UTF-8?q?=20the=20pin's=20Position)=20to=20carry=20other=20layers;=20one?= =?UTF-8?q?=20undo=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/commands/path_nulls.rs | 114 ++++++++++++++++++-- crates/engine/src/commands/puppet.rs | 2 +- crates/engine/src/tests_paint.rs | 43 ++++++++ crates/script/src/tests_ui_more.rs | 45 ++++++++ crates/ui-egui/src/panels/path_vr_panels.rs | 5 +- docs/agents.md | 4 + 6 files changed, 203 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/commands/path_nulls.rs b/crates/engine/src/commands/path_nulls.rs index 1034f3d5..bb74ee77 100644 --- a/crates/engine/src/commands/path_nulls.rs +++ b/crates/engine/src/commands/path_nulls.rs @@ -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

Option { 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)>> { + 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 { 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::>()})); + } 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 { fn points_follow_nulls(s: &mut Session, p: &Value) -> Result { 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::>()})); + } 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 { "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 { "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 ), diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index 7490354c..cfa2d549 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -210,7 +210,7 @@ fn pin_uid(s: &Session, cid: ItemId, lid: LayerId, key: &Value, cmd: &str) -> Re /// Pins named by `pin` or `pins` (uids or names) on `layer`, else the selected pins (of `layer` /// when given): (comp, layer, pin uid). -fn pins_p(s: &Session, p: &Value, cmd: &str) -> Result> { +pub(crate) fn pins_p(s: &Session, p: &Value, cmd: &str) -> Result> { let keys: Vec = match (p.get("pin"), p.get("pins")) { (Some(k), _) => vec![k.clone()], (None, Some(Value::Array(a))) => a.clone(), diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index 50b447a1..7f50d56f 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -269,6 +269,49 @@ fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() { 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 = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + let pins: Vec = 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 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 69abb61d..92164d47 100644 --- a/crates/script/src/tests_ui_more.rs +++ b/crates/script/src/tests_ui_more.rs @@ -211,6 +211,51 @@ 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 = 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 sockets_talk_tcp_behind_the_network_preference() { use std::io::{BufRead, BufReader, Write}; diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs index 1fc5b808..21b02f22 100644 --- a/crates/ui-egui/src/panels/path_vr_panels.rs +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -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 [ diff --git a/docs/agents.md b/docs/agents.md index eb607799..0984c822 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -269,6 +269,10 @@ that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript 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. * `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. From a02bcc844105886b8c4eee2f9d7080a87cd43027 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 00:19:26 +0700 Subject: [PATCH 4/5] =?UTF-8?q?M13.16:=20puppet=20Follow-Through=20?= =?UTF-8?q?=E2=80=94=20puppet.follow=20(Puppet=20tool=20bar=20=E2=96=B8=20?= =?UTF-8?q?Follow-Through=E2=80=A6)=20makes=20pins=20trail=20a=20leader=20?= =?UTF-8?q?pin=20for=20hair,=20cloth=20and=20overlapping=20action:=20each?= =?UTF-8?q?=20follower's=20Position=20adds=20the=20leader's=20displacement?= =?UTF-8?q?=20from=20rest=20delayed=20by=20`delay`=20(the=20k-th=20nearest?= =?UTF-8?q?=20by=20k=20=C3=97=20delay=20with=20Cascade),=20scaled=20by=20A?= =?UTF-8?q?mount,=20keeping=20its=20own=20keyframes;=20leader=20=3D=20the?= =?UTF-8?q?=20first=20selected=20pin,=20followers=20=3D=20the=20others;=20?= =?UTF-8?q?one=20undo=20step;=20reachable=20over=20MCP=20(execute=5Fcomman?= =?UTF-8?q?d)?= 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 9504094a..94706665 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 cfa2d549..737cc7cf 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 7f50d56f..d6b5f9f1 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 92164d47..228a5801 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 2b7f3759..3e70ee60 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 2bbe2f43..bc780095 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 0984c822..cd2f773a 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 6165318b..61bc90c0 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 | From 78dceea6389dba67909941f9d1a0c6048707bca3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 00:22:30 +0700 Subject: [PATCH 5/5] =?UTF-8?q?M13.16:=20docs:=20parity=20cites=20the=20re?= =?UTF-8?q?numbered=20puppet=20tasks=20(M13.14=E2=80=93M13.16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 61bc90c0..6ae2353f 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, 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) | +| 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 |