From 6869cff51bcc34b1fc1d0f25426f08bf51c52a60 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 00:19:24 +0700 Subject: [PATCH] =?UTF-8?q?M13.14:=20puppet=20pin=20editing=20=E2=80=94=20?= =?UTF-8?q?click=20/=20Shift-click=20selects=20pins=20(puppet.selectPins),?= =?UTF-8?q?=20a=20drag=20from=20empty=20canvas=20or=20Alt-drag=20marquee-s?= =?UTF-8?q?elects=20them,=20Select=20All=20with=20a=20pin=20selected=20tak?= =?UTF-8?q?es=20every=20pin=20of=20that=20kind,=20Delete=20removes=20the?= =?UTF-8?q?=20selected=20pins=20instead=20of=20their=20layer;=20Advanced?= =?UTF-8?q?=20and=20Bend=20pins=20rotate=20by=20their=20ring=20and=20scale?= =?UTF-8?q?=20by=20its=20square=20(Shift:=2015=C2=B0=20/=205=20%=20steps,?= =?UTF-8?q?=20one=20undo=20step=20per=20drag);=20=E2=8C=98/Ctrl-dragging?= =?UTF-8?q?=20one=20of=20several=20selected=20pins=20records=20them=20all;?= =?UTF-8?q?=20pins=20in=20their=20kind's=20colour,=20larger=20under=20the?= =?UTF-8?q?=20pointer;=20pushpin=20icons=20for=20the=20five=20pin=20tools?= 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.