Skip to content

Commit 3fd801b

Browse files
committed
Support single-foot ground locking
1 parent 743de2d commit 3fd801b

20 files changed

Lines changed: 144 additions & 21 deletions

File tree

packages/posecode-eval/src/metrics.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,10 @@ export function feetCenterSkateDistance(previous: PhasePose, pose: PhasePose): n
324324
}
325325

326326
export function footIsSupported(pose: PhasePose, side: "left" | "right"): boolean {
327-
return pose.groundLock.includes("feet") || pose.pins.some((p) =>
328-
(p.effector === "feet" || p.effector === `foot_${side}`) && p.anchor === "floor");
327+
return pose.groundLock.includes("feet")
328+
|| pose.groundLock.includes(`foot_${side}`)
329+
|| pose.pins.some((p) =>
330+
(p.effector === "feet" || p.effector === `foot_${side}`) && p.anchor === "floor");
329331
}
330332

331333
/** Lowest bone height in the pose (should never be much below 0). */

packages/posecode-language/src/vocab.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
MODES,
1212
LEGACY_MODE_ALIASES,
1313
EFFECTOR_NAMES,
14+
GROUND_LOCK_EFFECTOR_NAMES,
1415
} from "posecode-parser";
1516

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

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

2728
/** Reach/pin effectors (groups + per-side aliases), sourced from the parser. */
2829
export const REACH_EFFECTORS = EFFECTOR_NAMES;
@@ -49,7 +50,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
4950
snap: "Timing mode: fast, near-immediate arrival — an accent.",
5051
linear: "Timing mode: constant velocity — intentionally mechanical.",
5152
repeat: "How many times the movement loops.",
52-
"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).",
53+
"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).",
5354
reach:
5455
"Drives an effector to a target via ROM-constrained IK: `reach: hand_left ankle_left`, `reach: hands floor`.",
5556
pin: "Moves the body so an effector sits on an anchor: `pin: hands bar` (hang, pull up, step up, dip).",

packages/posecode-language/test/language.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe("getCompletions", () => {
7575

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

packages/posecode-parser/src/clamp.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
expandEffector,
2727
expandJoint,
2828
flexionSign,
29+
isGroundLockEffector,
2930
isLeft,
3031
} from "./joints.js";
3132
import { clampAngle, romFor } from "./rom.js";
@@ -125,6 +126,18 @@ function resolveStep(
125126
euler,
126127
}));
127128

129+
const groundLock: string[] = [];
130+
for (const effector of step.groundLock) {
131+
if (!isGroundLockEffector(effector)) {
132+
errors.push({
133+
line: step.groundLockLine ?? step.line,
134+
message: `unknown ground-lock effector: "${effector}"`,
135+
});
136+
continue;
137+
}
138+
groundLock.push(effector);
139+
}
140+
128141
// Reach / pin effectors: expand symmetric groups (`hands` → both hands) and
129142
// reject unknown names, since a typo'd effector would otherwise be silently
130143
// ignored by the renderer, invisible to the authoring LLM.
@@ -176,7 +189,7 @@ function resolveStep(
176189
durationSec: step.durationSec,
177190
easing: step.easing as Phase["easing"],
178191
targets,
179-
groundLock: step.groundLock,
192+
groundLock,
180193
reaches,
181194
pins,
182195
grips,

packages/posecode-parser/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ export {
5353
JOINT_NAMES,
5454
ACTION_NAMES,
5555
EFFECTOR_NAMES,
56+
GROUND_LOCK_EFFECTOR_NAMES,
5657
expandJoint,
5758
expandEffector,
59+
isGroundLockEffector,
5860
actionAxis,
5961
boneType,
6062
} from "./joints.js";

packages/posecode-parser/src/joints.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,30 @@ const EFFECTOR_GROUPS: Record<string, string[]> = {
120120
/** Every effector name `reach:` / `pin:` accept: groups + per-side aliases. */
121121
export const EFFECTOR_NAMES = [...Object.keys(EFFECTOR_GROUPS), ...EFFECTOR_SIDES];
122122

123+
/**
124+
* Effectors accepted by `ground-lock:`. Ground locking has historically
125+
* supported the symmetric hand/forearm/foot groups; per-side aliases let a
126+
* movement keep one support planted while the opposite limb moves freely.
127+
*/
128+
export const GROUND_LOCK_EFFECTOR_NAMES = [
129+
"hands",
130+
"hand_left",
131+
"hand_right",
132+
"forearms",
133+
"elbow_left",
134+
"elbow_right",
135+
"feet",
136+
"foot_left",
137+
"foot_right",
138+
] as const;
139+
140+
const GROUND_LOCK_EFFECTOR_SET = new Set<string>(GROUND_LOCK_EFFECTOR_NAMES);
141+
142+
/** True when an effector is implemented by the ground-lock solver. */
143+
export function isGroundLockEffector(name: string): boolean {
144+
return GROUND_LOCK_EFFECTOR_SET.has(name);
145+
}
146+
123147
const EFFECTOR_SIDE_SET = new Set<string>(EFFECTOR_SIDES);
124148

125149
/**

packages/posecode-parser/src/parser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export interface AstStep {
3535
easing: string;
3636
targets: AstJointTarget[];
3737
groundLock: string[];
38+
/** Source line of the active `ground-lock:` declaration. */
39+
groundLockLine?: number;
3840
reaches: AstReach[];
3941
pins: AstPin[];
4042
grips: AstPin[];
@@ -222,6 +224,7 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
222224
.filter((tok) => tok.type === "word")
223225
.map((tok) => tok.value);
224226
current.groundLock = effectors;
227+
current.groundLockLine = ln.line;
225228
return null;
226229
}
227230

packages/posecode-parser/src/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const stepSchema = z.object({
6060
easing: z.enum(MODES),
6161
targets: z.array(jointTargetSchema),
6262
groundLock: z.array(z.string()),
63+
groundLockLine: z.number().optional(),
6364
reaches: z.array(reachSchema),
6465
pins: z.array(pinSchema),
6566
grips: z.array(pinSchema),

packages/posecode-parser/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export interface Phase {
6666
durationSec: number;
6767
easing: Easing;
6868
targets: JointTarget[];
69-
/** Effector groups / prop anchors pinned for this phase, e.g. ["hands", "feet"]. */
69+
/** Grouped or per-side floor effectors pinned for this phase, e.g. ["feet"] or ["foot_right"]. */
7070
groundLock: string[];
7171
/** Reach-IK goals active during this phase. */
7272
reaches: ReachTarget[];

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,34 @@ describe("parse", () => {
9797
expect(errors[0]!.message).toMatch(/unknown joint/i);
9898
});
9999

100+
it("accepts per-side ground-lock effectors", () => {
101+
const src = [
102+
'posecode exercise "Single-leg pivot"',
103+
" rig humanoid",
104+
' step "Turn" 1s linear:',
105+
" turn: 180",
106+
" ground-lock: foot_right",
107+
].join("\n");
108+
const { ir, errors } = parse(src);
109+
expect(errors).toEqual([]);
110+
expect(ir!.phases[0]!.groundLock).toEqual(["foot_right"]);
111+
});
112+
113+
it("reports a line-anchored error for an unsupported ground-lock effector", () => {
114+
const src = [
115+
'posecode exercise "Typo"',
116+
" rig humanoid",
117+
' step "Turn" 1s linear:',
118+
" turn: 180",
119+
" ground-lock: shoe_right",
120+
].join("\n");
121+
const { ir, errors } = parse(src);
122+
expect(ir).toBeNull();
123+
expect(errors).toEqual([
124+
{ line: 5, message: 'unknown ground-lock effector: "shoe_right"' },
125+
]);
126+
});
127+
100128
it("reports an error when a step child has no enclosing step", () => {
101129
const src = [
102130
'posecode exercise "Orphan"',

0 commit comments

Comments
 (0)