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();