Bend the torso from the hips and add bow and twerk test animations

- LimbBend.anchoredAtEnd keeps the far end in place (the hips on the
  legs) and folds the other half, and angleAt/axis expose how far the
  limb has turned at a point.
- PoseApplier bends the body from the hips and carries the head and arms
  with it: their pivots follow the bent torso and they turn with its ring.
- Test animations: bow (torso only) and twerk. The gametest shoots the
  twerk at the top and bottom of its bounce, including from behind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 19:57:50 +02:00
co-authored by claude
parent 8bac5eef64
commit 34aba293b6
6 changed files with 252 additions and 27 deletions
@@ -11,6 +11,9 @@ package org.saturnclient.emotes.core.bend;
* <p>The spine is split into {@code segments} rings, spaced evenly by arc length so the limb keeps its
* length and each piece covers the same texture rows as before. Each ring moves its cross-section
* rigidly, so neighbouring pieces share their vertices and the mesh stays closed however far it bends.
*
* <p>A limb is normally anchored at {@code start}, its pivot. The torso is anchored at {@code end}
* instead: the hips stay on the legs and the upper half leans (see {@link #anchoredAtEnd}).
*/
public final class LimbBend {
/** Samples used to measure the curve's arc length. */
@@ -18,6 +21,8 @@ public final class LimbBend {
private final float start;
private final float length;
/** Whether the fixed end is {@code end}. Then everything is mirrored along Y around the middle. */
private final boolean anchoredAtEnd;
private final int segments;
private final float angle;
/** Unit bend direction D in the XZ plane, and the rotation axis n = Y × D. */
@@ -37,6 +42,20 @@ public final class LimbBend {
* @param segments number of pieces the limb is divided into
*/
public LimbBend(float start, float end, float angle, float axis, float sharpness, int segments) {
this(start, end, angle, axis, sharpness, segments, false);
}
/**
* Like {@link #LimbBend(float, float, float, float, float, int)}, but {@code end} stays in place and
* the half towards {@code start} folds. Axis 0 still folds towards the front.
*/
public static LimbBend anchoredAtEnd(float start, float end, float angle, float axis, float sharpness,
int segments) {
return new LimbBend(start, end, angle, axis, sharpness, segments, true);
}
private LimbBend(float start, float end, float angle, float axis, float sharpness, int segments,
boolean anchoredAtEnd) {
if (end <= start) {
throw new IllegalArgumentException("end must be below start");
}
@@ -45,6 +64,7 @@ public final class LimbBend {
}
this.start = start;
this.length = end - start;
this.anchoredAtEnd = anchoredAtEnd;
this.segments = segments;
this.angle = angle;
this.dirX = (float) Math.sin(axis);
@@ -138,6 +158,43 @@ public final class LimbBend {
* @param out receives the bent position as {x, y, z}
*/
public void bendPoint(float x, float y, float z, float[] out) {
if (anchoredAtEnd) {
bendPointFromStart(x, mirror(y), z, out);
out[1] = mirror(out[1]);
} else {
bendPointFromStart(x, y, z, out);
}
}
/** Rotates a direction (a face normal) the same way {@link #bendPoint} rotates points at {@code atY}. */
public void bendDirection(float atY, float nx, float ny, float nz, float[] out) {
if (anchoredAtEnd) {
rotate(nx, -ny, nz, angleFromStart(mirror(atY)), out);
out[1] = -out[1];
} else {
rotate(nx, ny, nz, angleFromStart(atY), out);
}
}
/**
* How far the limb has turned at {@code y}, as a rotation around {@link #axis()} by this angle.
* Parts attached to the limb at {@code y} (like the head and arms on the torso) turn by this much.
*/
public float angleAt(float y) {
// Mirroring along Y turns a rotation around a horizontal axis the other way.
return anchoredAtEnd ? -angleFromStart(mirror(y)) : angleFromStart(y);
}
/** The unit axis the limb turns around. It's horizontal, at right angles to the bend direction. */
public Vec3 axis() {
return new Vec3(axisX, 0, axisZ);
}
private float mirror(float y) {
return 2 * start + length - y;
}
private void bendPointFromStart(float x, float y, float z, float[] out) {
float along = (y - start) / length * segments;
int ring;
float f;
@@ -168,11 +225,10 @@ public final class LimbBend {
out[2] += cz;
}
/** Rotates a direction (a face normal) the same way {@link #bendPoint} rotates points at {@code atY}. */
public void bendDirection(float atY, float nx, float ny, float nz, float[] out) {
float along = Math.max(0, Math.min(segments, (atY - start) / length * segments));
private float angleFromStart(float y) {
float along = Math.max(0, Math.min(segments, (y - start) / length * segments));
int ring = Math.min((int) along, segments - 1);
rotate(nx, ny, nz, lerp(ringAngle[ring], ringAngle[ring + 1], along - ring), out);
return lerp(ringAngle[ring], ringAngle[ring + 1], along - ring);
}
/** Rodrigues' rotation of v around the bend axis n by {@code a}. */
@@ -110,4 +110,66 @@ class LimbBendTest {
arm(HALF_PI, 0).bendDirection(10, 0, 0, -1, normal);
assertArrayEquals(new float[] { 0, -1, 0 }, normal, 1e-3f);
}
/** A torso: box from y = 0 (neck) to 12 (hips), anchored at the hips. */
private static LimbBend torso(float angle, float axis) {
return LimbBend.anchoredAtEnd(0, 12, angle, axis, 0.5f, 12);
}
@Test
void torsoKeepsItsHipsInPlace() {
assertArrayEquals(new float[] { 3, 12, -1 }, bend(torso(1, 0), 3, 12, -1), 1e-4f);
}
@Test
void torsoLeansForward() {
// Axis 0 still means forward: the neck moves to the front (-Z) and down (+Y).
float[] neck = bend(torso(HALF_PI, 0), 0, 0, 0);
assertTrue(neck[2] < -4, "neck should be in front, z = " + neck[2]);
assertTrue(neck[1] > 4, "neck should drop, y = " + neck[1]);
}
@Test
void torsoKeepsItsLength() {
LimbBend bend = torso(HALF_PI, 0);
float total = 0;
float[] previous = bend(bend, 0, 0, 0);
for (int i = 1; i <= 12; i++) {
float[] point = bend(bend, 0, i, 0);
total += distance(previous, point);
previous = point;
}
assertEquals(12, total, 0.05);
}
@Test
void attachedPartsTurnWithTheRing() {
// The offset from a ring's center to a point on it turns by angleAt around axis.
for (LimbBend bend : new LimbBend[] { arm(1.3f, 0.4f), torso(1.3f, 0.4f) }) {
for (float y : new float[] { -2, 0, 4, 10, 12 }) {
float[] center = bend(bend, 0, y, 0);
float[] point = bend(bend, 1, y, -2);
float[] expected = rotate(new float[] { 1, 0, -2 }, bend.axis(), bend.angleAt(y));
assertArrayEquals(expected,
new float[] { point[0] - center[0], point[1] - center[1], point[2] - center[2] }, 1e-3f);
}
}
}
@Test
void torsoNormalsTurnWithTheRing() {
// A forward lean tips the front face (-Z) of the chest towards the ground (+Y).
float[] normal = new float[3];
torso(HALF_PI, 0).bendDirection(0, 0, 0, -1, normal);
assertArrayEquals(new float[] { 0, 1, 0 }, normal, 1e-3f);
}
/** Rodrigues' rotation of v around the unit axis n. */
private static float[] rotate(float[] v, Vec3 n, float angle) {
Vec3 vec = new Vec3(v[0], v[1], v[2]);
float cos = (float) Math.cos(angle);
float sin = (float) Math.sin(angle);
Vec3 r = vec.scale(cos).add(n.cross(vec).scale(sin)).add(n.scale(n.dot(vec) * (1 - cos)));
return new float[] { r.x(), r.y(), r.z() };
}
}