diff --git a/crates/engine/src/commands/path_nulls.rs b/crates/engine/src/commands/path_nulls.rs index 1034f3d50..bb74ee77c 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 7490354c4..cfa2d549b 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 50b447a11..7f50d56f8 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 69abb61dc..92164d471 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 1fc5b808a..21b02f22a 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 eb607799d..0984c822d 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.