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
6 changes: 4 additions & 2 deletions packages/posecode-eval/src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,10 @@ export function feetCenterSkateDistance(previous: PhasePose, pose: PhasePose): n
}

export function footIsSupported(pose: PhasePose, side: "left" | "right"): boolean {
return pose.groundLock.includes("feet") || pose.pins.some((p) =>
(p.effector === "feet" || p.effector === `foot_${side}`) && p.anchor === "floor");
return pose.groundLock.includes("feet")
|| pose.groundLock.includes(`foot_${side}`)
|| pose.pins.some((p) =>
(p.effector === "feet" || p.effector === `foot_${side}`) && p.anchor === "floor");
}

/** Lowest bone height in the pose (should never be much below 0). */
Expand Down
5 changes: 3 additions & 2 deletions packages/posecode-language/src/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
MODES,
LEGACY_MODE_ALIASES,
EFFECTOR_NAMES,
GROUND_LOCK_EFFECTOR_NAMES,
} from "posecode-parser";

export { JOINT_NAMES, ACTION_NAMES, EASINGS, MODES, LEGACY_MODE_ALIASES };
Expand All @@ -22,7 +23,7 @@ export const KINDS = ["exercise", "stretch", "posture"];
export const POSES = ["neutral", "standing", "plank", "supine", "prone", "seated"];

/** Effectors that can be ground-locked. */
export const EFFECTORS = ["hands", "feet"];
export const EFFECTORS = [...GROUND_LOCK_EFFECTOR_NAMES];

/** Reach/pin effectors (groups + per-side aliases), sourced from the parser. */
export const REACH_EFFECTORS = EFFECTOR_NAMES;
Expand All @@ -49,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 effectors (hands / feet) to the floor for this phase. Planted feet auto-level flat to the floor unless the ankle is plantarflexed (tiptoe).",
"ground-lock": "Pins grouped or per-side effectors to the floor for this phase: `ground-lock: feet`, `ground-lock: foot_right`. Planted feet auto-level flat unless the ankle is plantarflexed (tiptoe).",
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
2 changes: 1 addition & 1 deletion packages/posecode-language/test/language.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe("getCompletions", () => {

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

Expand Down
15 changes: 14 additions & 1 deletion packages/posecode-parser/src/clamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
expandEffector,
expandJoint,
flexionSign,
isGroundLockEffector,
isLeft,
} from "./joints.js";
import { clampAngle, romFor } from "./rom.js";
Expand Down Expand Up @@ -125,6 +126,18 @@ function resolveStep(
euler,
}));

const groundLock: string[] = [];
for (const effector of step.groundLock) {
if (!isGroundLockEffector(effector)) {
errors.push({
line: step.groundLockLine ?? step.line,
message: `unknown ground-lock effector: "${effector}"`,
});
continue;
}
groundLock.push(effector);
}

// Reach / pin effectors: expand symmetric groups (`hands` → both hands) and
// reject unknown names, since a typo'd effector would otherwise be silently
// ignored by the renderer, invisible to the authoring LLM.
Expand Down Expand Up @@ -176,7 +189,7 @@ function resolveStep(
durationSec: step.durationSec,
easing: step.easing as Phase["easing"],
targets,
groundLock: step.groundLock,
groundLock,
reaches,
pins,
grips,
Expand Down
2 changes: 2 additions & 0 deletions packages/posecode-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export {
JOINT_NAMES,
ACTION_NAMES,
EFFECTOR_NAMES,
GROUND_LOCK_EFFECTOR_NAMES,
expandJoint,
expandEffector,
isGroundLockEffector,
actionAxis,
boneType,
} from "./joints.js";
Expand Down
24 changes: 24 additions & 0 deletions packages/posecode-parser/src/joints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ const EFFECTOR_GROUPS: Record<string, string[]> = {
/** Every effector name `reach:` / `pin:` accept: groups + per-side aliases. */
export const EFFECTOR_NAMES = [...Object.keys(EFFECTOR_GROUPS), ...EFFECTOR_SIDES];

/**
* Effectors 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.
*/
export const GROUND_LOCK_EFFECTOR_NAMES = [
"hands",
"hand_left",
"hand_right",
"forearms",
"elbow_left",
"elbow_right",
"feet",
"foot_left",
"foot_right",
] as const;

const GROUND_LOCK_EFFECTOR_SET = new Set<string>(GROUND_LOCK_EFFECTOR_NAMES);

/** True when an effector is implemented by the ground-lock solver. */
export function isGroundLockEffector(name: string): boolean {
return GROUND_LOCK_EFFECTOR_SET.has(name);
}

const EFFECTOR_SIDE_SET = new Set<string>(EFFECTOR_SIDES);

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/posecode-parser/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface AstStep {
easing: string;
targets: AstJointTarget[];
groundLock: string[];
/** Source line of the active `ground-lock:` declaration. */
groundLockLine?: number;
reaches: AstReach[];
pins: AstPin[];
grips: AstPin[];
Expand Down Expand Up @@ -222,6 +224,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
.filter((tok) => tok.type === "word")
.map((tok) => tok.value);
current.groundLock = effectors;
current.groundLockLine = ln.line;
return null;
}

Expand Down
1 change: 1 addition & 0 deletions packages/posecode-parser/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const stepSchema = z.object({
easing: z.enum(MODES),
targets: z.array(jointTargetSchema),
groundLock: z.array(z.string()),
groundLockLine: z.number().optional(),
reaches: z.array(reachSchema),
pins: z.array(pinSchema),
grips: z.array(pinSchema),
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 @@ -66,7 +66,7 @@ export interface Phase {
durationSec: number;
easing: Easing;
targets: JointTarget[];
/** Effector groups / prop anchors pinned for this phase, e.g. ["hands", "feet"]. */
/** Grouped or per-side floor effectors pinned for this phase, e.g. ["feet"] or ["foot_right"]. */
groundLock: string[];
/** Reach-IK goals active during this phase. */
reaches: ReachTarget[];
Expand Down
28 changes: 28 additions & 0 deletions packages/posecode-parser/test/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,34 @@ describe("parse", () => {
expect(errors[0]!.message).toMatch(/unknown joint/i);
});

it("accepts per-side ground-lock effectors", () => {
const src = [
'posecode exercise "Single-leg pivot"',
" rig humanoid",
' step "Turn" 1s linear:',
" turn: 180",
" ground-lock: foot_right",
].join("\n");
const { ir, errors } = parse(src);
expect(errors).toEqual([]);
expect(ir!.phases[0]!.groundLock).toEqual(["foot_right"]);
});

it("reports a line-anchored error for an unsupported ground-lock effector", () => {
const src = [
'posecode exercise "Typo"',
" rig humanoid",
' step "Turn" 1s linear:',
" turn: 180",
" ground-lock: shoe_right",
].join("\n");
const { ir, errors } = parse(src);
expect(ir).toBeNull();
expect(errors).toEqual([
{ line: 5, message: 'unknown ground-lock effector: "shoe_right"' },
]);
});

it("reports an error when a step child has no enclosing step", () => {
const src = [
'posecode exercise "Orphan"',
Expand Down
11 changes: 8 additions & 3 deletions packages/posecode-render/src/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ export function alignFloorPalms(
};
reaches.forEach((r) => collect(r.effector, r.target, r.weight));
pins.forEach((p) => collect(p.effector, p.anchor));
if (groundLock.includes("hands")) {
if (groundLock.includes("hands") || groundLock.includes("hand_left")) {
sides.set("left", 1);
}
if (groundLock.includes("hands") || groundLock.includes("hand_right")) {
sides.set("right", 1);
}

Expand Down Expand Up @@ -71,9 +73,12 @@ const TMP_EULER = new THREE.Euler();
* where a leg-induced foot tilt makes the ball the lowest mesh point.
*/
export function levelPlantedFeet(m: Mannequin, activeGroundLock: readonly string[]): void {
if (!activeGroundLock.includes("feet")) return;
const plantedSides = FOOT_SIDES.filter((side) =>
activeGroundLock.includes("feet") || activeGroundLock.includes(`foot_${side}`),
);
if (plantedSides.length === 0) return;
let changed = false;
for (const side of FOOT_SIDES) {
for (const side of plantedSides) {
const ankle = m.bones.get(`ankle_${side}`);
if (!ankle?.parent) continue;
// Tiptoe opt-out: an ankle authored into plantarflexion (local +X) is a
Expand Down
4 changes: 2 additions & 2 deletions packages/posecode-render/src/groundlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* render loop and the headless eval harness (posecode-eval) use the identical
* solver.
*
* "Ground-lock" = keep the declared effectors (hands/feet) planted while the
* "Ground-lock" = keep the declared grouped or per-side effectors planted while the
* body moves, tuned per support type:
*
* - **Hands + feet (push-up / plank):** pivot the whole rigid body about the
Expand Down Expand Up @@ -49,7 +49,7 @@ export function groundFigure(m: Mannequin): void {
}
}

/** Resolve active effector group names ("hands"/"feet") into bone ids. */
/** Resolve active grouped/per-side effector names into bone ids. */
function activeEffectorIds(m: Mannequin, active: string[]): string[] {
const ids = new Set<string>();
for (const group of active) {
Expand Down
2 changes: 1 addition & 1 deletion packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1110,7 +1110,7 @@ function floorHandSidesOf(
};
for (const r of reaches) if (r.target === "floor") add(r.effector);
for (const p of pins) if (p.anchor === "floor") add(p.effector);
if (groundLock.includes("hands")) add("hands");
for (const effector of groundLock) add(effector);
return sides;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/posecode-render/src/mannequin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,14 @@ export function buildMannequin(material?: THREE.Material, proportions?: Proporti
bones,
effectors: {
hands: ["wrist_left", "wrist_right"],
hand_left: ["wrist_left"],
hand_right: ["wrist_right"],
forearms: ["elbow_left", "elbow_right"],
elbow_left: ["elbow_left"],
elbow_right: ["elbow_right"],
feet: ["ankle_left", "ankle_right"],
foot_left: ["ankle_left"],
foot_right: ["ankle_right"],
},
collision: proportions?.collision ?? DEFAULT_COLLISION,
};
Expand Down
13 changes: 13 additions & 0 deletions packages/posecode-render/test/contacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ describe("levelPlantedFeet", () => {
levelPlantedFeet(m, ["feet"]);
expect(m.bones.get("ankle_left")!.quaternion.angleTo(before)).toBeLessThan(1e-3);
});

it("levels only the selected per-side foot", () => {
const m = buildMannequin();
m.bones.get("knee_left")!.rotation.x = 12 * DEG;
m.bones.get("knee_right")!.rotation.x = 12 * DEG;
m.root.updateMatrixWorld(true);
groundFigure(m);
const leftBefore = m.bones.get("ankle_left")!.quaternion.clone();
const rightBefore = m.bones.get("ankle_right")!.quaternion.clone();
levelPlantedFeet(m, ["foot_left"]);
expect(m.bones.get("ankle_left")!.quaternion.angleTo(leftBefore)).toBeGreaterThan(1e-3);
expect(m.bones.get("ankle_right")!.quaternion.angleTo(rightBefore)).toBeLessThan(1e-6);
});
});

describe("relaxHands (L4.1)", () => {
Expand Down
22 changes: 22 additions & 0 deletions packages/posecode-render/test/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ describe("mannequin", () => {
it("declares hand and foot effector groups", () => {
const m = buildMannequin();
expect(m.effectors.hands).toEqual(["wrist_left", "wrist_right"]);
expect(m.effectors.hand_left).toEqual(["wrist_left"]);
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"]);
});
});

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

it("plants only the requested foot for a single-foot ground lock", () => {
const m = posedRaw(
[
'posecode exercise "One-leg balance"',
" rig humanoid",
" pose start = standing",
' step "Lift left" 1s linear:',
" hip_left: flex 55",
" knee_left: flex 75",
" ground-lock: foot_right",
].join("\n"),
);
applyGroundLock(m, ["foot_right"]);
m.root.updateMatrixWorld(true);
const rightSole = new THREE.Box3().setFromObject(m.bones.get("ankle_right")!).min.y;
const leftSole = new THREE.Box3().setFromObject(m.bones.get("ankle_left")!).min.y;
expect(Math.abs(rightSole)).toBeLessThan(0.01);
expect(leftSole).toBeGreaterThan(0.1);
});

it("is a no-op when no effectors are ground-locked", () => {
const m = posedRaw(
[
Expand Down
2 changes: 1 addition & 1 deletion 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;easing&gt;: # easing = linear | ease-in | ease-out | ease-in-out
&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; # hands and/or feet pinned to the floor this phase
ground-lock: &lt;effectors&gt; # groups (hands/forearms/feet) or per-side aliases such as foot_right
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 Down
6 changes: 3 additions & 3 deletions playground/public/spec.html
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,8 @@ <h2>5. Rendering model</h2>
<p>bone rotations between phases with the phase's easing.</p>
<ol><li><strong>Grounding</strong>: the figure is dropped so its lowest point rests on the floor</li></ol>
<p>(a bounding-box drop), which grounds standing, plank, and the lying/seated poses alike.</p>
<ol><li><strong>Ground-lock IK</strong>: effectors listed in <code>ground-lock</code> (<code>hands</code>, <code>feet</code>) are</li></ol>
<p>pinned to their planted floor position so they stay put while the body moves.</p>
<ol><li><strong>Ground-lock IK</strong>: effectors listed in <code>ground-lock</code> (<code>hands</code>, <code>forearms</code>,</li></ol>
<p><code>feet</code>, or the per-side aliases <code>hand_left|hand_right</code>, <code>elbow_left|elbow_right</code>, <code>foot_left|foot_right</code>) are pinned to their planted floor position so they stay put while the body moves. Unsupported effector names are line-anchored validation errors.</p>
<ol><li><strong>Reach-IK</strong>: a <code>reach:</code> line drives an effector (`hand_left|hand_right|</li></ol>
<p>foot_left|foot_right<code>, or the groups </code>hands<code>/</code>feet<code> for both sides) to a world <strong>target</strong> via Cyclic Coordinate Descent (CCD) over the arm/leg chain. A target is a body landmark bone (e.g. </code>ankle_left<code>), the keyword </code>floor<code>, or a prop anchor (</code>bar<code>, </code>seat<code>, </code>wall`). The solve is <strong>ROM-constrained</strong>: each iteration clamps every chain joint into its §4 Range-of-Motion limits (expressed as a per-axis box in the bone's local Euler frame), so a reach toward an unsafe or unreachable target settles on the closest *healthy* pose; solved angles obey the same hard limits as authored ones.</p>
<ol><li><strong>Props</strong>: <code>prop chair|wall|bar|box|dip-bars</code> adds a scene object at a</li></ol>
Expand Down Expand Up @@ -229,7 +229,7 @@ <h2>6. Intermediate Representation (IR)</h2>
durationSec: number;
easing: &quot;linear&quot; | &quot;ease-in&quot; | &quot;ease-out&quot; | &quot;ease-in-out&quot;;
targets: { boneId: string; euler: { x: number; y: number; z: number } }[];
groundLock: string[]; // [&quot;hands&quot;,&quot;feet&quot;]
groundLock: string[]; // [&quot;hands&quot;,&quot;feet&quot;] or [&quot;foot_right&quot;]
cue?: string;
}[];
}</code></pre>
Expand Down
9 changes: 6 additions & 3 deletions spec/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,11 @@ research §5.1 normative tables. Selected ceilings (degrees):
2. **Grounding**: the figure is dropped so its lowest point rests on the floor
(a bounding-box drop), which grounds standing, plank, and the lying/seated
poses alike.
3. **Ground-lock IK**: effectors listed in `ground-lock` (`hands`, `feet`) are
pinned to their planted floor position so they stay put while the body moves.
3. **Ground-lock IK**: effectors listed in `ground-lock` (`hands`, `forearms`,
`feet`, or the per-side aliases `hand_left|hand_right`,
`elbow_left|elbow_right`, `foot_left|foot_right`) are pinned to their planted
floor position so they stay put while the body moves. Unsupported effector
names are line-anchored validation errors.
4. **Reach-IK**: a `reach:` line drives an effector (`hand_left|hand_right|
foot_left|foot_right`, or the groups `hands`/`feet` for both sides) to a
world **target** via Cyclic Coordinate Descent (CCD) over the arm/leg chain.
Expand Down Expand Up @@ -202,7 +205,7 @@ interface PosecodeIR {
durationSec: number;
easing: "linear" | "ease-in" | "ease-out" | "ease-in-out";
targets: { boneId: string; euler: { x: number; y: number; z: number } }[];
groundLock: string[]; // ["hands","feet"]
groundLock: string[]; // ["hands","feet"] or ["foot_right"]
cue?: string;
}[];
}
Expand Down
2 changes: 1 addition & 1 deletion spec/llm-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ posecode <kind> "<Name>" # kind = exercise | stretch | posture
step "<Phase name>" <Ns> <easing>: # easing = linear | ease-in | ease-out | ease-in-out
<joint>: <action> <degrees>
reach: <effector> <target> # optional: drive a hand/foot to a target via IK
ground-lock: <effectors> # hands and/or feet pinned to the floor this phase
ground-lock: <effectors> # groups (hands/forearms/feet) or per-side aliases such as foot_right
turn: <degrees> # optional: face this yaw by phase end (standing only)
travel: <x> <z> # optional: move to this x z (metres) by phase end
cue "<short coaching cue>"
Expand Down
Loading