From 2cd7a36d66dbf3e2e53b0fc05a2dbbbe9c168bfe Mon Sep 17 00:00:00 2001 From: heojunfo Date: Fri, 9 Oct 2026 12:34:00 +0900 Subject: [PATCH] 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) --- crates/effects/src/puppet/arap.rs | 6 ++++++ crates/effects/src/puppet/tests.rs | 17 +++++++++++++++++ crates/engine/src/tests_paint.rs | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/crates/effects/src/puppet/arap.rs b/crates/effects/src/puppet/arap.rs index 9d46a978..19a87594 100644 --- a/crates/effects/src/puppet/arap.rs +++ b/crates/effects/src/puppet/arap.rs @@ -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; diff --git a/crates/effects/src/puppet/tests.rs b/crates/effects/src/puppet/tests.rs index 066ac0c5..10426d46 100644 --- a/crates/effects/src/puppet/tests.rs +++ b/crates/effects/src/puppet/tests.rs @@ -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"); + } + } +} diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index d6b5f9f1..4e05027a 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -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();