diff --git a/packages/posecode-eval/src/diagnostics.ts b/packages/posecode-eval/src/diagnostics.ts index b6e89c4..173a015 100644 --- a/packages/posecode-eval/src/diagnostics.ts +++ b/packages/posecode-eval/src/diagnostics.ts @@ -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), @@ -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; @@ -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); diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index bf3dddb..13592d1 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -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)); diff --git a/packages/posecode-eval/test/locomotion-grounding.test.ts b/packages/posecode-eval/test/locomotion-grounding.test.ts new file mode 100644 index 0000000..e2d555a --- /dev/null +++ b/packages/posecode-eval/test/locomotion-grounding.test.ts @@ -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, + ); + }); +}); diff --git a/spec/examples/box-step-taps.posecode b/spec/examples/box-step-taps.posecode index 80db227..4f2a0cd 100644 --- a/spec/examples/box-step-taps.posecode +++ b/spec/examples/box-step-taps.posecode @@ -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: @@ -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 diff --git a/spec/examples/box-step.posecode b/spec/examples/box-step.posecode index d8ccd19..39539f5 100644 --- a/spec/examples/box-step.posecode +++ b/spec/examples/box-step.posecode @@ -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 diff --git a/spec/examples/chasse.posecode b/spec/examples/chasse.posecode index 1dac32b..6b1819f 100644 --- a/spec/examples/chasse.posecode +++ b/spec/examples/chasse.posecode @@ -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 diff --git a/spec/examples/front-kick.posecode b/spec/examples/front-kick.posecode index e7e0ebb..ef740f7 100644 --- a/spec/examples/front-kick.posecode +++ b/spec/examples/front-kick.posecode @@ -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 diff --git a/spec/examples/grapevine.posecode b/spec/examples/grapevine.posecode index 50e3c2f..8a07184 100644 --- a/spec/examples/grapevine.posecode +++ b/spec/examples/grapevine.posecode @@ -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 diff --git a/spec/examples/high-knee-march.posecode b/spec/examples/high-knee-march.posecode index 9df623b..e824e52 100644 --- a/spec/examples/high-knee-march.posecode +++ b/spec/examples/high-knee-march.posecode @@ -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 diff --git a/spec/examples/hip-abduction.posecode b/spec/examples/hip-abduction.posecode index d8073e5..912b49c 100644 --- a/spec/examples/hip-abduction.posecode +++ b/spec/examples/hip-abduction.posecode @@ -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 diff --git a/spec/examples/hip-flexion-demo.posecode b/spec/examples/hip-flexion-demo.posecode index 43f4d43..8dbb6ab 100644 --- a/spec/examples/hip-flexion-demo.posecode +++ b/spec/examples/hip-flexion-demo.posecode @@ -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 diff --git a/spec/examples/quarter-turns.posecode b/spec/examples/quarter-turns.posecode index 6ce5775..db9804e 100644 --- a/spec/examples/quarter-turns.posecode +++ b/spec/examples/quarter-turns.posecode @@ -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: @@ -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: @@ -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: @@ -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 diff --git a/spec/examples/tendu.posecode b/spec/examples/tendu.posecode index 3857154..81d94a1 100644 --- a/spec/examples/tendu.posecode +++ b/spec/examples/tendu.posecode @@ -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 diff --git a/spec/examples/walk-cycle.posecode b/spec/examples/walk-cycle.posecode index e11e5dd..2fc8c67 100644 --- a/spec/examples/walk-cycle.posecode +++ b/spec/examples/walk-cycle.posecode @@ -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: @@ -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 diff --git a/spec/examples/waltz-box.posecode b/spec/examples/waltz-box.posecode index 71701e2..7e744bf 100644 --- a/spec/examples/waltz-box.posecode +++ b/spec/examples/waltz-box.posecode @@ -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: @@ -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