This commit is contained in:
Brandon Thomas committed 2026-11-03 09:04:54 -05:00
commit 3072f009bf
5 files changed
+152 -10

No files matched your search

+32 -4
View File
@@ -7,7 +7,8 @@
//!
//! Pin positions are in effect 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.
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. Off
//! every mesh a pin starts a new mesh, unless its art already has one (#2865).
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
use effectcraft_effects::{Buf, Params};
@@ -62,6 +63,32 @@ fn layer_time(s: &Session, cid: ItemId, lid: LayerId, t: Tick) -> Result<Tick> {
Ok(l.layer_time(t))
}
/// Whether a pin at `at` (effect space) goes on this mesh as `def` deforms it: on it or beside it.
fn on_mesh(mesh: &puppet::Mesh, def: &[[f64; 2]], at: [f64; 2]) -> bool {
!mesh.tris.is_empty() && (puppet::contains(mesh, def, at) || def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0)
}
/// Whether a new mesh at `at` (effect space) would repeat a mesh of `params`: the art it would
/// outline is that mesh's own, even if a deformation has carried the mesh off it (#2865).
fn repeats_mesh(buf: &Buf, params: &Params, at: [f64; 2]) -> bool {
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
let alpha: Vec<f32> = buf.img.data.iter().map(|p| p[3]).collect();
puppet::parse(params).iter().any(|m| {
let mut outline = puppet::mesh::outline(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, m.opts.expansion);
// As `triangulate` winds it.
if puppet::mesh::signed_area(&outline) < 0.0 {
outline.reverse();
}
puppet::mesh_for(buf, m).outline == outline
})
}
/// Whether `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect.
pub fn places_pin(s: &Session, cid: ItemId, lid: LayerId, at: [f64; 2], t: Tick) -> bool {
let Some((buf, params)) = find_puppet(s, cid, lid).and_then(|fu| puppet_eval(s, cid, lid, fu, t)) else { return true };
!repeats_mesh(&buf, &params, at) || puppet::overlay(&buf, &params).iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at))
}
fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.addPin";
let (cid, lid) = layer_p(s, p, cmd)?;
@@ -93,9 +120,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
continue;
}
let inside = puppet::contains(mesh, def, at);
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
if inside || near || want_mesh.is_some() {
if on_mesh(mesh, def, at) || want_mesh.is_some() {
let rest = puppet::unmap(mesh, def, at);
// A moving pin holds its nearest mesh vertex at `position + (vertex - rest)`
// (puppet::solve). Choose the position that keeps that vertex where it is now, so
@@ -120,6 +145,9 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(cmd, format!("no mesh {w}")));
}
if target.is_none() && repeats_mesh(&buf, &params, at) {
return Ok(json!({"pin": null, "changed": false}));
}
}
let opts = MeshOpts {
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
+65
View File
@@ -225,6 +225,71 @@ fn a_pin_added_to_a_bent_mesh_moves_nothing() {
}
}
fn mesh_count(s: &mut Session, id: u64) -> usize {
s.execute("puppet.info", json!({"layer": id})).unwrap()["meshes"].as_array().unwrap().len()
}
/// #2865: where the deformed layer is empty a click makes nothing: no copy of the art it has moved
/// off, no undo step, no error.
#[test]
fn a_pin_where_the_deformed_layer_is_empty_creates_nothing() {
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();
s.set_time(Tick::from_seconds_f64(1.0));
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 1", "position": [10, 70]})).unwrap();
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [90, 70]})).unwrap();
let (cid, t) = (s.active_comp_id().unwrap(), Tick::from_seconds_f64(1.0));
let before = s.render(cid, t, RenderOpts::default());
let revision = s.revision;
let lid = effectcraft_project::LayerId(id);
// The solid's top, which the pins have carried 40 px down; and well off it.
for at in [[50, 10], [300, 300]] {
assert!(!crate::commands::puppet::places_pin(&s, cid, lid, [at[0] as f64, at[1] as f64], t), "{at:?}");
let r = s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap();
assert_eq!(r["pin"], Value::Null, "{at:?}");
assert_eq!(s.revision, revision, "{at:?} changed the project");
}
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 2));
assert_eq!(s.render(cid, t, RenderOpts::default()), before);
// Whatever Expansion new meshes would now get, the art is the mesh's own.
s.state.puppet.expansion = 9.0;
assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 10]})).unwrap()["pin"], Value::Null);
// On the deformed shape a pin still goes on the mesh.
assert!(crate::commands::puppet::places_pin(&s, cid, lid, [50.0, 70.0], t));
assert!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 70]})).unwrap()["pin"].is_u64());
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 3));
}
/// #2865: separate art still gets its own mesh, also from a near miss in the gap beside it (a click
/// off the art finds the art nearest it): only a repeat of a mesh is refused.
#[test]
fn separate_art_still_gets_its_own_mesh() {
let (mut s, id) = setup();
for x in [[0, 30], [60, 100]] {
s.execute("mask.new", json!({"layer": id, "vertices": [[x[0], 0], [x[1], 0], [x[1], 60], [x[0], 60]], "closed": true})).unwrap();
}
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 1));
// In the gap, nearer the right piece and past reach of the left piece's mesh.
s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the near miss meshes the right piece");
s.undo();
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 30]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the right piece gets its own mesh");
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 50]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 3), "and the next pin goes on it");
}
/// `newMesh` asks for a new mesh wherever the click is, the art's own mesh or not.
#[test]
fn new_mesh_option_starts_a_mesh_on_meshed_art() {
let (mut s, id) = setup();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30], "newMesh": true})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2));
}
#[test]
fn puppet_pin_kinds_and_mesh_options() {
let (mut s, id) = setup();
+10 -2
View File
@@ -84,8 +84,8 @@ pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &L
/// The layer a Puppet tool press at comp point `cpt` works on, of the selected `layers` with a
/// Puppet effect (stack order): the one whose mesh, as deformed now, is under it (pins go on the
/// deformed shape, also where it reaches past the layer's own bounds), else the one whose art is
/// (a new mesh there). Never a layer without a Puppet effect: the tools stay on the layers being
/// deformed shape, also where it reaches past the layer's own bounds), else the one `on_art` takes
/// it (see `takes_pin`). Never a layer without a Puppet effect: the tools stay on the layers being
/// rigged, as in After Effects (#284).
pub fn target(ectx: &EvalCtx, layers: &[(&Layer, Overlay)], cpt: [f64; 2], on_art: impl Fn(&Layer) -> bool) -> Option<LayerId> {
let on_mesh = |l: &Layer, ov: &Overlay| {
@@ -96,6 +96,14 @@ pub fn target(ectx: &EvalCtx, layers: &[(&Layer, Overlay)], cpt: [f64; 2], on_ar
layers.iter().find(|(l, ov)| on_mesh(l, ov)).or_else(|| layers.iter().find(|(l, _)| on_art(l))).map(|(l, _)| l.id)
}
/// Whether a press at comp point `cpt` places a pin on `layer`. Where it would not (the deformed
/// layer is empty there) the press is on nothing: a click does nothing, a drag is a marquee (#2865).
pub fn takes_pin(app: &EffectcraftApp, ectx: &EvalCtx, layer: &Layer, cpt: [f64; 2]) -> bool {
let Some(inv) = fx2c(ectx, layer).inverse() else { return false };
let p = inv.apply(gv2(cpt[0], cpt[1]));
effectcraft_engine::commands::puppet::places_pin(&app.session, ectx.comp_id, layer.id, [p.x, p.y], ectx.time)
}
/// Keep a pin's keyframed Position in view in the Timeline after placing, moving or recording the
/// pin in the viewer (#273).
pub fn reveal_pin(app: &mut EffectcraftApp, layer: LayerId, pin: u64) {
+9 -4
View File
@@ -1112,7 +1112,9 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx,
if puppet_layers.is_empty() {
return pick(app, ectx, cpt, false);
}
super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| {
layers_at(ectx, cpt).any(|h| h.id == l.id) && super::puppet_tool::takes_pin(app, ectx, l, cpt)
})
};
if resp.drag_started()
&& !text_owns
@@ -1186,8 +1188,9 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx,
t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }),
Tool::ObjectMatte => Some(Gesture::ObjectMatteBox { start: press }),
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 || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press })
// Off the pins: Alt or a press outside the art drags a marquee that selects pins. Asked
// of where the button went down: the pointer may be over the art by the time this is a drag.
(mods.alt || puppet_layer(app, map.to_comp(press)).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;
@@ -1700,7 +1703,9 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response
if puppet_layers.is_empty() {
return pick(app, ectx, cpt, false);
}
super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| {
layers_at(ectx, cpt).any(|h| h.id == l.id) && super::puppet_tool::takes_pin(app, ectx, l, cpt)
})
};
if resp.clicked()
&& !text_owns
+36
View File
@@ -1657,6 +1657,42 @@ fn puppet_pins_go_on_the_deformed_mesh_and_the_tool_stays_on_the_rigged_layer()
assert!(!rigged(&h, plate));
}
/// #2865: a press where the deformed layer is empty, though on its original bounds, adds nothing:
/// a click leaves the rig alone and a drag from there is a pin marquee.
#[test]
fn puppet_press_where_the_deformed_layer_is_empty_is_a_marquee_not_a_copy() {
let mut h = harness();
let s = &mut h.state_mut().session;
s.execute("edit.clear", json!({"layers": ["Plate"]})).unwrap();
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
let id = s.state.selected_layers[0].0;
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 40]})).unwrap()["pin"].as_u64().unwrap();
let b = s.execute("puppet.addPin", json!({"layer": id, "position": [70, 40]})).unwrap()["pin"].as_u64().unwrap();
// Carry the Box 60 px right: its left part (comp x 280..340) is now empty.
s.execute("puppet.movePin", json!({"layer": id, "pin": a, "position": [70, 40]})).unwrap();
s.execute("puppet.movePin", json!({"layer": id, "pin": b, "position": [130, 40]})).unwrap();
h.state_mut().ui.tool = Tool::Puppet;
h.run_steps(3);
let rig = |h: &mut Harness<'_, EffectcraftApp>| {
let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap();
let meshes = info["meshes"].as_array().unwrap().clone();
(meshes.len(), meshes.iter().map(|m| m["pins"].as_array().unwrap().len()).sum::<usize>())
};
let at = screen(&h, [285.0, 180.0]);
click(&mut h, at);
assert_eq!(rig(&mut h), (1, 2), "the click made nothing");
let comp = rect(&h, "viewer.comp");
drag(&mut h, at, comp.max - vec2(4.0, 4.0));
assert_eq!(rig(&mut h), (1, 2), "nor did the drag");
let mut sel: Vec<u64> = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect();
sel.sort();
assert_eq!(sel, [a, b], "the drag's marquee selected the pins");
// On the deformed shape a click still pins it.
let at = screen(&h, [380.0, 180.0]);
click(&mut h, at);
assert_eq!(rig(&mut h), (1, 3));
}
/// Filled circles painted this frame (flattening nested shape lists).
fn circles(h: &Harness<'_, EffectcraftApp>) -> Vec<egui::epaint::CircleShape> {
fn walk(s: &egui::Shape, out: &mut Vec<egui::epaint::CircleShape>) {