Skip to content

Commit 8c444b4

Browse files
committed
Fix inverted frontal plane: abduction swung limbs through the torso
`abduct`'s base Z sign was +1, but with every bone resting along -Y and the body's right side at -X, a positive-Z rotation carries the unmirrored (right-side) limb TOWARD the midline — and the left mirror flipped it into the same mistake — so `shoulders: abduct 80` folded both arms across the chest and `hips: abduct` crossed the legs. Every frontal-plane example (lateral raise, jumping jacks, port de bras, arms-to-second, horse stance, hip abduction) rendered mirrored, and the thumb pinch adducted away from the fingers. Flip abduct to -Z / adduct to +Z so positive abduction moves away from the midline on both sides, matching what the spec already promised ("away from / toward midline"). The axial spine/neck lateral-flex direction flips with it, so swap the actions in neck-side-stretch whose cues name a side. Regression test checks all four limbs end up further from the midline than their proximal joints, on the same side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0164eTtmmq6U8GQkAEmQsvV5
1 parent 99363f3 commit 8c444b4

3 files changed

Lines changed: 53 additions & 9 deletions

File tree

packages/movit-parser/src/joints.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
* Coordinate convention (must match the renderer's rig — see spec/SPEC.md):
55
* Rest pose: standing, arms at sides, facing +Z. Each bone rotates in its
66
* own local frame where
7-
* X = sagittal plane (flexion +, extension -)
8-
* Y = longitudinal (internal rotation +, external -)
9-
* Z = frontal plane (abduction +, adduction -)
10-
* Left-side bones mirror the Y and Z axes so symmetric cues look symmetric.
7+
* X = sagittal plane (flexion / extension)
8+
* Y = longitudinal (internal / external rotation)
9+
* Z = frontal plane (abduction / adduction)
10+
* The unmirrored sign of each action is the RIGHT side's (the body's right
11+
* is -X); left-side bones mirror the Y and Z axes so symmetric cues look
12+
* symmetric — `shoulders: abduct 80` lifts both arms away from the midline.
1113
*/
1214

1315
import type { Axis } from "./types.js";
@@ -134,8 +136,14 @@ export interface ActionAxis {
134136
const ACTIONS: Record<string, ActionAxis> = {
135137
flex: { axis: "x", sign: 1 },
136138
extend: { axis: "x", sign: -1 },
137-
abduct: { axis: "z", sign: 1 },
138-
adduct: { axis: "z", sign: -1 },
139+
// Frontal plane. The unmirrored sign is the RIGHT side's; with every bone
140+
// resting along -Y and the right side of the body at -X, carrying a limb
141+
// AWAY from the midline (abduction) is a -Z rotation. The left side mirrors
142+
// to +Z. (Base sign +1 here was a bug that swung both arms and both legs
143+
// through the torso.) For the unmirrored axial bones (spine/neck) abduct
144+
// reads as lateral flexion toward the person's left.
145+
abduct: { axis: "z", sign: -1 },
146+
adduct: { axis: "z", sign: 1 },
139147
"rotate-in": { axis: "y", sign: 1 },
140148
"rotate-out": { axis: "y", sign: -1 },
141149
supinate: { axis: "y", sign: 1 },

packages/movit-render/test/render.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,3 +458,39 @@ describe("ROM-constrained reach-IK", () => {
458458
expect(Math.abs(e.z)).toBeLessThan(1e-6);
459459
});
460460
});
461+
462+
describe("frontal plane direction", () => {
463+
it("abduction carries arms and legs AWAY from the midline on both sides", () => {
464+
const { ir, errors } = parse(
465+
[
466+
'movit exercise "Open"',
467+
" rig humanoid",
468+
" pose start = standing",
469+
' step "Open" 1s linear:',
470+
" shoulders: abduct 80",
471+
" hips: abduct 30",
472+
" repeat 1",
473+
].join("\n"),
474+
);
475+
expect(errors).toEqual([]);
476+
const tl = buildTimeline(ir!);
477+
const m = buildMannequin();
478+
tl.sample(1, m.bones);
479+
m.root.updateMatrixWorld(true);
480+
481+
// Each distal joint must sit FURTHER from the midline (|x|) than its
482+
// proximal joint, on the SAME side — the inverted sign swung all four
483+
// limbs across the body instead.
484+
for (const [distal, proximal] of [
485+
["wrist_left", "shoulder_left"],
486+
["wrist_right", "shoulder_right"],
487+
["ankle_left", "hip_left"],
488+
["ankle_right", "hip_right"],
489+
] as const) {
490+
const d = m.bones.get(distal)!.getWorldPosition(new THREE.Vector3());
491+
const p = m.bones.get(proximal)!.getWorldPosition(new THREE.Vector3());
492+
expect(Math.sign(d.x)).toBe(Math.sign(p.x));
493+
expect(Math.abs(d.x)).toBeGreaterThan(Math.abs(p.x));
494+
}
495+
});
496+
});

spec/examples/neck-side-stretch.movit

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ movit stretch "Neck side stretch"
33
pose start = standing
44

55
step "Ear to right" 3s ease-in-out:
6-
neck: abduct 40
6+
neck: adduct 40
77
ground-lock: feet
88
cue "Gently take the right ear toward the right shoulder"
99

1010
step "Ear to left" 3.4s ease-in-out:
11-
neck: adduct 40
11+
neck: abduct 40
1212
ground-lock: feet
1313
cue "Pass through center and lengthen to the left"
1414

1515
step "Center" 2s ease-out:
16-
neck: adduct 0
16+
neck: abduct 0
1717
ground-lock: feet
1818
cue "Float the head back to neutral"
1919

0 commit comments

Comments
 (0)