Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .changeset/heroic-owls-land.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@
"posecode-mcp": patch
---

Strengthen Posecode motion authoring and playback with strict contact validation, grounded multi-contact solving, continuous sparse transitions, and more lifelike canonical movement guidance.
Strengthen Posecode motion authoring and playback with strict contact validation,
grounded multi-contact solving, continuous sparse transitions, explicit forearm
roll guidance, natural relaxed hands, a mobile-safe phase rail, and more lifelike
canonical movements.
78 changes: 78 additions & 0 deletions packages/posecode-eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
kneeFlexionDeg,
lowestPoint,
palmFloorAngleDeg,
palmForwardAngleDeg,
palmInwardAngleDeg,
palmUpAngleDeg,
phaseMaxLandmarkSpeed,
propPenetrationDepth,
segmentTiltDeg,
Expand Down Expand Up @@ -535,6 +538,81 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
(v) => v > 1.0,
"wrist > 1.0m",
),
phaseCheck(
"left-palm-up",
"Curl",
(p) => palmUpAngleDeg(p, "left"),
(v) => v < 55,
"< 55° from palm-up at peak flexion",
),
phaseCheck(
"right-palm-up",
"Curl",
(p) => palmUpAngleDeg(p, "right"),
(v) => v < 55,
"< 55° from palm-up at peak flexion",
),
phaseCheck(
"left-palm-forward-at-bottom",
"Lower",
(p) => palmForwardAngleDeg(p, "left"),
(v) => v < 25,
"< 25° from forward",
),
phaseCheck(
"right-palm-forward-at-bottom",
"Lower",
(p) => palmForwardAngleDeg(p, "right"),
(v) => v < 25,
"< 25° from forward",
),
],
},
{
movement: "jab-cross",
checks: [
phaseCheck(
"jab-palm-down",
"Jab",
(p) => palmFloorAngleDeg(p, "left"),
(v) => v < 35,
"< 35° from palm-down",
),
phaseCheck(
"cross-palm-down",
"Cross",
(p) => palmFloorAngleDeg(p, "right"),
(v) => v < 35,
"< 35° from palm-down",
),
phaseCheck(
"lead-guard-palm-in",
"Recoil cross",
(p) => palmInwardAngleDeg(p, "left"),
(v) => v < 20,
"< 20° from inward",
),
phaseCheck(
"rear-guard-palm-in",
"Recoil cross",
(p) => palmInwardAngleDeg(p, "right"),
(v) => v < 20,
"< 20° from inward",
),
phaseCheck(
"lead-guard-high",
"Recoil cross",
(p) => heightOf(p, "wrist_left"),
(v) => v > 1.45,
"> 1.45m",
),
phaseCheck(
"rear-guard-high",
"Recoil cross",
(p) => heightOf(p, "wrist_right"),
(v) => v > 1.45,
"> 1.45m",
),
],
},
{
Expand Down
3 changes: 3 additions & 0 deletions packages/posecode-eval/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export {
kneeFlexionDeg,
lowestPoint,
palmFloorAngleDeg,
palmForwardAngleDeg,
palmInwardAngleDeg,
palmUpAngleDeg,
phaseMaxLandmarkSpeed,
segmentTiltDeg,
soleUpAngleDeg,
Expand Down
25 changes: 25 additions & 0 deletions packages/posecode-eval/src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ export function palmFloorAngleDeg(pose: PhasePose, side: "left" | "right"): numb
return angleBetweenDeg(rotateByQuat([0, 0, 1], q), [0, -1, 0]);
}

/** Angle between the palm face normal and world-up (0 = palm facing up). */
export function palmUpAngleDeg(pose: PhasePose, side: "left" | "right"): number {
const q = pose.boneQuaternions.get(`wrist_${side}`);
if (!q) return 180;
return angleBetweenDeg(rotateByQuat([0, 0, 1], q), [0, 1, 0]);
}

/** Angle between the palm face normal and character-forward (+Z at zero yaw). */
export function palmForwardAngleDeg(pose: PhasePose, side: "left" | "right"): number {
const q = pose.boneQuaternions.get(`wrist_${side}`);
if (!q) return 180;
const forward: Vec3 = [Math.sin(pose.rootYaw), 0, Math.cos(pose.rootYaw)];
return angleBetweenDeg(rotateByQuat([0, 0, 1], q), forward);
}

/** Angle between a palm and the body's lateral midline direction. */
export function palmInwardAngleDeg(pose: PhasePose, side: "left" | "right"): number {
const q = pose.boneQuaternions.get(`wrist_${side}`);
if (!q) return 180;
const inward = side === "left"
? sub(bone(pose, "shoulder_right"), bone(pose, "shoulder_left"))
: sub(bone(pose, "shoulder_left"), bone(pose, "shoulder_right"));
return angleBetweenDeg(rotateByQuat([0, 0, 1], q), inward);
}

/** Angle between the semantic fist's knuckle direction and floor-down. */
export function fistFloorAngleDeg(pose: PhasePose, side: "left" | "right"): number {
const q = pose.boneQuaternions.get(`wrist_${side}`);
Expand Down
5 changes: 3 additions & 2 deletions packages/posecode-language/src/hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,13 @@ export function getHover(
if (bone && token !== "hold") {
const rom = romFor(bone, token);
if (rom) {
const detail = KEYWORD_DOCS[token];
return md(
`**${boneType(bone)} · ${token}**: configured range **${rom.min}–${rom.max}°**. Angles beyond this are clamped with a diagnostic.`,
`**${boneType(bone)} · ${token}**: configured range **${rom.min}–${rom.max}°**. Angles beyond this are clamped with a diagnostic.${detail ? ` ${detail}` : ""}`,
);
}
}
return md(`Action **${token}**.`);
return md(`Action **${token}**.${KEYWORD_DOCS[token] ? ` ${KEYWORD_DOCS[token]}` : ""}`);
}

if (JOINT_NAMES.includes(token)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/posecode-language/src/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,6 @@ export const KEYWORD_DOCS: Record<string, string> = {
travel: "Moves the figure across the floor: `travel: 0.4 0` (world x z metres from the start spot). Absolute, carried across phases. Standing poses only.",
cue: "A short coaching cue shown while this phase plays.",
hold: "Reset every rotation channel on this joint to its neutral / rest angle: `<joint>: hold neutral`.",
pronate: "Rolls the forearm toward palm-down. With upright arms at the sides, about 80° faces the palm inward toward the thigh; final world facing also depends on the arm pose.",
supinate: "Rolls the forearm in the palm-up direction; final world facing also depends on the shoulder and elbow pose.",
};
11 changes: 10 additions & 1 deletion packages/posecode-language/test/language.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("getCompletions", () => {

it("suggests joints (and child keywords) at the start of an indented line", () => {
const l = onLine(" ", 4);
expect(l).toEqual(expect.arrayContaining(["knees", "elbows"]));
expect(l).toEqual(expect.arrayContaining(["knees", "elbows", "forearms"]));
expect(l).toContain("cue");
});

Expand Down Expand Up @@ -87,6 +87,15 @@ describe("getCompletions", () => {
expect(chestActions).not.toContain("rotate-out");
});

it("offers and explains anatomical forearm rotation", () => {
expect(onLine(" forearms: ", 14)).toEqual(
expect.arrayContaining(["pronate", "supinate"]),
);
const line = " forearms: pronate 80";
const hover = getHover(line, 0, line.indexOf("pronate") + 1);
expect(hover?.contents).toContain("thigh");
});

it("suggests timing modes inside a step header", () => {
expect(onLine(' step "y" 2s ', 14)).toEqual(
expect.arrayContaining(["flow", "settle", "linear"]),
Expand Down
8 changes: 6 additions & 2 deletions packages/posecode-mcp/src/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,13 @@ posecode <kind> "<Name>" # kind = exercise | stretch | posture
\`\`\`

Joints: neck head spine chest pelvis, and (singular or plural) shoulders elbows
wrists hips knees ankles. Actions (degrees are absolute targets): flex/extend,
forearms wrists hips knees ankles. \`forearms\` aliases the elbow bones for palm
roll. Actions (degrees are absolute targets): flex/extend,
abduct/adduct, rotate-in/rotate-out (shoulder/hip), twist-left/twist-right
(axial joints), dorsiflex/plantarflex, hold neutral, and hinge (pelvis only).
(axial joints), supinate/pronate (forearm roll), dorsiflex/plantarflex, hold
neutral, and hinge (pelvis only). With upright arms at the sides,
\`forearms: pronate 80\` faces the palms inward toward the thighs.
At zero degrees, \`pronate 0\` and \`supinate 0\` are the same absolute target.
Use only joint/action pairs and declared prop anchors accepted by the validator.
Author the gross pose before reach; a parsed reach is not proof of contact.
Keep cues, sides, and declared contacts consistent through every phase. Floor
Expand Down
9 changes: 7 additions & 2 deletions packages/posecode-parser/src/joints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ const FINGERS_RIGHT: BoneId[] = [
const GROUPS: Record<string, BoneId[]> = {
shoulders: ["shoulder_left", "shoulder_right"],
elbows: ["elbow_left", "elbow_right"],
// Anatomical authoring alias: forearm axial rotation lives on the elbow
// bone in the rig, but `forearms: pronate 80` is clearer to authors.
forearms: ["elbow_left", "elbow_right"],
wrists: ["wrist_left", "wrist_right"],
hips: ["hip_left", "hip_right"],
knees: ["knee_left", "knee_right"],
Expand Down Expand Up @@ -196,8 +199,10 @@ const ACTIONS: Record<string, ActionAxis> = {
// toward the person's left (+X), and -Y turns it right (-X).
"twist-left": { axis: "y", sign: 1 },
"twist-right": { axis: "y", sign: -1 },
supinate: { axis: "y", sign: 1 },
pronate: { axis: "y", sign: -1 },
// From the palm-forward driver rest, pronation turns each palm toward its
// own thigh. The left-side mirror in clamp.ts supplies the opposite sign.
supinate: { axis: "y", sign: -1 },
pronate: { axis: "y", sign: 1 },
// The foot points FORWARD (+Z): lifting the toes toward the shin
// (dorsiflexion) is a -X rotation, pointing them is +X.
dorsiflex: { axis: "x", sign: -1 },
Expand Down
7 changes: 4 additions & 3 deletions packages/posecode-parser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ export interface ReachTarget {

/**
* A contact pin: translate the whole figure so `effector` sits on a fixed
* `anchor` (a prop anchor, a landmark, or `floor`). Unlike a reach (which moves
* the limb to a target) a pin moves the BODY, so the figure can hang from a bar,
* rise onto a box, or lower into a dip while the contact stays put.
* world `anchor` (a declared prop anchor or `floor`). Unlike a reach (which
* moves the limb to a target) a pin moves the BODY, so a body landmark cannot
* serve as its anchor: that landmark would move with the same root. Pins let the
* figure hang from a bar, rise onto a box, or keep one floor support fixed.
*/
export interface PinTarget {
effector: string;
Expand Down
19 changes: 19 additions & 0 deletions packages/posecode-parser/test/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ describe("parse", () => {
expect(elbowL.euler.x).toBe(-90);
});

it("accepts `forearms` as the anatomical alias for palm rotation", () => {
const { ir, errors } = parse([
'posecode posture "Palms inward"',
" rig humanoid",
' step "Turn palms" 1s settle:',
" forearms: pronate 80",
].join("\n"));
expect(errors).toEqual([]);
const targets = ir!.phases[0]!.targets;
expect(targets.find((target) => target.boneId === "elbow_left")).toMatchObject({
euler: { y: -80 },
axes: ["y"],
});
expect(targets.find((target) => target.boneId === "elbow_right")).toMatchObject({
euler: { y: 80 },
axes: ["y"],
});
});

it("clamps out-of-range angles and records a warning", () => {
const src = [
'posecode exercise "Bad knee"',
Expand Down
4 changes: 2 additions & 2 deletions packages/posecode-parser/test/rom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ describe("euler ROM boxes (eulerRomFor)", () => {
const right = eulerRomFor("elbow_right")!;
const left = eulerRomFor("elbow_left")!;
// supinate 92 / pronate 84 flip sides under the mirror.
expect(right.y).toEqual({ min: -84, max: 92 });
expect(left.y).toEqual({ min: -92, max: 84 });
expect(right.y).toEqual({ min: -92, max: 84 });
expect(left.y).toEqual({ min: -84, max: 92 });
// X (sagittal) is never mirrored.
expect(left.x).toEqual(right.x);
});
Expand Down
16 changes: 16 additions & 0 deletions packages/posecode-parser/test/strict-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,22 @@ describe("contact namespace", () => {
expect(parse(crossed).errors[0]?.message).toMatch(/does not match/i);
});

it("expands a valid grouped grip onto distinct declared side anchors", () => {
const source = [
'posecode exercise "Grip"',
" rig humanoid",
" prop bar",
' step "Hold" 1s settle:',
" grip: hands bar",
].join("\n");
const result = parse(source);
expect(result.errors).toEqual([]);
expect(result.ir?.phases[0]?.grips).toEqual([
{ effector: "hand_left", anchor: "bar_left" },
{ effector: "hand_right", anchor: "bar_right" },
]);
});

it("rejects competing whole-root contact solvers in one step", () => {
const result = parse(doc("pin: knee_left floor", "ground-lock: foot_right"));
expect(result.ir).toBeNull();
Expand Down
Loading