Skip to content

Commit 27e0c82

Browse files
committed
feat(render): contralateral arm swing during locomotion (L4.2)
1 parent 7734730 commit 27e0c82

3 files changed

Lines changed: 73 additions & 2 deletions

File tree

packages/posecode-render/src/contacts.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,38 @@ export function relaxHands(
176176
}
177177
if (changed) m.root.updateMatrixWorld(true);
178178
}
179+
180+
/** Fraction of the contralateral hip's sagittal angle carried into arm swing. */
181+
export const SWING_GAIN = 0.4;
182+
const SWING_EULER = new THREE.Euler();
183+
const HIP_EULER = new THREE.Euler();
184+
185+
/**
186+
* Contralateral arm swing: during locomotion the arms counter-swing to the legs
187+
* (right leg forward ↔ left arm forward). Adds a swing to each free shoulder
188+
* proportional to the OPPOSITE hip's sagittal (local X) angle, so any move that
189+
* animates the hips (walk, march, box-step) gets natural arm swing for free.
190+
* Skips shoulders the document authors and any gripping side.
191+
*/
192+
export function swingArms(
193+
m: Mannequin,
194+
authoredShoulders: ReadonlySet<string>,
195+
gripSides: ReadonlySet<"left" | "right">,
196+
): void {
197+
let changed = false;
198+
for (const side of ["left", "right"] as const) {
199+
if (gripSides.has(side)) continue;
200+
const shoulderId = `shoulder_${side}`;
201+
if (authoredShoulders.has(shoulderId)) continue;
202+
const shoulder = m.bones.get(shoulderId);
203+
const contraHip = m.bones.get(`hip_${side === "left" ? "right" : "left"}`);
204+
if (!shoulder || !contraHip) continue;
205+
HIP_EULER.setFromQuaternion(contraHip.quaternion, "XYZ");
206+
if (Math.abs(HIP_EULER.x) < 1e-3) continue; // legs still → no swing
207+
SWING_EULER.setFromQuaternion(shoulder.quaternion, "XYZ");
208+
SWING_EULER.x += HIP_EULER.x * SWING_GAIN;
209+
shoulder.quaternion.setFromEuler(SWING_EULER);
210+
changed = true;
211+
}
212+
if (changed) m.root.updateMatrixWorld(true);
213+
}

packages/posecode-render/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
type ClipSource,
2828
} from "./clips.js";
2929
import { depenetrate } from "./depenetrate.js";
30-
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands } from "./contacts.js";
30+
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms } from "./contacts.js";
3131

3232
const DEG = Math.PI / 180;
3333

@@ -270,6 +270,8 @@ export function createViewer(
270270
// Finger bones the loaded document explicitly poses (make-a-fist, finger-spell,
271271
// hand-wave): the L4.1 resting-hand curl leaves these alone.
272272
let authoredFingers = new Set<string>();
273+
// Shoulders the document poses: L4.2 arm-swing leaves these to the author.
274+
let authoredShoulders = new Set<string>();
273275
// The last loaded document, kept so the viewer can re-solve base pose and
274276
// ground anchors when the character (with its own proportions) arrives.
275277
let lastIR: PosecodeIR | null = null;
@@ -596,6 +598,8 @@ export function createViewer(
596598
// lower-body poses (squat, lunge, deadlift) don't balance on the toes.
597599
// Runs before the floor clamp so the leveled sole is what rests on y=0.
598600
levelPlantedFeet(mannequin, info.groundLock);
601+
// L4.2 aliveness: contralateral arm swing during locomotion (free arms only).
602+
swingArms(mannequin, authoredShoulders, gripSidesOf(info.grips));
599603
// L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
600604
relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
601605
// Safety net: nothing above ever intentionally pushes part of the body
@@ -697,6 +701,8 @@ export function createViewer(
697701
groundFigureOf(mannequin);
698702
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
699703
authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId));
704+
authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_")));
705+
swingArms(mannequin, authoredShoulders, gripSidesOf(ir.phases[0]?.grips ?? []));
700706
relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
701707
captureGroundTargets();
702708
baseRootPos.copy(mannequin.root.position);

packages/posecode-render/test/contacts.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import * as THREE from "three";
33
import { buildMannequin } from "../src/mannequin.js";
4-
import { levelPlantedFeet, relaxHands } from "../src/contacts.js";
4+
import { levelPlantedFeet, relaxHands, swingArms } from "../src/contacts.js";
55
import { groundFigure } from "../src/groundlock.js";
66

77
const DEG = Math.PI / 180;
@@ -75,3 +75,33 @@ describe("relaxHands (L4.1)", () => {
7575
expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(1.4, 5);
7676
});
7777
});
78+
79+
describe("swingArms (L4.2)", () => {
80+
it("adds contralateral arm swing when a hip is flexed and the arm is free", () => {
81+
const m = buildMannequin();
82+
// Flex the right hip forward (walking: right leg forward → left arm forward).
83+
m.bones.get("hip_right")!.rotation.x = -0.6;
84+
const before = m.bones.get("shoulder_left")!.rotation.x;
85+
swingArms(m, new Set(), new Set());
86+
const after = m.bones.get("shoulder_left")!.rotation.x;
87+
expect(Math.abs(after - before)).toBeGreaterThan(0.05); // swung
88+
// swings the same sagittal direction as the contralateral hip (forward)
89+
expect(Math.sign(after - before)).toBe(Math.sign(-0.6));
90+
});
91+
92+
it("respects an authored shoulder (no swing)", () => {
93+
const m = buildMannequin();
94+
m.bones.get("hip_right")!.rotation.x = -0.6;
95+
m.bones.get("shoulder_left")!.rotation.x = 0.9; // authored
96+
swingArms(m, new Set(["shoulder_left"]), new Set());
97+
expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(0.9, 5);
98+
});
99+
100+
it("skips a gripping side", () => {
101+
const m = buildMannequin();
102+
m.bones.get("hip_right")!.rotation.x = -0.6;
103+
const before = m.bones.get("shoulder_left")!.rotation.x;
104+
swingArms(m, new Set(), new Set(["left"]));
105+
expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(before, 5);
106+
});
107+
});

0 commit comments

Comments
 (0)