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
9 changes: 9 additions & 0 deletions packages/posecode-eval/test/eval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ describe("metrics", () => {

const pullUp = movement("pull-up");
expect(Math.min(...pullUp.phases.map((p) => headPropClearance(pullUp, p)))).toBeGreaterThan(-0.01);

// A dead bug moves opposite limbs while the torso remains planted. This
// catches the old silent `ground-lock: back` no-op, where whole-figure
// floor reconciliation could move the torso with whichever limb was lowest.
const deadBug = movement("dead-bug");
for (const id of ["pelvis", "chest"]) {
const heights = deadBug.phases.map((p) => p.bones.get(id)![1]);
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(0.01);
}
});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/posecode-language/src/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const KINDS = ["exercise", "stretch", "posture"];
/** Recognised start poses (`pose start = ...`). */
export const POSES = ["neutral", "standing", "plank", "supine", "prone", "seated"];

/** Effectors that can be ground-locked. */
/** Floor contacts that can be ground-locked. */
export const EFFECTORS = [...GROUND_LOCK_EFFECTOR_NAMES];

/** Reach/pin effectors (groups + per-side aliases), sourced from the parser. */
Expand Down Expand Up @@ -50,7 +50,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
snap: "Timing mode: fast, near-immediate arrival — an accent.",
linear: "Timing mode: constant velocity — intentionally mechanical.",
repeat: "How many times the movement loops.",
"ground-lock": "Pins grouped or per-side effectors to the floor: `feet`, `foot_left` (also written `left foot`), `hands`, or `forearms`. Planted feet auto-level unless the ankle is intentionally plantarflexed.",
"ground-lock": "Keeps grouped, per-side, or axial contacts on the floor: `feet`, `foot_left` (also `left foot`), `hands`, `forearms`, or `back` for supine work. Planted feet auto-level unless intentionally plantarflexed.",
reach:
"Drives an effector to a target via ROM-constrained IK: `reach: hand_left ankle_left`, `reach: hands floor`.",
pin: "Moves the body so an effector sits on an anchor: `pin: hands bar` (hang, pull up, step up, dip).",
Expand Down
10 changes: 9 additions & 1 deletion packages/posecode-language/test/language.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ describe("getCompletions", () => {

it("suggests effectors after `ground-lock: `", () => {
expect(onLine(" ground-lock: ", 17)).toEqual(
expect.arrayContaining(["hands", "feet", "hand_left", "hand_right", "foot_left", "foot_right"]),
expect.arrayContaining([
"hands",
"feet",
"hand_left",
"hand_right",
"foot_left",
"foot_right",
"back",
]),
);
});

Expand Down
7 changes: 5 additions & 2 deletions packages/posecode-mcp/src/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ posecode <kind> "<Name>" # kind = exercise | stretch | posture
pose start = <pose> # neutral | standing | plank
step "<Phase>" <Ns> <mode>: # mode = flow | settle | drive | snap | linear
<joint>: <action> <degrees>
ground-lock: <effectors> # feet/hands/forearms, or one side: foot_left / left foot
ground-lock: <contacts> # feet/hands/forearms/back, or foot_left / left foot
cue "<short coaching cue>"
repeat <count>
\`\`\`
Expand All @@ -47,4 +47,7 @@ wrists hips knees ankles. Actions (degrees are absolute targets): flex/extend,
abduct/adduct, rotate-in/rotate-out, dorsiflex/plantarflex, hold neutral, and
hinge (hips only, closed-chain hip flexion: torso tips over planted feet with
a neutral spine; use for deadlift / forward fold instead of hips: flex).
Stay within healthy range of motion; the renderer hard-clamps anything beyond.`;
Stay within healthy range of motion; the renderer hard-clamps anything beyond.
Use ground-lock: feet when standing, hands and feet in a high plank, forearms
and feet in a forearm plank, and back for supine floor work such as a dead bug.
Do not invent other contact names.`;
4 changes: 3 additions & 1 deletion packages/posecode-parser/src/joints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ const EFFECTOR_GROUPS: Record<string, string[]> = {
export const EFFECTOR_NAMES = [...Object.keys(EFFECTOR_GROUPS), ...EFFECTOR_SIDES];

/**
* Effectors accepted by `ground-lock:`. Ground locking has historically
* Contacts accepted by `ground-lock:`. Ground locking has historically
* supported the symmetric hand/forearm/foot groups; per-side aliases let a
* movement keep one support planted while the opposite limb moves freely.
* `back` is an axial surface contact for supine floor work.
*/
export const GROUND_LOCK_EFFECTOR_NAMES = [
"hands",
Expand All @@ -135,6 +136,7 @@ export const GROUND_LOCK_EFFECTOR_NAMES = [
"feet",
"foot_left",
"foot_right",
"back",
] as const;

const GROUND_LOCK_EFFECTOR_SET = new Set<string>(GROUND_LOCK_EFFECTOR_NAMES);
Expand Down
4 changes: 2 additions & 2 deletions packages/posecode-parser/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
if (head === "ground-lock") {
if (!current) return { line: ln.line, message: "`ground-lock` outside of a step" };
if (t[1]?.type !== "colon") {
return { line: ln.line, message: "expected `ground-lock: <effectors>`" };
return { line: ln.line, message: "expected `ground-lock: <contacts>`" };
}
const words = t.slice(2).filter((tok) => tok.type === "word").map((tok) => tok.value);
const effectors: string[] = [];
Expand All @@ -255,7 +255,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
effectors.push(value);
}
if (effectors.length === 0) {
return { line: ln.line, message: "`ground-lock` requires at least one effector" };
return { line: ln.line, message: "`ground-lock` requires at least one contact" };
}
current.groundLock = effectors;
current.groundLockLine = ln.line;
Expand Down
2 changes: 1 addition & 1 deletion packages/posecode-parser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export interface Phase {
durationSec: number;
easing: Easing;
targets: JointTarget[];
/** Grouped or per-side floor effectors pinned for this phase, e.g. ["feet"] or ["foot_right"]. */
/** Grouped, per-side, or axial floor contacts held this phase, e.g. ["foot_right"] or ["back"]. */
groundLock: string[];
/** Reach-IK goals active during this phase. */
reaches: ReachTarget[];
Expand Down
23 changes: 23 additions & 0 deletions packages/posecode-parser/test/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,29 @@ describe("reach/pin effectors", () => {
});
});

describe("ground-lock contacts", () => {
it("accepts back as a supine floor contact", () => {
const result = parse(`posecode exercise "Dead bug"
rig humanoid
pose start = supine
step "Extend" 1s settle:
ground-lock: back`);
expect(result.errors).toEqual([]);
expect(result.ir?.phases[0]?.groundLock).toEqual(["back"]);
});

it("rejects unknown contacts instead of silently ignoring them", () => {
const result = parse(`posecode posture "Floor"
rig humanoid
step "Hold" 1s linear:
ground-lock: shoulders`);
expect(result.ir).toBeNull();
expect(result.errors).toEqual([
expect.objectContaining({ line: 4, message: expect.stringContaining("shoulders") }),
]);
});
});

describe("clip directive", () => {
const doc = (clipLine: string): string =>
[
Expand Down
43 changes: 41 additions & 2 deletions packages/posecode-render/src/groundlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* render loop and the headless eval harness (posecode-eval) use the identical
* solver.
*
* "Ground-lock" = keep the declared grouped or per-side effectors planted while the
* body moves, tuned per support type:
* "Ground-lock" = keep the declared grouped, per-side, or axial contacts
* planted while the body moves, tuned per support type:
*
* - **Hands + feet (push-up / plank):** pivot the whole rigid body about the
* foot line (the toes stay planted) until the hands reach the floor. As the
Expand All @@ -23,6 +23,8 @@
* the heels). Only feet near the floor anchor (a swing leg in a curl or
* march must stay free), and only the average delta is corrected so
* symmetric spreads (jumping jacks) don't fight the lock.
* - **Back (dead bug / supine floor work):** translate the body vertically so
* the pelvis-to-ribcage surface stays on the floor while the limbs move.
*
* Both paths ground the visible MESH (bounding boxes), not just bone origins:
* an ankle bone sits ~0.04m above the sole, so anchoring bones alone left the
Expand Down Expand Up @@ -97,8 +99,14 @@ export function applyGroundLock(
const hands = ids.filter((id) => id.startsWith("wrist"));
const forearms = ids.filter((id) => id.startsWith("elbow"));
const feet = ids.filter((id) => id.startsWith("ankle"));
const back = ids.filter((id) => id === "pelvis" || id === "spine" || id === "chest");
const upperSupports = forearms.length > 0 ? forearms : hands;

if (back.length > 0) {
dropOwnMeshesToFloor(m, back);
return;
}

if (upperSupports.length > 0 && feet.length > 0) {
// Plant the feet FIRST: drop the body so the foot mesh rests on the floor,
// so the pivot the body then rotates about is itself at floor level. The
Expand Down Expand Up @@ -146,6 +154,37 @@ export function applyGroundLock(
}
}

/**
* Drop only the meshes owned by the selected bones onto the floor. `Box3` on
* a torso bone would include its child limbs, so an overhead arm could
* otherwise lift a supine person's back. Bone child subtrees are deliberately
* excluded; non-bone groups (capsules/ellipsoids) remain part of the surface.
*/
function dropOwnMeshesToFloor(m: Mannequin, boneIds: string[]): void {
const boneNodes = new Set(m.bones.values());
const box = new THREE.Box3();
const childBox = new THREE.Box3();
let found = false;

for (const id of boneIds) {
const bone = m.bones.get(id);
if (!bone) continue;
for (const child of bone.children) {
if (boneNodes.has(child)) continue;
childBox.setFromObject(child);
if (!childBox.isEmpty()) {
box.union(childBox);
found = true;
}
}
}

if (found && Number.isFinite(box.min.y)) {
m.root.position.y -= box.min.y;
m.root.updateMatrixWorld(true);
}
}

/**
* Drop the whole body vertically so the lowest FOOT-mesh point rests on the
* floor. Grounds the visible sole (bounding box), not the ankle bone, which
Expand Down
10 changes: 8 additions & 2 deletions packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
* `createViewer(canvas)` sets up a Three.js studio scene with the procedural
* mannequin and returns a controller. `load(ir)` builds a timeline from a parsed
* PosecodeIR; the render loop applies forward kinematics each frame, then keeps
* ground-locked contacts (hands/feet) planted via floating-root solving. The
* camera auto-frames the figure and eases smoothly when a new movement loads.
* ground-locked contacts (hands/forearms/feet/back) planted via floating-root
* solving. The camera auto-frames the figure and eases smoothly when a new
* movement loads.
*/

import * as THREE from "three";
Expand Down Expand Up @@ -1055,6 +1056,11 @@ function contactBoneIds(info: {
ids.add("elbow_left");
ids.add("elbow_right");
}
if (effector === "back") {
ids.add("pelvis");
ids.add("spine");
ids.add("chest");
}
};
for (const group of info.groundLock) addEffector(group);
for (const contact of [...info.grips, ...info.pins]) addEffector(contact.effector);
Expand Down
2 changes: 2 additions & 0 deletions packages/posecode-render/src/mannequin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export function buildMannequin(material?: THREE.Material, proportions?: Proporti
feet: ["ankle_left", "ankle_right"],
foot_left: ["ankle_left"],
foot_right: ["ankle_right"],
// Axial surface used by supine floor work (dead bug, crunch, leg raise).
back: ["pelvis", "spine", "chest"],
},
collision: proportions?.collision ?? DEFAULT_COLLISION,
};
Expand Down
27 changes: 27 additions & 0 deletions packages/posecode-render/test/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe("mannequin", () => {
expect(m.effectors.forearms).toEqual(["elbow_left", "elbow_right"]);
expect(m.effectors.feet).toEqual(["ankle_left", "ankle_right"]);
expect(m.effectors.foot_right).toEqual(["ankle_right"]);
expect(m.effectors.back).toEqual(["pelvis", "spine", "chest"]);
});
});

Expand Down Expand Up @@ -466,6 +467,32 @@ describe("ground-lock (shared solver)", () => {
expect(Math.abs(soleY)).toBeLessThan(0.01);
});

it("plants the torso surface for a supine back lock", () => {
const m = buildMannequin();
const spec = poseFor("supine");
m.root.position.set(...spec.root!.position!);
const [rx, ry, rz] = spec.root!.rotationDeg!;
m.root.rotation.set(rx * DEG, ry * DEG, rz * DEG);
// Put the limbs into an asymmetric dead-bug phase before solving contact.
m.bones.get("shoulder_right")!.rotation.x = -150 * DEG;
m.bones.get("hip_left")!.rotation.x = -20 * DEG;
m.bones.get("knee_left")!.rotation.x = 5 * DEG;
m.root.updateMatrixWorld(true);

applyGroundLock(m, ["back"]);

// Measure meshes owned by the pelvis/spine/chest only. Descendant limbs
// are intentionally excluded: moving an arm must not lift the back.
const boneNodes = new Set(m.bones.values());
const backBox = new THREE.Box3();
for (const id of ["pelvis", "spine", "chest"]) {
for (const child of m.bones.get(id)!.children) {
if (!boneNodes.has(child)) backBox.union(new THREE.Box3().setFromObject(child));
}
}
expect(backBox.min.y).toBeCloseTo(0, 3);
});

it("is a no-op when no effectors are ground-locked", () => {
const m = posedRaw(
[
Expand Down
8 changes: 4 additions & 4 deletions playground/public/llm-guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ <h2>Grammar</h2>
step &quot;&lt;Phase name&gt;&quot; &lt;Ns&gt; &lt;mode&gt;: # mode = flow | settle | drive | snap | linear
&lt;joint&gt;: &lt;action&gt; &lt;degrees&gt;
reach: &lt;effector&gt; &lt;target&gt; # optional: drive a hand/foot to a target via IK
ground-lock: &lt;effectors&gt; # groups, foot_left/foot_right, or natural left foot/right foot
ground-lock: &lt;contacts&gt; # groups/back, foot_left, or natural left foot
turn: &lt;degrees&gt; # optional: face this yaw by phase end (standing only)
travel: &lt;x&gt; &lt;z&gt; # optional: move to this x z (metres) by phase end
cue &quot;&lt;short coaching cue&gt;&quot;
Expand All @@ -149,8 +149,8 @@ <h2>Rules</h2>
<p>previous value.</p>
<ol><li>Stay within healthy range of motion (e.g. knee flex ≤ 144°, elbow flex ≤</li></ol>
<p>154°, shoulder flex ≤ 180°). The renderer hard-clamps anything beyond it.</p>
<ol><li>Add a one-line <code>cue</code> per phase. Use <code>ground-lock</code> for whatever touches the</li></ol>
<p>floor (feet when standing; hands and feet in a plank).</p>
<ol><li>Add a one-line <code>cue</code> per phase. Ground contacts are a closed vocabulary:</li></ol>
<p>use <code>ground-lock: feet</code> when standing, <code>ground-lock: hands, feet</code> in a high plank, <code>ground-lock: forearms, feet</code> in a forearm plank, and <code>ground-lock: back</code> for supine floor work such as a dead bug. Do not invent other contact names.</p>
<ol><li><code>repeat</code> the rep count.</li></ol>
<h2>Timing modes</h2>
<ul><li><code>flow</code>: carry momentum through an interior pose; use for continuous dance,</li></ul>
Expand Down Expand Up @@ -209,7 +209,7 @@ <h2>Reaching, props, lying poses &amp; hands</h2>
<ul><li><strong>Pins</strong>: <code>pin: &lt;effector&gt; &lt;anchor&gt;</code> moves the whole BODY so the effector sits</li></ul>
<p>on the anchor (vs <code>reach</code>, which moves just the limb). Same effectors as reach, including <code>hands</code> / <code>feet</code>. Use it for hanging and climbing: <code>pin: hands bar</code> + flexing the elbows = a pull-up; <code>pin: foot_right box</code> + straightening the leg = a step-up; <code>pin: hands bars</code> (dip bars) + bending the elbows = a triceps dip.</p>
<ul><li><strong>Lying / seated</strong>: <code>pose start = supine | prone | seated</code> for floor and mat</li></ul>
<p>work (glute bridge, dead bug, cobra, seated forward fold).</p>
<p>work (glute bridge, dead bug, cobra, seated forward fold). In a supine exercise whose torso stays down, add <code>ground-lock: back</code> to each phase.</p>
<ul><li><strong>Hands</strong>: <code>fingers: flex 80</code> makes a fist; curl individual fingers for shapes</li></ul>
<p>(<code>index_right: flex 95</code>). Single-DOF per finger, good for grip and rough gesture, not exact sign language.</p>
<h2>Authoring by domain</h2>
Expand Down
4 changes: 4 additions & 0 deletions playground/public/moves/dead-bug.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,14 @@ <h2>The .posecode source</h2>
shoulders: flex 90
hips: flex 90
knees: flex 90
ground-lock: back
cue &quot;Arms reach to the ceiling, knees stacked over the hips&quot;

step &quot;Extend right arm &amp; left leg&quot; 1.2s flow:
shoulder_right: flex 150
hip_left: flex 20
knee_left: flex 5
ground-lock: back
cue &quot;Lower the right arm overhead and the left leg toward the floor&quot;

step &quot;Switch&quot; 1.2s flow:
Expand All @@ -178,12 +180,14 @@ <h2>The .posecode source</h2>
shoulder_left: flex 150
hip_right: flex 20
knee_right: flex 5
ground-lock: back
cue &quot;Return and switch: left arm and right leg reach out&quot;

step &quot;Return&quot; 1s settle:
shoulder_left: flex 90
hip_right: flex 90
knee_right: flex 90
ground-lock: back
cue &quot;Bring everything back to the start position&quot;

repeat 4
Expand Down
Loading
Loading