Skip to content
66 changes: 44 additions & 22 deletions packages/posecode-eval/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,10 @@ export interface ClipDiagnosticsCollector {
finish(): ClipDiagnostics;
}

export function createClipDiagnosticsCollector(sampleRateHz: number): ClipDiagnosticsCollector {
export function createClipDiagnosticsCollector(
sampleRateHz: number,
isLocomotion = false,
): ClipDiagnosticsCollector {
const rate = Math.max(
1,
Math.min(120, Number.isFinite(sampleRateHz) ? sampleRateHz : DEFAULT_DIAGNOSTIC_SAMPLE_RATE_HZ),
Expand All @@ -195,16 +198,24 @@ export function createClipDiagnosticsCollector(sampleRateHz: number): ClipDiagno
const state = feet[side];
const foot = measureFootContact(m, side);
let kind = supportKind(frame, side);
// The generic `feet` group also contains a deliberately lifted swing
// foot. Match ground-lock's own near-floor selection so that swing height
// is not mislabeled as a failed planted contact; an explicit foot lock or
// floor pin is always evaluated.
// A supported foot whose sole is well off the floor is mid-swing, not
// planted. Skip it in two cases: (1) the generic `feet` group's lifted
// swing foot, and (2) any airborne foot in a locomotion clip — at a step
// transition the stance pin alternates a beat before the landing foot is
// actually down, so the descending foot is swinging, not a failed plant.
// The endpoint contact-position check still catches a pin left airborne.
const airborne =
kind !== null
&& foot !== null
&& !isGroundLockFootPlanted(floorContactHeight(m, `foot_${side}`) ?? NaN);
if (
kind === "ground-lock"
&& frame.groundLock.includes("feet")
&& !frame.groundLock.includes(`foot_${side}`)
&& foot
&& !isGroundLockFootPlanted(floorContactHeight(m, `foot_${side}`) ?? NaN)
airborne
&& (
(kind === "ground-lock"
&& frame.groundLock.includes("feet")
&& !frame.groundLock.includes(`foot_${side}`))
|| isLocomotion
)
) kind = null;
if (!kind || !foot) {
state.supportKind = null;
Expand All @@ -214,21 +225,32 @@ export function createClipDiagnosticsCollector(sampleRateHz: number): ClipDiagno
}
state.supportedSamples++;
const location = { timeSec: frame.timeSec, phaseName: frame.phaseName };
state.minToeHeightMeters = Math.min(state.minToeHeightMeters ?? Infinity, foot.toeHeight);
state.maxToeHeightMeters = Math.max(state.maxToeHeightMeters ?? -Infinity, foot.toeHeight);
if (Math.abs(foot.toeHeight) > state.worstToeAbs) {
state.worstToeAbs = Math.abs(foot.toeHeight);
state.worstToe = location;
}
// Flat-sole grounding checks only apply when a flat foot is expected: the
// ankle is not plantarflexed AND the shin stands near-vertical. A foot on
// its ball with the shin laid down (plank, knee-drive) legitimately shows
// a steep sole and lifted heel, so measuring it as a failed flat plant
// fabricates warnings.
// Flat-foot grounding checks (heel/toe height, sole tilt) only apply when a
// flat foot is expected: the ankle is not plantarflexed AND the shin stands
// near-vertical. A foot on its ball with the shin laid down (plank,
// knee-drive) legitimately shows a lifted heel/toe and a steep sole, so
// measuring it as a failed flat plant fabricates warnings.
const shinDeg = shinFromVerticalDeg(m, side);
// A stance foot in a locomotion clip rolls onto its ball as the body
// travels over it — the toe stays planted while the heel lifts (push-off).
// That roll is correct gait, not a failed flat plant, so it is exempt from
// the flat-foot checks. A fully airborne foot (toe also lifted) is not a
// roll and stays measured; static clips keep the strict flat-foot bar.
const pushOffRoll =
isLocomotion
&& Math.abs(foot.toeHeight) <= FOOT_CONTACT_HEIGHT_MAX
&& foot.heelHeight > FOOT_CONTACT_HEIGHT_MAX;
const expectedFlat =
foot.plantigrade && (shinDeg === null || shinDeg <= FLAT_SOLE_SHIN_MAX_DEG);
foot.plantigrade
&& !pushOffRoll
&& (shinDeg === null || shinDeg <= FLAT_SOLE_SHIN_MAX_DEG);
if (expectedFlat) {
state.minToeHeightMeters = Math.min(state.minToeHeightMeters ?? Infinity, foot.toeHeight);
state.maxToeHeightMeters = Math.max(state.maxToeHeightMeters ?? -Infinity, foot.toeHeight);
if (Math.abs(foot.toeHeight) > state.worstToeAbs) {
state.worstToeAbs = Math.abs(foot.toeHeight);
state.worstToe = location;
}
state.plantigradeSamples++;
state.minHeelHeightMeters = Math.min(state.minHeelHeightMeters ?? Infinity, foot.heelHeight);
state.maxHeelHeightMeters = Math.max(state.maxHeelHeightMeters ?? -Infinity, foot.heelHeight);
Expand Down
2 changes: 1 addition & 1 deletion packages/posecode-eval/src/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,7 @@ export function probeMovement(
// Endpoint probes above power semantic movement checks. Separately sample
// the solved clip between endpoints so a heel lift or collision that appears
// only mid-transition cannot hide behind two valid terminal poses.
const diagnosticsCollector = createClipDiagnosticsCollector(diagnosticSampleRateHz);
const diagnosticsCollector = createClipDiagnosticsCollector(diagnosticSampleRateHz, clipHasTravel);
for (let phaseIndex = 0; phaseIndex < tl.segments.length; phaseIndex++) {
const seg = tl.segments[phaseIndex]!;
const steps = Math.max(1, Math.ceil((seg.end - seg.start) * diagnosticSampleRateHz));
Expand Down
47 changes: 47 additions & 0 deletions packages/posecode-eval/test/locomotion-grounding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { probeMovement } from "../src/index.js";

const examplesDir = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../../spec/examples",
);
const load = (name: string): string =>
readFileSync(resolve(examplesDir, `${name}.posecode`), "utf8");

const footWarnings = (name: string) =>
probeMovement(load(name)).diagnostics.warnings.filter((w) =>
["heel-height", "toe-height", "sole-angle", "grounding-rom-conflict"].includes(w.kind),
);

/**
* A stance foot in a traveling clip rolls onto its ball as the body passes
* (push-off) and is briefly airborne at each step transition — correct gait,
* not a grounding artifact, so it is exempt from the static flat-foot checks.
* A static clip keeps the strict bar, so genuine heel-lift must still surface.
*/
describe("locomotion grounding exemption", () => {
it("exempts a locomotion stance foot's airborne swing (no large float at a step transition)", () => {
// Before the fix, box-step's stance pin was measured while it was still
// descending from the previous phase's swing — a ~0.12m heel float and a
// ~33° sole tilt mid-transition. The airborne-swing exemption removes those
// large phase-transition floats (a smaller settling roll may remain).
const large = footWarnings("box-step").filter(
(w) =>
(w.kind === "heel-height" && w.value > 0.1) ||
(w.kind === "sole-angle" && w.value > 30),
);
expect(large, large.map((w) => w.detail).join("\n")).toHaveLength(0);
});

it("still flags genuine heel-lift in a static deep pose (no over-suppression)", () => {
// superhero-landing holds a deep static landing whose shin exceeds the ankle
// dorsiflexion ROM; that heel-lift is a real artifact and must stay flagged.
const warns = footWarnings("superhero-landing");
expect(warns.some((w) => w.kind === "grounding-rom-conflict" || w.kind === "heel-height")).toBe(
true,
);
});
});
6 changes: 4 additions & 2 deletions spec/examples/box-step-taps.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ posecode exercise "Box step taps"
hip_right: flex 0
knee_right: flex 0
ankle_right: plantarflex 0
ground-lock: feet
ground-lock: foot_left
reach: foot_right floor
cue "Return the right foot to the floor"

step "Left tap" 0.6s settle:
Expand All @@ -28,7 +29,8 @@ posecode exercise "Box step taps"
hip_left: flex 0
knee_left: flex 0
ankle_left: plantarflex 0
ground-lock: feet
ground-lock: foot_right
reach: foot_left floor
cue "Back to the floor: keep a light, quick rhythm"

repeat 6
3 changes: 2 additions & 1 deletion spec/examples/box-step.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ posecode exercise "Box step"
ankle_right: plantarflex 0
shoulders: flex 0
travel: 0 0
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Close the left foot and settle over both feet, completing the square"

repeat 4
3 changes: 2 additions & 1 deletion spec/examples/chasse.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ posecode exercise "Chassé"
shoulders: abduct 0
elbows: flex 0
travel: 0 0
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Close home over both feet and let the momentum resolve"

repeat 4
3 changes: 2 additions & 1 deletion spec/examples/front-kick.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ posecode exercise "Front kick"
step "Return" 0.6s settle:
hip_right: flex 0
knee_right: flex 0
ground-lock: feet
ground-lock: foot_left
reach: foot_right floor
cue "Set the foot back down to a fighting stance"

repeat 5
3 changes: 2 additions & 1 deletion spec/examples/grapevine.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ posecode exercise "Grapevine"
hip_right: abduct 0
shoulders: abduct 0
travel: 0 0
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Close the left foot under the body and settle the phrase"

repeat 3
5 changes: 3 additions & 2 deletions spec/examples/high-knee-march.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ posecode exercise "High-knee march"
hip_left: flex 90
knee_left: flex 90
shoulder_right: flex 40
pin: foot_right floor
reach: foot_right floor
cue "Plant and drive the left knee up"

step "Down" 0.6s settle:
hip_left: flex 0
knee_left: flex 0
shoulder_right: flex 0
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Return to a tall, ready stance"

repeat 6
3 changes: 2 additions & 1 deletion spec/examples/hip-abduction.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ posecode exercise "Standing hip abduction"

step "Lower" 1.6s settle:
hip_right: abduct 0
ground-lock: feet
ground-lock: foot_left
reach: foot_right floor
cue "Lower the leg back to the midline"

repeat 10
3 changes: 2 additions & 1 deletion spec/examples/hip-flexion-demo.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ posecode stretch "Hip flexion (ROM demo)"

step "Lower" 2.5s settle:
hip_right: flex 0
ground-lock: feet
ground-lock: foot_left
reach: foot_right floor
cue "Lower the leg back under the hip"

repeat 4
12 changes: 8 additions & 4 deletions spec/examples/quarter-turns.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ posecode posture "Quarter turns"
ankles: plantarflex 20
shoulders: abduct 30
turn: 90
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Small dip and pivot a quarter-turn to the right"

step "Face back" 1s flow:
Expand All @@ -17,7 +18,8 @@ posecode posture "Quarter turns"
ankles: plantarflex 0
shoulders: abduct 0
turn: 180
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Rise, then pivot another quarter-turn to face the back"

step "Face left" 1s flow:
Expand All @@ -26,7 +28,8 @@ posecode posture "Quarter turns"
ankles: plantarflex 20
shoulders: abduct 30
turn: 270
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Dip and pivot again to face the far side"

step "Face front" 1s settle:
Expand All @@ -35,7 +38,8 @@ posecode posture "Quarter turns"
ankles: plantarflex 0
shoulders: abduct 0
turn: 360
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Rise and complete the circle, back to front"

repeat 3
3 changes: 2 additions & 1 deletion spec/examples/tendu.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ posecode exercise "Tendu"
ankle_right: plantarflex 0
shoulders: abduct 0
elbows: flex 0
ground-lock: feet
ground-lock: foot_left
reach: foot_right floor
cue "Draw the foot back to first position, heel down"

repeat 4
6 changes: 4 additions & 2 deletions spec/examples/walk-cycle.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ posecode exercise "Walk & turn"
shoulders: flex 0
turn: 180
travel: 0 0.66
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Plant and turn a half-turn to face back the way you came"

step "Walk back" 0.7s flow:
Expand All @@ -48,7 +49,8 @@ posecode exercise "Walk & turn"
shoulders: flex 0
turn: 360
travel: 0 0
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Arrive home and turn to face front again"

repeat 2
6 changes: 4 additions & 2 deletions spec/examples/waltz-box.posecode
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ posecode stretch "Waltz box step"
knee_left: flex 0
ankles: plantarflex 0
travel: -0.2 0.2
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Close the right foot to the left and lower softly through count three"

step "4 - left foot back" 0.95s flow:
Expand Down Expand Up @@ -57,7 +58,8 @@ posecode stretch "Waltz box step"
shoulders: abduct 0
elbows: flex 0
travel: 0 0
ground-lock: feet
reach: foot_left floor
reach: foot_right floor
cue "Close the left foot and lower with control to complete the six-count box"

repeat 3