Skip to content

Commit f1182f3

Browse files
committed
feat: add hip-hinge primitive and fix torso flexion direction
- hips: hinge <deg> — closed-chain hip flexion (deadlift/forward fold): pelvis pitches forward, hips counter-rotate, legs stay vertical - fix torso-chain flexion sign: spine/chest/neck/pelvis rest along +Y, so they bent backward (-Z) while limbs flexed forward (+Z) - pelvis ROM entries (tilt); hinge clamped by hip-flexion ROM - new examples: deadlift, true forward fold; roll-down keeps its own file - teach hinge everywhere: SPEC, llm-authoring, language vocab, MCP guide, VS Code grammar (rebuilt bundle) - repair broken typecheck script (root tsc -b had no tsconfig) + fix landing.ts narrowing error it uncovered
1 parent 0bd60c7 commit f1182f3

16 files changed

Lines changed: 317 additions & 27 deletions

File tree

editors/vscode/syntaxes/movit.tmLanguage.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
},
3838
"actions": {
3939
"name": "keyword.operator.movit",
40-
"match": "\\b(flex|extend|abduct|adduct|rotate-in|rotate-out|supinate|pronate|dorsiflex|plantarflex)\\b"
40+
"match": "\\b(flex|extend|abduct|adduct|rotate-in|rotate-out|supinate|pronate|dorsiflex|plantarflex|hinge)\\b"
4141
},
4242
"joints": {
4343
"name": "variable.other.movit",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"coverage": "vitest run --coverage",
1616
"dev": "npm run dev -w playground",
1717
"build": "npm run build -w playground",
18-
"typecheck": "tsc -b --pretty"
18+
"typecheck": "tsc --noEmit -p packages/movit-parser && tsc --noEmit -p packages/movit-share && tsc --noEmit -p packages/movit-render && tsc --noEmit -p packages/movit-language && tsc --noEmit -p packages/movit-lsp && tsc --noEmit -p packages/movit-mcp && tsc --noEmit -p playground && tsc --noEmit -p editors/vscode"
1919
},
2020
"devDependencies": {
2121
"@vitest/coverage-v8": "^2.1.8",

packages/movit-language/src/vocab.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
3434
"ground-lock": "Pins effectors (hands / feet) to the floor for this phase.",
3535
cue: "A short coaching cue shown while this phase plays.",
3636
hold: "Keep the joint at its neutral / rest angle.",
37+
hinge:
38+
"Hips only: closed-chain hip flexion — the torso tips over planted feet " +
39+
"with a neutral spine (deadlift / forward fold).",
3740
};

packages/movit-mcp/src/guide.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,7 @@ movit <kind> "<Name>" # kind = exercise | stretch | posture
4444
4545
Joints: neck head spine chest pelvis, and (singular or plural) shoulders elbows
4646
wrists hips knees ankles. Actions (degrees are absolute targets): flex/extend,
47-
abduct/adduct, rotate-in/rotate-out, dorsiflex/plantarflex, hold neutral.
47+
abduct/adduct, rotate-in/rotate-out, dorsiflex/plantarflex, hold neutral, and
48+
hinge (hips only — closed-chain hip flexion: torso tips over planted feet with
49+
a neutral spine; use for deadlift / forward fold instead of hips: flex).
4850
Stay within healthy range of motion; the renderer hard-clamps anything beyond.`;

packages/movit-parser/src/clamp.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ import type {
1717
} from "./types.js";
1818
import { MOVIT_VERSION } from "./types.js";
1919
import type { AstDoc, AstStep } from "./parser.js";
20-
import { actionAxis, boneType, expandJoint, flexionSign, isLeft } from "./joints.js";
20+
import {
21+
HINGE_ACTION,
22+
actionAxis,
23+
boneType,
24+
expandJoint,
25+
flexionSign,
26+
isLeft,
27+
} from "./joints.js";
2128
import { clampAngle, romFor } from "./rom.js";
2229

2330
export interface ResolveResult {
@@ -70,6 +77,13 @@ function resolveStep(
7077
continue;
7178
}
7279

80+
// `hinge` is closed-chain and multi-bone (pelvis + both hips), so it
81+
// can't go through the single-axis action table below.
82+
if (target.action === HINGE_ACTION) {
83+
resolveHinge(target, step.name, byBone, warnings, errors);
84+
continue;
85+
}
86+
7387
const aa = actionAxis(target.action);
7488
if (!aa) {
7589
errors.push({ line: target.line, message: `unknown action: "${target.action}"` });
@@ -123,6 +137,55 @@ function resolveStep(
123137
};
124138
}
125139

140+
/**
141+
* Resolve `hips: hinge θ` — the closed-chain hip flexion of a deadlift or
142+
* forward fold. Open-chain hip flexion swings the free leg forward; with the
143+
* feet planted, the same joint motion instead tips the torso over the femurs.
144+
* The rig expresses that as: rotate the pelvis forward by θ (the whole upper
145+
* body pitches, spine staying neutral) and counter-rotate both hips by θ so
146+
* the legs remain a vertical column. Clamped by hip-flexion ROM.
147+
*/
148+
function resolveHinge(
149+
target: AstStep["targets"][number],
150+
phaseName: string,
151+
byBone: Map<string, EulerDeg>,
152+
warnings: Warning[],
153+
errors: ParseError[],
154+
): void {
155+
if (target.joint !== "hips") {
156+
errors.push({
157+
line: target.line,
158+
message: `action "hinge" only applies to "hips" (got "${target.joint}")`,
159+
});
160+
return;
161+
}
162+
if (target.degrees === null) {
163+
errors.push({ line: target.line, message: `action "hinge" requires an angle` });
164+
return;
165+
}
166+
167+
const hipBones = expandJoint("hips");
168+
let clamped = target.degrees;
169+
for (const bone of hipBones) {
170+
clamped = clampAngle(bone, HINGE_ACTION, target.degrees);
171+
if (clamped !== target.degrees) {
172+
warnings.push({
173+
line: target.line,
174+
phase: phaseName,
175+
joint: bone,
176+
action: HINGE_ACTION,
177+
requested: target.degrees,
178+
clamped,
179+
limit: romFor(bone, HINGE_ACTION)!,
180+
});
181+
}
182+
}
183+
184+
// Pelvis rests along +Y, so +x pitches the torso toward +Z (forward).
185+
ensure(byBone, "pelvis").x = clamped;
186+
for (const bone of hipBones) ensure(byBone, bone).x = -clamped;
187+
}
188+
126189
function ensure(map: Map<string, EulerDeg>, bone: string): EulerDeg {
127190
let euler = map.get(bone);
128191
if (!euler) {

packages/movit-parser/src/joints.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,41 @@ const ACTIONS: Record<string, ActionAxis> = {
8383
plantarflex: { axis: "x", sign: -1 },
8484
};
8585

86+
/**
87+
* `hinge` is the closed-chain hip action (deadlift / forward fold): the torso
88+
* tips over planted feet instead of the legs swinging forward. It maps to
89+
* multiple bones (pelvis + both hips), so it lives outside the single-axis
90+
* ACTIONS table and is resolved specially in clamp.ts.
91+
*/
92+
export const HINGE_ACTION = "hinge";
93+
8694
/** Every semantic action name the DSL accepts (e.g. "flex", "abduct"). */
87-
export const ACTION_NAMES = Object.keys(ACTIONS);
95+
export const ACTION_NAMES = [...Object.keys(ACTIONS), HINGE_ACTION];
8896

8997
/** Map a semantic action to its rotation axis and sign, or null if unknown. */
9098
export function actionAxis(action: string): ActionAxis | null {
9199
return ACTIONS[action] ?? null;
92100
}
93101

94102
/**
95-
* Sagittal flexion direction differs by joint. With every bone resting along
96-
* -Y, most joints flex toward +Z (anatomically forward / up): hip, shoulder,
97-
* elbow, spine, neck. The KNEE is the exception — it flexes toward -Z (heel
98-
* toward the buttock). `extend` is the opposite of `flex`. Used by the resolver
99-
* to sign flex/extend per joint so a squat folds correctly instead of inverting.
103+
* Sagittal flexion direction differs by joint. Limb bones rest along -Y
104+
* (children hang below the joint), and for them a NEGATIVE x rotation flexes
105+
* toward +Z — anatomically forward: hip, shoulder, elbow. Exceptions:
106+
* - KNEE flexes toward -Z (heel toward the buttock) → +1.
107+
* - TORSO chain (pelvis, spine, chest, neck) rests along +Y (children sit
108+
* ABOVE the joint), which mirrors the rotation: a POSITIVE x rotation is
109+
* what bends them toward +Z → +1. Without this the spine bent backward
110+
* while the limbs flexed forward.
111+
* `extend` is the opposite of `flex`. Used by the resolver to sign
112+
* flex/extend per joint so a squat folds correctly instead of inverting.
100113
*/
101-
const FLEXION_SIGN: Record<string, number> = { knee: 1 };
114+
const FLEXION_SIGN: Record<string, number> = {
115+
knee: 1,
116+
pelvis: 1,
117+
spine: 1,
118+
chest: 1,
119+
neck: 1,
120+
};
102121

103122
export function flexionSign(boneType: string): number {
104123
return FLEXION_SIGN[boneType] ?? -1;

packages/movit-parser/src/rom.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const ROM: Record<string, ActionLimits> = {
4141
// --- Lower extremity (research Table 2) ---
4242
hip: {
4343
flex: { min: 0, max: 135 },
44+
// Closed-chain hip flexion (torso over femur — deadlift / forward fold).
45+
// Same anatomical joint and ceiling as open-chain flexion.
46+
hinge: { min: 0, max: 135 },
4447
extend: { min: 0, max: 20 },
4548
abduct: { min: 0, max: 45 },
4649
adduct: { min: 0, max: 30 },
@@ -56,6 +59,14 @@ const ROM: Record<string, ActionLimits> = {
5659
plantarflex: { min: 0, max: 50 },
5760
},
5861
// --- Axial (conservative literature values) ---
62+
pelvis: {
63+
flex: { min: 0, max: 30 }, // anterior tilt
64+
extend: { min: 0, max: 20 }, // posterior tilt
65+
abduct: { min: 0, max: 15 }, // lateral tilt
66+
adduct: { min: 0, max: 15 },
67+
"rotate-in": { min: 0, max: 15 },
68+
"rotate-out": { min: 0, max: 15 },
69+
},
5970
spine: {
6071
flex: { min: 0, max: 90 },
6172
extend: { min: 0, max: 30 },

packages/movit-parser/test/parse.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,83 @@ describe("parse", () => {
114114
expect(errors[0]!.message).toMatch(/header|must start/i);
115115
});
116116

117+
it("resolves torso flexion forward, matching the limbs (positive x)", () => {
118+
// Torso bones' children sit at +Y offsets (limbs at -Y), so torso flexion
119+
// needs the opposite euler sign to bend the same world direction (+Z).
120+
const src = [
121+
'movit stretch "Fold"',
122+
" rig humanoid",
123+
' step "Bend" 1s linear:',
124+
" spine: flex 45",
125+
" neck: flex 20",
126+
].join("\n");
127+
const { ir, errors } = parse(src);
128+
expect(errors).toEqual([]);
129+
const targets = ir!.phases[0]!.targets;
130+
expect(targets.find((t) => t.boneId === "spine")!.euler.x).toBe(45);
131+
expect(targets.find((t) => t.boneId === "neck")!.euler.x).toBe(20);
132+
});
133+
134+
it("resolves a hip hinge into pelvis rotation + hip counter-rotation", () => {
135+
const src = [
136+
'movit exercise "Deadlift"',
137+
" rig humanoid",
138+
' step "Hinge" 2s ease-in-out:',
139+
" hips: hinge 70",
140+
" knees: flex 20",
141+
" ground-lock: feet",
142+
].join("\n");
143+
const { ir, errors, warnings } = parse(src);
144+
expect(errors).toEqual([]);
145+
expect(warnings).toEqual([]);
146+
const targets = ir!.phases[0]!.targets;
147+
// Pelvis tips the torso forward (+x); both hips counter-rotate so the
148+
// legs stay vertical in world space.
149+
expect(targets.find((t) => t.boneId === "pelvis")!.euler.x).toBe(70);
150+
expect(targets.find((t) => t.boneId === "hip_left")!.euler.x).toBe(-70);
151+
expect(targets.find((t) => t.boneId === "hip_right")!.euler.x).toBe(-70);
152+
// Explicit knee targets are untouched by the hinge.
153+
expect(targets.find((t) => t.boneId === "knee_left")!.euler.x).toBe(20);
154+
});
155+
156+
it("clamps a hinge beyond hip flexion ROM and records warnings", () => {
157+
const src = [
158+
'movit exercise "Overfold"',
159+
" rig humanoid",
160+
' step "Hinge" 1s linear:',
161+
" hips: hinge 170",
162+
].join("\n");
163+
const { ir, warnings } = parse(src);
164+
expect(warnings).toHaveLength(2); // hip_left + hip_right
165+
expect(warnings[0]!.action).toBe("hinge");
166+
expect(warnings[0]!.clamped).toBe(135);
167+
const targets = ir!.phases[0]!.targets;
168+
expect(targets.find((t) => t.boneId === "pelvis")!.euler.x).toBe(135);
169+
expect(targets.find((t) => t.boneId === "hip_left")!.euler.x).toBe(-135);
170+
});
171+
172+
it("rejects hinge on joints other than hips", () => {
173+
const src = [
174+
'movit exercise "Bad hinge"',
175+
" rig humanoid",
176+
' step "Move" 1s linear:',
177+
" knees: hinge 30",
178+
].join("\n");
179+
const { errors } = parse(src);
180+
expect(errors.some((e) => /hinge.*hips/i.test(e.message))).toBe(true);
181+
});
182+
183+
it("requires an angle for hinge", () => {
184+
const src = [
185+
'movit exercise "No angle"',
186+
" rig humanoid",
187+
' step "Move" 1s linear:',
188+
" hips: hinge",
189+
].join("\n");
190+
const { errors } = parse(src);
191+
expect(errors.some((e) => /angle/i.test(e.message))).toBe(true);
192+
});
193+
117194
it("rejects an unknown easing", () => {
118195
const src = [
119196
'movit exercise "Bad easing"',

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,52 @@ describe("timeline", () => {
5555
});
5656
});
5757

58+
describe("pose directions (world space)", () => {
59+
function poseAtEnd(src: string) {
60+
const { ir, errors } = parse(src);
61+
expect(errors).toEqual([]);
62+
const m = buildMannequin();
63+
const tl = buildTimeline(ir!);
64+
tl.sample(tl.segments[0]!.end - 1e-6, m.bones);
65+
m.root.updateMatrixWorld(true);
66+
return m;
67+
}
68+
const world = (m: ReturnType<typeof buildMannequin>, id: string) =>
69+
m.bones.get(id)!.getWorldPosition(new THREE.Vector3());
70+
const doc = (...lines: string[]) =>
71+
['movit exercise "t"', " rig humanoid", ' step "go" 2s linear:', ...lines.map((l) => ` ${l}`)].join("\n");
72+
73+
it("spine flexion bends the head forward (+Z), the same side as the toes", () => {
74+
const m = poseAtEnd(doc("spine: flex 45"));
75+
expect(world(m, "head").z).toBeGreaterThan(0.15);
76+
});
77+
78+
it("hip hinge tips the torso over the feet while the legs stay vertical", () => {
79+
const m = poseAtEnd(doc("hips: hinge 70", "ground-lock: feet"));
80+
// Torso pitched well forward…
81+
expect(world(m, "head").z).toBeGreaterThan(0.35);
82+
expect(world(m, "head").y).toBeLessThan(1.3);
83+
// …while the legs stay a vertical column: knee directly below the hip.
84+
const hip = world(m, "hip_left");
85+
const knee = world(m, "knee_left");
86+
const ankle = world(m, "ankle_left");
87+
expect(Math.abs(knee.z - hip.z)).toBeLessThan(0.03);
88+
expect(Math.abs(ankle.z - knee.z)).toBeLessThan(0.03);
89+
});
90+
91+
it("hip hinge is a hinge, not a spinal roll: spine stays neutral", () => {
92+
const m = poseAtEnd(doc("hips: hinge 70"));
93+
// Neck→head direction should still align with the chest→neck direction
94+
// (straight back), unlike a roll-down which curls the spine.
95+
const chest = world(m, "chest");
96+
const neck = world(m, "neck");
97+
const head = world(m, "head");
98+
const a = neck.clone().sub(chest).normalize();
99+
const b = head.clone().sub(neck).normalize();
100+
expect(a.dot(b)).toBeGreaterThan(0.99);
101+
});
102+
});
103+
58104
describe("ccd ik", () => {
59105
it("brings an effector close to its target", () => {
60106
const root = new THREE.Object3D();

playground/src/landing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function initHero(): void {
4242
if ("requestIdleCallback" in window) {
4343
requestIdleCallback(initHero, { timeout: 1200 });
4444
} else {
45-
window.setTimeout(initHero, 200);
45+
setTimeout(initHero, 200);
4646
}
4747

4848
// --- Examples gallery: each card opens the movement in the playground -------

0 commit comments

Comments
 (0)