Puppet: the tools stay on the rigged layer and pins go on the deformed mesh (#284)

With a Puppet tool, a click off the pins picked the topmost layer under
the pointer by its own bounds, and selected it. So a click where the
deformation had stretched the mesh past the layer's bounds went to the
layer behind (selecting it and giving it a Puppet effect), and any
click on another layer switched to it.

While selected layers have a Puppet effect, a click (or a press that
might start a pin marquee) now works only on them, as in After
Effects: on the layer whose mesh, as deformed now, is under the
pointer (the pin lands on the deformed shape), else the one whose own
art is (a new mesh); elsewhere nothing happens and the selection
stays. Without a rigged layer selected, the tools pick and rig the
layer under the pointer as before.

`puppet::contains` tests a point against a deformed mesh; `puppet.addPin`
uses it too (its own test only held for one triangle winding).

Test: puppet_pins_go_on_the_deformed_mesh_and_the_tool_stays_on_the_rigged_layer
(ui_viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 23:16:26 +07:00
parent 065f0c91e7
commit 02bc2b8f9a
5 changed files with 75 additions and 7 deletions

View File

@ -387,6 +387,14 @@ pub fn unmap(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> [f64; 2] {
}
}
/// Whether `p` (layer space) is on the mesh as `deformed` places its vertices.
pub fn contains(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> bool {
mesh.tris.iter().any(|t| match (deformed.get(t[0]), deformed.get(t[1]), deformed.get(t[2])) {
(Some(a), Some(b), Some(c)) => bary(*a, *b, *c, p).is_some_and(|l| l.iter().all(|x| *x >= -1e-9)),
_ => false,
})
}
#[inline]
fn bary(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> {
let d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);

View File

@ -87,11 +87,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 = mesh.tris.iter().any(|tr| {
let (a, b, c) = (def[tr[0]], def[tr[1]], def[tr[2]]);
let cr = |o: [f64; 2], u: [f64; 2], v: [f64; 2]| (u[0] - o[0]) * (v[1] - o[1]) - (u[1] - o[1]) * (v[0] - o[0]);
cr(a, b, at) >= 0.0 && cr(b, c, at) >= 0.0 && cr(c, a, at) >= 0.0
});
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() {
target = Some((*uid, puppet::unmap(mesh, def, at)));

View File

@ -82,6 +82,20 @@ pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &L
Some(overlay)
}
/// 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
/// 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| {
let Some(inv) = l2c(ectx, l).0.inverse() else { return false };
let p = inv.apply(gv2(cpt[0], cpt[1]));
ov.iter().any(|(_, mesh, def, _)| puppet::contains(mesh, def, [p.x, p.y]))
};
layers.iter().find(|(l, ov)| on_mesh(l, ov)).or_else(|| layers.iter().find(|(l, _)| on_art(l))).map(|(l, _)| l.id)
}
/// 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) {

View File

@ -919,6 +919,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Puppet pins and meshes of the selected layers (Puppet tools).
let mut pin_hits: Vec<super::puppet_tool::PinHit> = vec![];
// The selected layers with a Puppet effect, top of the stack first (the layers the Puppet
// tools work on).
let mut puppet_layers: Vec<(&Layer, super::puppet_tool::Overlay)> = vec![];
if app.ui.tool.puppet_kind().is_some() {
let sel_props: Vec<u64> = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
for lid in &selected {
@ -927,8 +930,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
{
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));
puppet_layers.push((l, ov));
}
}
puppet_layers.sort_by_key(|(l, _)| comp.index_of(l.id));
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 {
@ -938,6 +943,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
// The layer a Puppet tool press works on: while selected layers are rigged, only those
// ([`super::puppet_tool::target`]); otherwise the layer under the pointer, selected.
let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option<LayerId> {
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))
};
// Project items, files and effects dropped on the viewer.
super::viewer_drop::show(app, ui, &painter, &map, &ectx);
@ -1121,7 +1134,7 @@ 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() && 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 })
(mods.alt || puppet_layer(app, cpt).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;
@ -1557,7 +1570,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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))
} else if let Some(l) = puppet_layer(app, cpt).and_then(|l| comp.layer(l))
&& let Some(inv) = l2c(&ectx, l).0.inverse()
{
let lp = inv.apply(gv2(cpt[0], cpt[1]));

View File

@ -891,6 +891,43 @@ fn placing_a_pin_opens_its_groups_on_a_twirled_open_layer() {
assert_eq!(timeline_keys(&h, pin_keys(&h, id, pin).0).len(), 1, "the new pin's Position key shows");
}
/// #284: the Puppet tools stay on the layer being rigged: a pin goes on its mesh where the
/// deformation has stretched it past the layer's own bounds (over another layer), and a click on
/// another layer neither selects it nor rigs it.
#[test]
fn puppet_pins_go_on_the_deformed_mesh_and_the_tool_stays_on_the_rigged_layer() {
let mut h = harness();
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
let (boxl, plate) = (s.state.selected_layers[0], s.active_comp().unwrap().layers[1].id);
s.execute("puppet.addPin", json!({"layer": boxl.0, "position": [10, 40]})).unwrap();
let right = s.execute("puppet.addPin", json!({"layer": boxl.0, "position": [70, 40]})).unwrap()["pin"].as_u64().unwrap();
// Stretch the Box to the right: its right edge (layer x 80, comp x 360) moves well past 400.
s.execute("puppet.movePin", json!({"layer": boxl.0, "pin": right, "position": [140, 40]})).unwrap();
h.state_mut().ui.tool = Tool::Puppet;
h.run_steps(3);
let pins = |h: &mut Harness<'_, EffectcraftApp>| {
let info = h.state_mut().session.execute("puppet.info", json!({"layer": boxl.0})).unwrap();
info["meshes"].as_array().unwrap().iter().map(|m| m["pins"].as_array().unwrap().len()).sum::<usize>()
};
let rigged = |h: &Harness<'_, EffectcraftApp>, l: LayerId| {
h.state().session.active_comp().unwrap().layer(l).unwrap().effects().is_some_and(|f| f.groups().next().is_some())
};
// Comp (380, 180) is layer (100, 40): past the Box's own edge, over the Plate, on the
// stretched mesh.
let at = screen(&h, [380.0, 180.0]);
click(&mut h, at);
assert_eq!(pins(&mut h), 3, "the pin went on the deformed mesh");
assert_eq!(h.state().session.state.selected_layers, vec![boxl]);
assert!(!rigged(&h, plate), "the Plate got no Puppet");
// Far from the Box, on the Plate only: nothing.
let at = screen(&h, [40.0, 40.0]);
click(&mut h, at);
assert_eq!(pins(&mut h), 3);
assert_eq!(h.state().session.state.selected_layers, vec![boxl], "the tool stays on the Box");
assert!(!rigged(&h, plate));
}
/// 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>) {