M13.14: puppet pin editing — click / Shift-click selects pins (puppet.selectPins), a drag from empty canvas or Alt-drag marquee-selects them, Select All with a pin selected takes every pin of that kind, Delete removes the selected pins instead of their layer; Advanced and Bend pins rotate by their ring and scale by its square (Shift: 15° / 5 % steps, one undo step per drag); ⌘/Ctrl-dragging one of several selected pins records them all; pins in their kind's colour, larger under the pointer; pushpin icons for the five pin tools
This commit is contained in:
parent
244cee4687
commit
6869cff51b
|
|
@ -56,6 +56,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
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<Value> {
|
|||
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!({}));
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
|||
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<Item = &PropGroup> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()).collect();
|
||||
s.state.selected_props = all.clone();
|
||||
Some(json!({ "pins": all.iter().map(|(_, u)| *u).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// 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<Uid> {
|
||||
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<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(),
|
||||
(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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn with_pin<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut PropGroup, Tick) -> Result<T>) -> Result<T> {
|
||||
|
|
@ -237,19 +317,19 @@ fn set_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn remove_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
|
@ -341,6 +421,43 @@ fn record_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> = 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::<Vec<_>>());
|
||||
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<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<Value> = [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();
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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<(u64, Arc<Mesh>, Vec<[f64; 2]>, Vec<Pin>)>>;
|
||||
|
||||
/// 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<Pos2>,
|
||||
}
|
||||
|
||||
/// 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<PinHit> {
|
||||
/// 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<Pos2>,
|
||||
) -> Vec<PinHit> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<u64>,
|
||||
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<u64> = 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<LayerId> = 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<u64> = 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<serde_json::Value> = 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]));
|
||||
|
|
|
|||
|
|
@ -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<Tool> {
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
// 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<u64> = 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<u64> = 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<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
|
||||
assert_eq!(sel, vec![bend]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.<uid>.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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue