From 3fd801b65bb069b5459063bc1c1004265b4278d4 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Wed, 15 Jul 2026 10:54:06 +0300 Subject: [PATCH] Support single-foot ground locking --- packages/posecode-eval/src/metrics.ts | 6 ++-- packages/posecode-language/src/vocab.ts | 5 ++-- .../posecode-language/test/language.test.ts | 2 +- packages/posecode-parser/src/clamp.ts | 15 +++++++++- packages/posecode-parser/src/index.ts | 2 ++ packages/posecode-parser/src/joints.ts | 24 ++++++++++++++++ packages/posecode-parser/src/parser.ts | 3 ++ packages/posecode-parser/src/schema.ts | 1 + packages/posecode-parser/src/types.ts | 2 +- packages/posecode-parser/test/parse.test.ts | 28 +++++++++++++++++++ packages/posecode-render/src/contacts.ts | 11 ++++++-- packages/posecode-render/src/groundlock.ts | 4 +-- packages/posecode-render/src/index.ts | 2 +- packages/posecode-render/src/mannequin.ts | 6 ++++ .../posecode-render/test/contacts.test.ts | 13 +++++++++ packages/posecode-render/test/render.test.ts | 22 +++++++++++++++ playground/public/llm-guide.html | 2 +- playground/public/spec.html | 6 ++-- spec/SPEC.md | 9 ++++-- spec/llm-authoring.md | 2 +- 20 files changed, 144 insertions(+), 21 deletions(-) diff --git a/packages/posecode-eval/src/metrics.ts b/packages/posecode-eval/src/metrics.ts index 1ab1ba9..b490332 100644 --- a/packages/posecode-eval/src/metrics.ts +++ b/packages/posecode-eval/src/metrics.ts @@ -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). */ diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts index cf2024f..e628d2f 100644 --- a/packages/posecode-language/src/vocab.ts +++ b/packages/posecode-language/src/vocab.ts @@ -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 }; @@ -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; @@ -49,7 +50,7 @@ export const KEYWORD_DOCS: Record = { 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).", diff --git a/packages/posecode-language/test/language.test.ts b/packages/posecode-language/test/language.test.ts index 7efdadc..352f0ea 100644 --- a/packages/posecode-language/test/language.test.ts +++ b/packages/posecode-language/test/language.test.ts @@ -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"]), ); }); diff --git a/packages/posecode-parser/src/clamp.ts b/packages/posecode-parser/src/clamp.ts index 1bc0923..a4984cb 100644 --- a/packages/posecode-parser/src/clamp.ts +++ b/packages/posecode-parser/src/clamp.ts @@ -26,6 +26,7 @@ import { expandEffector, expandJoint, flexionSign, + isGroundLockEffector, isLeft, } from "./joints.js"; import { clampAngle, romFor } from "./rom.js"; @@ -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. @@ -176,7 +189,7 @@ function resolveStep( durationSec: step.durationSec, easing: step.easing as Phase["easing"], targets, - groundLock: step.groundLock, + groundLock, reaches, pins, grips, diff --git a/packages/posecode-parser/src/index.ts b/packages/posecode-parser/src/index.ts index ff457bd..7c16daf 100644 --- a/packages/posecode-parser/src/index.ts +++ b/packages/posecode-parser/src/index.ts @@ -53,8 +53,10 @@ export { JOINT_NAMES, ACTION_NAMES, EFFECTOR_NAMES, + GROUND_LOCK_EFFECTOR_NAMES, expandJoint, expandEffector, + isGroundLockEffector, actionAxis, boneType, } from "./joints.js"; diff --git a/packages/posecode-parser/src/joints.ts b/packages/posecode-parser/src/joints.ts index 84631a6..747547d 100644 --- a/packages/posecode-parser/src/joints.ts +++ b/packages/posecode-parser/src/joints.ts @@ -120,6 +120,30 @@ const EFFECTOR_GROUPS: Record = { /** 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(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(EFFECTOR_SIDES); /** diff --git a/packages/posecode-parser/src/parser.ts b/packages/posecode-parser/src/parser.ts index eb09014..1bd5618 100644 --- a/packages/posecode-parser/src/parser.ts +++ b/packages/posecode-parser/src/parser.ts @@ -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[]; @@ -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; } diff --git a/packages/posecode-parser/src/schema.ts b/packages/posecode-parser/src/schema.ts index 4ced7d1..bb33ba5 100644 --- a/packages/posecode-parser/src/schema.ts +++ b/packages/posecode-parser/src/schema.ts @@ -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), diff --git a/packages/posecode-parser/src/types.ts b/packages/posecode-parser/src/types.ts index c4f3b0d..ca95148 100644 --- a/packages/posecode-parser/src/types.ts +++ b/packages/posecode-parser/src/types.ts @@ -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[]; diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts index 9e9838e..709bae9 100644 --- a/packages/posecode-parser/test/parse.test.ts +++ b/packages/posecode-parser/test/parse.test.ts @@ -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"', diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index 35b4f9a..0a1fdee 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -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); } @@ -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 diff --git a/packages/posecode-render/src/groundlock.ts b/packages/posecode-render/src/groundlock.ts index c90e3a3..89ede25 100644 --- a/packages/posecode-render/src/groundlock.ts +++ b/packages/posecode-render/src/groundlock.ts @@ -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 @@ -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(); for (const group of active) { diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 4da7206..cf3b280 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -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; } diff --git a/packages/posecode-render/src/mannequin.ts b/packages/posecode-render/src/mannequin.ts index d41b060..5191b79 100644 --- a/packages/posecode-render/src/mannequin.ts +++ b/packages/posecode-render/src/mannequin.ts @@ -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, }; diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts index d2245f4..cf968fd 100644 --- a/packages/posecode-render/test/contacts.test.ts +++ b/packages/posecode-render/test/contacts.test.ts @@ -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)", () => { diff --git a/packages/posecode-render/test/render.test.ts b/packages/posecode-render/test/render.test.ts index be37b40..0bc3c10 100644 --- a/packages/posecode-render/test/render.test.ts +++ b/packages/posecode-render/test/render.test.ts @@ -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"]); }); }); @@ -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( [ diff --git a/playground/public/llm-guide.html b/playground/public/llm-guide.html index dd19a4d..bfa082f 100644 --- a/playground/public/llm-guide.html +++ b/playground/public/llm-guide.html @@ -133,7 +133,7 @@

Grammar

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>" diff --git a/playground/public/spec.html b/playground/public/spec.html index 4c54ae3..976ead3 100644 --- a/playground/public/spec.html +++ b/playground/public/spec.html @@ -199,8 +199,8 @@

5. Rendering model

bone rotations between phases with the phase's easing.

  1. 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.

-
  1. 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.

+
  1. 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.

  1. 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. A target is a body landmark bone (e.g. ankle_left), the keyword floor, or a prop anchor (bar, seat, wall`). The solve is ROM-constrained: 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.

  1. Props: prop chair|wall|bar|box|dip-bars adds a scene object at a
@@ -229,7 +229,7 @@

6. Intermediate Representation (IR)

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; }[]; } diff --git a/spec/SPEC.md b/spec/SPEC.md index e861f0b..1dd0d38 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -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. @@ -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; }[]; } diff --git a/spec/llm-authoring.md b/spec/llm-authoring.md index 37a81b4..50f0450 100644 --- a/spec/llm-authoring.md +++ b/spec/llm-authoring.md @@ -20,7 +20,7 @@ posecode "" # kind = exercise | stretch | posture step "" : # easing = linear | ease-in | ease-out | ease-in-out : reach: # optional: drive a hand/foot to a target via IK - ground-lock: # hands and/or feet pinned to the floor this phase + ground-lock: # groups (hands/forearms/feet) or per-side aliases such as foot_right turn: # optional: face this yaw by phase end (standing only) travel: # optional: move to this x z (metres) by phase end cue ""