Puppet: the first pin no longer turns the layer about it (#313)

With one Position pin and no Bend pins, every rotation and scale about the
pin is free in the ARAP energy. The 1e-9 regulariser (about 1e-12 after the
edge-length scaling) is too weak to hold them, so the CG solve turned
round-off into a visible rotation of several pixels, depending on where the
pin landed. The exact solution is the rest mesh translated by the pin's
offset, so deform() returns it directly in that case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
heojunfo 2026-10-09 12:34:00 +09:00
parent d60cb71e29
commit 2cd7a36d66
3 changed files with 40 additions and 0 deletions

View File

@ -164,6 +164,12 @@ pub fn deform(rest: &[[f64; 2]], tris: &[[usize; 3]], weights: &[f64], h: &Handl
t[1] /= h.fixed.len() as f64;
}
let x0: Vec<[f64; 2]> = rest.iter().map(|p| [p[0] + t[0], p[1] + t[1]]).collect();
// One pin and no edge targets: every rotation and scale about the pin costs nothing, and
// the regulariser that picks the translation is too weak to stop CG turning round-off
// into a visible rotation (#313). The translation is the exact solution.
if h.fixed.len() == 1 && h.edges.is_empty() {
return x0;
}
let mut fixed = vec![false; n];
for (i, _) in &h.fixed {
fixed[*i] = true;

View File

@ -306,3 +306,20 @@ fn groups_round_trip_serde() {
assert_eq!(m[0].pins[1].kind, PinKind::Starch);
assert_eq!(m[0].pins[0].position, [3.0, 4.0]);
}
#[test]
fn single_pin_on_a_traced_solid_does_not_rotate_it() {
// #313: an 800×600 solid traced from its alpha, as the Puppet Pin tool builds it. One
// pin is not enough to rotate or scale the mesh, so it may only translate it.
let (w, h) = (800, 600);
let o = outline(&vec![1.0; w * h], w, h, 1.0, [0.0; 2], [400.0, 300.0], 3.0);
let m = triangulate(&o, &MeshOpts::default());
check_valid(&m);
for at in [[40.0, 40.0], [760.0, 30.0], [20.0, 580.0], [790.0, 590.0], [400.0, 300.0], [123.0, 456.0]] {
for t in [[0.0, 0.0], [35.0, -20.0]] {
let d = solve(&m, &[pin(PinKind::Position, at, [at[0] + t[0], at[1] + t[1]])], 2);
let err = d.iter().zip(&m.verts).map(|(a, b)| (a[0] - b[0] - t[0]).hypot(a[1] - b[1] - t[1])).fold(0.0, f64::max);
assert!(err < 1e-6, "pin at {at:?} moved by {t:?}: vertices off by up to {err} px");
}
}
}

View File

@ -186,6 +186,23 @@ fn puppet_pins_deform_and_undo() {
assert_eq!(px(&s, 1.0, 100, 25), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn first_puppet_pin_leaves_the_layer_still() {
// #313: an 800×600 solid in a 1920×1080 comp turned about the first pin at these spots.
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 1080, "duration": 2, "frameRate": 30})).unwrap();
let id = s.execute("layer.newSolid", json!({"width": 800, "height": 600, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let before = s.render(cid, Tick::ZERO, RenderOpts::default());
for at in [[40, 40], [400, 300], [123, 456]] {
s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap();
let after = s.render(cid, Tick::ZERO, RenderOpts::default());
let moved = after.data.iter().zip(&before.data).filter(|(a, b)| (0..4).any(|c| (a[c] - b[c]).abs() > 1e-3)).count();
assert_eq!(moved, 0, "pin at {at:?} changed {moved} pixels");
s.undo();
}
}
#[test]
fn puppet_pin_kinds_and_mesh_options() {
let (mut s, id) = setup();