Puppet: refuse only a mesh that repeats one already there (#2865)

Cut the fix down: a click off every mesh makes nothing only when the art it resolves to
already has a mesh with that outline. The nearest-art fallback in `region` stays, so a
near miss beside separate art still meshes it. `mesh::outline` is as it was and the
`newMesh` option of puppet.addPin is back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bar BorerandClaude Opus 5.5 committed 2026-11-03 04:00:19 +02:00
1 parent 9431089493
commit fa0c260630
5 files changed
+47 -104

No files matched your search

-21
View File
@@ -395,27 +395,6 @@ pub fn contains(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> bool {
})
}
/// Whether `at` (effect space) is art a new mesh with this `expansion` could take: on the expanded
/// alpha itself, and not what a mesh of `params` already covers at rest, even if its deformation
/// has carried that art away. A mesh there would only rebuild the same outline: a full copy (#2865).
pub fn unmeshed_art_at(buf: &Buf, params: &Params, at: [f64; 2], expansion: f64) -> 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();
let mut outline = mesh::outline_under(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, expansion);
if outline.is_empty() {
return false;
}
// As `triangulate` winds it. The simplified outline can cut a corner past `at`, so a mesh can
// miss the very art it was built from: the same outline is the same art.
if mesh::signed_area(&outline) < 0.0 {
outline.reverse();
}
!parse(params).iter().any(|m| {
let mesh = mesh_for(buf, m);
mesh.outline == outline || contains(&mesh, &mesh.verts, at)
})
}
#[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]);
+1 -25
View File
@@ -194,11 +194,6 @@ pub fn region(g: &Grid, seed: [f64; 2]) -> Option<Grid> {
Some(Grid { w, h, on: outside.iter().map(|o| !o).collect() })
}
/// The region under `seed` only when `seed` is on a cell: `region` without its nearest fallback.
pub fn region_under(g: &Grid, seed: [f64; 2]) -> Option<Grid> {
if g.at(seed[0].floor() as i64, seed[1].floor() as i64) { region(g, seed) } else { None }
}
/// Trace the outer boundary of a hole-free region along pixel cracks: a closed polygon through
/// pixel corners, region on the left when walking (counter-clockwise in y-down coordinates).
pub fn trace(g: &Grid) -> Vec<[f64; 2]> {
@@ -622,25 +617,6 @@ pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh {
/// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and
/// `offset`), expanded by `expansion` layer pixels.
pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> {
outline_of(alpha, w, h, scale, offset, seed, expansion, region)
}
/// As `outline`, but empty unless `seed` is on the expanded alpha itself.
pub fn outline_under(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> {
outline_of(alpha, w, h, scale, offset, seed, expansion, region_under)
}
#[allow(clippy::too_many_arguments)]
fn outline_of(
alpha: &[f32],
w: usize,
h: usize,
scale: f64,
offset: [f64; 2],
seed: [f64; 2],
expansion: f64,
pick: fn(&Grid, [f64; 2]) -> Option<Grid>,
) -> Vec<[f64; 2]> {
// Pad so expansion can grow past the buffer edge.
let pad = (expansion.max(0.0) * scale).ceil() as usize + 2;
let (pw, ph) = (w + 2 * pad, h + 2 * pad);
@@ -652,7 +628,7 @@ fn outline_of(
}
let g = expand(&Grid { w: pw, h: ph, on }, expansion * scale);
let sp = [seed[0] * scale + offset[0] + pad as f64, seed[1] * scale + offset[1] + pad as f64];
let Some(r) = pick(&g, sp) else { return vec![] };
let Some(r) = region(&g, sp) else { return vec![] };
let c = trace(&r);
let c = simplify(&c, 0.75);
c.iter().map(|p| [(p[0] - pad as f64 - offset[0]) / scale, (p[1] - pad as f64 - offset[1]) / scale]).collect()
-37
View File
@@ -323,40 +323,3 @@ fn single_pin_on_a_traced_solid_does_not_rotate_it() {
}
}
}
/// #2865: only art no mesh has takes a new mesh, however close the rest is.
#[test]
fn unmeshed_art_is_strictly_under_the_point_and_not_already_meshed() {
// Two discs; the mesh is on the left one, at rest.
let (w, h) = (96usize, 48usize);
let (l, r) = (disc_alpha(w, h, [24.0, 24.0], 18.0), disc_alpha(w, h, [72.0, 24.0], 12.0));
let mut img = Image::new(w as u32, h as u32);
for (i, p) in img.data.iter_mut().enumerate() {
*p = [1.0, 1.0, 1.0, l[i].max(r[i])];
}
let buf = Buf { img, offset: [0.0; 2], scale: 1.0 };
let params = flatten_params(&puppet_fx(&[(PinKind::Position, [24.0, 24.0], [24.0, 24.0])]), &mut |p| p.value.clone());
let e = 3.0;
assert!(unmeshed_art_at(&buf, &params, [72.0, 24.0], e), "the other disc");
assert!(!unmeshed_art_at(&buf, &params, [24.0, 24.0], e), "the meshed disc");
assert!(!unmeshed_art_at(&buf, &params, [48.0, 24.0], e), "between them: no nearest fallback");
assert!(!unmeshed_art_at(&buf, &params, [500.0, 24.0], e), "off the buffer");
// Pixel corners of the meshed disc that simplifying the outline left just outside the triangles:
// on its art, off its mesh, and a mesh there would be the same mesh again.
let m = parse(&params).remove(0);
let mesh = mesh_for(&buf, &m);
let mut slivers = 0;
for y in 0..h {
for x in 0..48 {
for (dx, dy) in [(0.02, 0.02), (0.98, 0.02), (0.02, 0.98), (0.98, 0.98)] {
let p = [x as f64 + dx, y as f64 + dy];
if contains(&mesh, &mesh.verts, p) || mesh::outline_under(&l, w, h, 1.0, [0.0; 2], p, e).is_empty() {
continue;
}
slivers += 1;
assert!(!unmeshed_art_at(&buf, &params, p, e), "{p:?}");
}
}
}
assert!(slivers > 0, "no point of the art falls outside the mesh: this test proves nothing");
}
+27 -13
View File
@@ -8,7 +8,7 @@
//! 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. Off
//! every mesh a pin starts a new mesh only on art no mesh has yet; elsewhere nothing happens (#2865).
//! 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};
@@ -68,12 +68,25 @@ 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 `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect: on
/// a mesh, on art no mesh has, or anywhere when there is no mesh yet (the first pin).
/// 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 };
let meshes = puppet::overlay(&buf, &params);
meshes.is_empty() || meshes.iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at)) || puppet::unmeshed_art_at(&buf, &params, at, s.state.puppet.expansion)
!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> {
@@ -92,17 +105,14 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "the Puppet tools work on footage, solid, text, shape and precomp layers"));
}
}
let opts = MeshOpts {
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion),
triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles),
};
// Which mesh: the one under the click (through the current deformation), else a new one.
let fx_uid = find_puppet(s, cid, lid);
let want_mesh = p.get("mesh").and_then(Value::as_u64);
let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false);
// (mesh, the pin's rest point, where it is now)
let mut target: Option<(Uid, [f64; 2], [f64; 2])> = None;
if let Some(fu) = fx_uid
&& !new_mesh
&& let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t)
{
let meshes = puppet::overlay(&buf, &params);
@@ -135,11 +145,15 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(cmd, format!("no mesh {w}")));
}
// Nothing to pin here: empty space, or art a mesh has already carried away (#2865).
if target.is_none() && !meshes.is_empty() && !puppet::unmeshed_art_at(&buf, &params, at, opts.expansion) {
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),
expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion),
triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles),
};
let size = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let l = comp.layer(lid).ok_or(EngineError::NoComp)?;
@@ -666,7 +680,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Puppet Pin",
[],
None,
"{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, density?, expansion?, triangles?}",
"{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}",
has_comp,
add_pin
),
+19 -8
View File
@@ -252,33 +252,44 @@ fn a_pin_where_the_deformed_layer_is_empty_creates_nothing() {
}
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: art no mesh has still takes its own mesh; the gap beside it takes none. The first pin of
/// a layer is as ever: it finds the art nearest the click.
/// #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 only_unmeshed_art_takes_a_new_mesh() {
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": [46, 30]})).unwrap();
assert_eq!(mesh_count(&mut s, id), 1, "the first pin");
s.undo();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
// In the gap, past reach of the left piece's mesh (Expansion takes it to x 33).
assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap()["pin"], Value::Null);
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();