Files
effectcraft/crates/effects/src/puppet/tests.rs
T
heojunfoandClaude Opus 5.5 2cd7a36d66 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>
2026-10-09 12:34:00 +09:00

326 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::mesh::*;
use super::*;
use crate::{EffectEnv, flatten_params, instantiate};
fn disc_alpha(w: usize, h: usize, c: [f64; 2], r: f64) -> Vec<f32> {
let mut a = vec![0.0; w * h];
for y in 0..h {
for x in 0..w {
let d = ((x as f64 + 0.5 - c[0]).powi(2) + (y as f64 + 0.5 - c[1]).powi(2)).sqrt();
a[y * w + x] = if d <= r { 1.0 } else { 0.0 };
}
}
a
}
fn check_valid(m: &Mesh) {
assert!(!m.tris.is_empty());
for t in &m.tris {
let a = cross(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]);
assert!(a > 0.0, "flipped or degenerate triangle {t:?} area {a}");
}
let tri_area: f64 = m.tris.iter().map(|t| cross(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]) * 0.5).sum();
let poly_area = signed_area(&m.outline).abs();
assert!((tri_area - poly_area).abs() < 1e-6 * poly_area.max(1.0), "triangles {tri_area} vs outline {poly_area}");
}
fn covered(m: &Mesh, p: [f64; 2]) -> bool {
m.tris.iter().any(|t| {
let (a, b, c) = (m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]);
cross(a, b, p) >= -1e-9 && cross(b, c, p) >= -1e-9 && cross(c, a, p) >= -1e-9
})
}
#[test]
fn distance_transform_matches_brute_force() {
let (w, h) = (13, 9);
let src: Vec<bool> = (0..w * h).map(|i| i % 17 == 3 || i == 50).collect();
let d = distance_sq(w, h, &src);
for y in 0..h {
for x in 0..w {
let mut best = f64::MAX;
for yy in 0..h {
for xx in 0..w {
if src[yy * w + xx] {
best = best.min(((x as f64 - xx as f64).powi(2)) + (y as f64 - yy as f64).powi(2));
}
}
}
assert_eq!(d[y * w + x], best, "({x},{y})");
}
}
}
#[test]
fn triangulation_is_valid_and_covers_the_alpha() {
let (w, h) = (120, 100);
let alpha = disc_alpha(w, h, [60.0, 50.0], 38.0);
let outline = outline(&alpha, w, h, 1.0, [0.0; 2], [60.0, 50.0], 3.0);
let m = triangulate(&outline, &MeshOpts::default());
check_valid(&m);
assert!(m.tris.len() > 150 && m.tris.len() < 800, "{} triangles", m.tris.len());
for y in 0..h {
for x in 0..w {
if alpha[y * w + x] > 0.0 {
assert!(covered(&m, [x as f64 + 0.5, y as f64 + 0.5]), "alpha pixel ({x},{y}) not covered");
}
}
}
// Density controls the triangle count.
let fine = triangulate(&outline, &MeshOpts { density: 100.0, ..MeshOpts::default() });
check_valid(&fine);
assert!(fine.tris.len() > m.tris.len() * 2);
}
#[test]
fn concave_outline_keeps_its_edges() {
// A U shape (concave) and an L shape.
let (w, h) = (100, 100);
let mut alpha = vec![0.0f32; w * h];
for y in 10..90 {
for x in 10..90 {
let notch = (35..65).contains(&x) && y < 70;
alpha[y * w + x] = if notch { 0.0 } else { 1.0 };
}
}
let o = outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 50.0], 0.0);
let m = triangulate(&o, &MeshOpts { triangles: 120.0, ..Default::default() });
check_valid(&m);
// Nothing in the notch.
assert!(!covered(&m, [50.0, 30.0]));
for (x, y) in [(15.0, 15.0), (85.0, 15.0), (50.0, 80.0), (12.0, 88.0)] {
assert!(covered(&m, [x, y]), "({x},{y})");
}
}
#[test]
fn expansion_grows_the_outline() {
let (w, h) = (80, 80);
let alpha = disc_alpha(w, h, [40.0, 40.0], 20.0);
let a0 = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [40.0, 40.0], 0.0)).abs();
let a5 = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [40.0, 40.0], 5.0)).abs();
let am = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [40.0, 40.0], -5.0)).abs();
let pi = std::f64::consts::PI;
assert!((a0 - pi * 400.0).abs() < 60.0, "{a0}");
assert!((a5 - pi * 625.0).abs() < 90.0, "{a5}");
assert!((am - pi * 225.0).abs() < 60.0, "{am}");
}
fn rect_mesh(w: f64, h: f64, tris: f64) -> Mesh {
triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &MeshOpts { triangles: tris, density: 50.0, expansion: 0.0 })
}
fn pin(kind: PinKind, rest: [f64; 2], pos: [f64; 2]) -> Pin {
Pin { kind, uid: 0, rest, position: pos, scale: 1.0, rotation: 0.0, amount: 50.0, extent: 15.0, in_front: 50.0 }
}
#[test]
fn arap_identity_with_pins_at_rest() {
let m = rect_mesh(100.0, 60.0, 200.0);
check_valid(&m);
let pins = [pin(PinKind::Position, [0.0, 0.0], [0.0, 0.0]), pin(PinKind::Position, [100.0, 60.0], [100.0, 60.0])];
let d = solve(&m, &pins, 2);
for (a, b) in d.iter().zip(&m.verts) {
assert!((a[0] - b[0]).abs() < 1e-7 && (a[1] - b[1]).abs() < 1e-7, "{a:?} vs {b:?}");
}
}
#[test]
fn arap_single_pin_translates_rigidly() {
let m = rect_mesh(100.0, 60.0, 200.0);
let d = solve(&m, &[pin(PinKind::Position, [50.0, 30.0], [70.0, 15.0])], 2);
let v = m.verts[nearest(&m.verts, [50.0, 30.0]).unwrap()];
let t = [70.0 - 50.0 + (v[0] - 50.0) * 0.0, 15.0 - 30.0];
for (a, b) in d.iter().zip(&m.verts) {
assert!((a[0] - b[0] - t[0]).abs() < 1e-7 && (a[1] - b[1] - t[1]).abs() < 1e-7, "{a:?} vs {b:?}");
}
}
#[test]
fn arap_two_pins_rotate_rigidly() {
let m = rect_mesh(100.0, 40.0, 200.0);
let (a, b) = (m.verts[nearest(&m.verts, [0.0, 20.0]).unwrap()], m.verts[nearest(&m.verts, [100.0, 20.0]).unwrap()]);
let c = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
let ang = 30f64.to_radians();
let rot = |p: [f64; 2]| {
let (s, co) = ang.sin_cos();
let d = [p[0] - c[0], p[1] - c[1]];
[c[0] + d[0] * co - d[1] * s, c[1] + d[0] * s + d[1] * co]
};
let pins = [pin(PinKind::Position, a, rot(a)), pin(PinKind::Position, b, rot(b))];
let d = solve(&m, &pins, 0);
for (p, r) in d.iter().zip(&m.verts) {
let e = rot(*r);
assert!((p[0] - e[0]).abs() < 1e-3 && (p[1] - e[1]).abs() < 1e-3, "{p:?} vs {e:?}");
}
}
fn distortion(m: &Mesh, d: &[[f64; 2]], near: [f64; 2], radius: f64) -> f64 {
let mut s = 0.0;
for t in &m.tris {
let c = [(m.verts[t[0]][0] + m.verts[t[1]][0] + m.verts[t[2]][0]) / 3.0, (m.verts[t[0]][1] + m.verts[t[1]][1] + m.verts[t[2]][1]) / 3.0];
if (c[0] - near[0]).hypot(c[1] - near[1]) > radius {
continue;
}
for (i, j) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let l0 = (m.verts[i][0] - m.verts[j][0]).hypot(m.verts[i][1] - m.verts[j][1]);
let l1 = (d[i][0] - d[j][0]).hypot(d[i][1] - d[j][1]);
s += (l1 / l0 - 1.0).powi(2);
}
}
s
}
#[test]
fn starch_reduces_distortion() {
let m = rect_mesh(200.0, 40.0, 300.0);
// Squash the bar: both ends pushed inwards and one end lifted.
let base = vec![pin(PinKind::Position, [0.0, 20.0], [30.0, 20.0]), pin(PinKind::Position, [200.0, 20.0], [160.0, 60.0])];
let plain = solve(&m, &base, 2);
let mut st = base.clone();
st.push(Pin { amount: 100.0, extent: 30.0, ..pin(PinKind::Starch, [100.0, 20.0], [100.0, 20.0]) });
let starched = solve(&m, &st, 2);
let (a, b) = (distortion(&m, &plain, [100.0, 20.0], 25.0), distortion(&m, &starched, [100.0, 20.0], 25.0));
assert!(b < a * 0.5, "starch {b} vs plain {a}");
}
#[test]
fn bend_pin_rotates_around_it() {
let m = rect_mesh(100.0, 40.0, 200.0);
let mut bend = pin(PinKind::Bend, [100.0, 20.0], [100.0, 20.0]);
bend.rotation = 45.0;
let d = solve(&m, &[pin(PinKind::Position, [0.0, 20.0], [0.0, 20.0]), bend], 2);
// The far end turned downwards (positive rotation in y-down layer space).
let v = nearest(&m.verts, [100.0, 40.0]).unwrap();
let u = nearest(&m.verts, [100.0, 0.0]).unwrap();
assert!(d[v][0] < d[u][0] - 5.0, "bottom {:?} top {:?}", d[v], d[u]);
}
#[test]
fn overlap_pins_set_depth() {
let m = rect_mesh(100.0, 40.0, 100.0);
let mut o = pin(PinKind::Overlap, [20.0, 20.0], [20.0, 20.0]);
o.in_front = 80.0;
o.extent = 15.0;
let d = depths(&m, &[o]);
assert!(d.contains(&80.0));
assert!(d.contains(&0.0));
}
fn puppet_fx(pins: &[(PinKind, [f64; 2], [f64; 2])]) -> PropGroup {
let mut next = 5000;
let mut ids = Ids(&mut next);
let mut fx = instantiate(crate::find(ID).unwrap(), &mut ids, "Puppet", [64.0, 64.0]);
let mut mesh = mesh_group(&mut ids, "Mesh 1", pins.first().map(|p| p.1).unwrap_or([32.0; 2]), &MeshOpts::default());
for (k, (kind, rest, pos)) in pins.iter().enumerate() {
let mut g = pin_group(&mut ids, &format!("Puppet Pin {}", k + 1), *kind, *rest);
if let Some(p) = g.get_mut("position") {
p.value = Value::Vec2(*pos);
}
mesh.sub_mut("deform").unwrap().children.push(g.into());
}
fx.children.push(mesh.into());
fx
}
fn run(fx: &PropGroup, img: Image) -> Buf {
let params = flatten_params(fx, &mut |p| p.value.clone());
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: EffectEnv::default() };
crate::apply(crate::find(ID).unwrap(), &ctx, Buf { img, offset: [0.0; 2], scale: 1.0 })
}
fn test_image() -> Image {
let mut img = Image::new(64, 64);
for y in 0..64u32 {
for x in 0..64u32 {
if (12..52).contains(&x) && (16..48).contains(&y) {
img.set(x, y, [x as f32 / 64.0, y as f32 / 64.0, ((x + y) % 3) as f32 / 3.0, 1.0]);
}
}
}
img
}
/// Output pixel at layer pixel (x, y).
fn at(b: &Buf, x: i64, y: i64) -> [f32; 4] {
b.img.get(x + b.offset[0] as i64, y + b.offset[1] as i64)
}
#[test]
fn render_identity_and_translation() {
let img = test_image();
let id = run(&puppet_fx(&[(PinKind::Position, [30.0, 30.0], [30.0, 30.0])]), img.clone());
for y in 17..47 {
for x in 13..51 {
let (o, e) = (at(&id, x, y), img.get(x, y));
assert!((0..4).all(|c| (o[c] - e[c]).abs() < 1e-5), "({x},{y}) {o:?} vs {e:?}");
}
}
let moved = run(&puppet_fx(&[(PinKind::Position, [30.0, 30.0], [40.0, 35.0])]), img.clone());
for y in 17..47 {
for x in 13..51 {
let o = at(&moved, x + 10, y + 5);
let e = img.get(x, y);
for c in 0..4 {
assert!((o[c] - e[c]).abs() < 1e-4, "({x},{y}) {o:?} vs {e:?}");
}
}
}
// Outside the mesh: nothing.
assert_eq!(at(&moved, 2, 2), [0.0; 4]);
}
#[test]
fn render_is_deterministic_and_antialiased() {
let img = test_image();
let fx = puppet_fx(&[(PinKind::Position, [14.0, 30.0], [14.0, 30.0]), (PinKind::Position, [50.0, 30.0], [45.0, 10.0])]);
let a = run(&fx, img.clone());
let b = run(&fx, img);
assert_eq!(a.img, b.img);
assert_eq!(a.offset, b.offset);
assert!(a.img.data.iter().any(|p| p[3] > 0.05 && p[3] < 0.95), "expected soft edges");
assert!(a.img.data.iter().all(|p| p[3] <= 1.0001 && p.iter().all(|c| c.is_finite())));
}
#[test]
fn unmap_inverts_the_deformation() {
let m = rect_mesh(100.0, 40.0, 150.0);
let pins = [pin(PinKind::Position, [0.0, 20.0], [10.0, 30.0]), pin(PinKind::Position, [100.0, 20.0], [90.0, 0.0])];
let d = solve(&m, &pins, 1);
let q = unmap(&m, &d, d[5]);
assert!((q[0] - m.verts[5][0]).abs() < 1e-6 && (q[1] - m.verts[5][1]).abs() < 1e-6);
}
#[test]
fn groups_round_trip_serde() {
let fx = puppet_fx(&[(PinKind::Position, [1.0, 2.0], [3.0, 4.0]), (PinKind::Starch, [5.0, 5.0], [5.0, 5.0])]);
let j = serde_json::to_string(&fx).unwrap();
let back: PropGroup = serde_json::from_str(&j).unwrap();
assert_eq!(back, fx);
assert_eq!(pin_count(&back), 2);
let params = flatten_params(&back, &mut |p| p.value.clone());
let m = parse(&params);
assert_eq!(m.len(), 1);
assert_eq!(m[0].pins.len(), 2);
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");
}
}
}