mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
M13.15: rig puppet pins to nulls — Points Follow Nulls / Nulls Follow Points (Window ▸ Create Nulls From Paths, paths.* {pins?}) take selected pins, named pins, or every pin of a layer with no path: a null per Position / Advanced pin drives its Position (fromComp of the null's anchor, so parented nulls rig limbs), or rides on it (toComp of the pin's Position) to carry other layers; one undo step
This commit is contained in:
@@ -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<P
|
||||
[q.x, q.y]
|
||||
})
|
||||
.collect();
|
||||
// Seen from another layer (the nulls).
|
||||
let reference = from_other_layer(comp, layer, uid).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()) })
|
||||
}
|
||||
|
||||
/// An expression reference to `uid` of `layer` as another layer (the nulls) sees it.
|
||||
fn from_other_layer(comp: &Comp, layer: &Layer, uid: Uid) -> Option<String> {
|
||||
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<Option<(ItemId, Vec<PinRig>)>> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()}));
|
||||
}
|
||||
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<Value> {
|
||||
|
||||
fn points_follow_nulls(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Vec<_>>()}));
|
||||
}
|
||||
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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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<Vec<(ItemId, LayerId, Uid)>> {
|
||||
pub(crate) fn pins_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(ItemId, LayerId, Uid)>> {
|
||||
let keys: Vec<Value> = match (p.get("pin"), p.get("pins")) {
|
||||
(Some(k), _) => vec![k.clone()],
|
||||
(None, Some(Value::Array(a))) => a.clone(),
|
||||
|
||||
@@ -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<u64> = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
let pins: Vec<u64> = 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();
|
||||
|
||||
@@ -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<u64> = 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};
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user