Skip to content

Commit 07fb4a2

Browse files
committed
Add semantic pose checks and prop-aware probing
1 parent 9054727 commit 07fb4a2

23 files changed

Lines changed: 2891 additions & 30 deletions

diagnose_parser.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { readFileSync } from "node:fs";
2+
import { resolve, dirname } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { parse } from "./packages/posecode-parser/dist/index.js";
5+
6+
const here = dirname(fileURLToPath(import.meta.url));
7+
const repoRoot = here;
8+
9+
const originalDeadlift = readFileSync(resolve(repoRoot, "spec/examples/deadlift.posecode"), "utf-8");
10+
11+
const modified = originalDeadlift.replace(
12+
"knees: flex 25",
13+
"knees: flex 25\n ankles: plantarflex 20"
14+
);
15+
16+
console.log("=== Modified posecode ===");
17+
console.log(modified);
18+
19+
const { ir, errors, warnings } = parse(modified);
20+
console.log("=== Parse results ===");
21+
console.log("Errors:", errors);
22+
console.log("Warnings:", warnings);
23+
console.log("Parsed targets for Lower phase:");
24+
console.log(JSON.stringify(ir.phases[0].targets, null, 2));

packages/posecode-eval/src/checks.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@
77

88
import type { PhasePose, ProbeResult } from "./probe.js";
99
import {
10+
balanceOverflow,
11+
distanceBetween,
12+
feetCenterSkateDistance,
13+
footIsSupported,
14+
footSkateDistance,
15+
headPropClearance,
1016
heightOf,
1117
kneeFlexionDeg,
1218
lowestPoint,
19+
palmFloorAngleDeg,
20+
phaseMaxLandmarkSpeed,
1321
segmentTiltDeg,
1422
spineCurlDeg,
1523
torsoPitchDeg,
@@ -81,6 +89,81 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] {
8189
pass: lowestPoint(p) > -0.05,
8290
detail: `lowest bone ${lowestPoint(p).toFixed(3)}m (want > -0.05)`,
8391
});
92+
93+
const floorHands = new Set<string>();
94+
for (const r of p.reaches) {
95+
if (r.target !== "floor") continue;
96+
if (r.effector === "hands" || r.effector === "hand_left") floorHands.add("left");
97+
if (r.effector === "hands" || r.effector === "hand_right") floorHands.add("right");
98+
}
99+
for (const pin of p.pins) {
100+
if (pin.anchor !== "floor") continue;
101+
if (pin.effector === "hands" || pin.effector === "hand_left") floorHands.add("left");
102+
if (pin.effector === "hands" || pin.effector === "hand_right") floorHands.add("right");
103+
}
104+
for (const side of floorHands) {
105+
const angle = palmFloorAngleDeg(p, side as "left" | "right");
106+
out.push({
107+
id: `palm-normal:${p.name}:${side}`,
108+
pass: angle < 55,
109+
detail: `${angle.toFixed(1)}° from palm-down (want < 55°)`,
110+
});
111+
}
112+
113+
const overflow = balanceOverflow(p);
114+
out.push({
115+
id: `balance:${p.name}`,
116+
pass: overflow < 0.3,
117+
detail: `COM ${overflow.toFixed(3)}m outside support base (want < 0.30m)`,
118+
});
119+
120+
const clearance = headPropClearance(result, p);
121+
if (Number.isFinite(clearance)) {
122+
out.push({
123+
id: `head-prop-clearance:${p.name}`,
124+
pass: clearance > -0.01,
125+
detail: `${clearance.toFixed(3)}m clearance (want > -0.01m)`,
126+
});
127+
}
128+
}
129+
130+
for (let i = 1; i < result.phases.length; i++) {
131+
const previous = result.phases[i - 1]!;
132+
const current = result.phases[i]!;
133+
const bothSupported = (["left", "right"] as const).every((side) =>
134+
footIsSupported(previous, side) && footIsSupported(current, side));
135+
if (bothSupported) {
136+
const skate = feetCenterSkateDistance(previous, current);
137+
out.push({
138+
id: `foot-skate:${current.name}:pair`,
139+
pass: skate < 0.08,
140+
detail: `${skate.toFixed(3)}m planted support-center drift (want < 0.08m)`,
141+
});
142+
}
143+
for (const side of ["left", "right"] as const) {
144+
const explicitlyPinned = (phase: PhasePose) => phase.pins.some((p) =>
145+
(p.effector === "feet" || p.effector === `foot_${side}`) && p.anchor === "floor");
146+
if (!explicitlyPinned(previous) || !explicitlyPinned(current)) continue;
147+
const skate = footSkateDistance(previous, current, side);
148+
out.push({
149+
id: `foot-skate:${current.name}:${side}`,
150+
pass: skate < 0.08,
151+
detail: `${skate.toFixed(3)}m pinned-foot drift (want < 0.08m)`,
152+
});
153+
}
154+
const speed = phaseMaxLandmarkSpeed(previous, current);
155+
out.push({
156+
id: `transition-speed:${current.name}`,
157+
pass: speed < 4,
158+
detail: `${speed.toFixed(2)}m/s fastest landmark (want < 4.0m/s)`,
159+
});
160+
if (current.easing === "linear" && speed > 0.15) {
161+
out.push({
162+
id: `transition-easing:${current.name}`,
163+
pass: false,
164+
detail: `moving linear phase enters at ${speed.toFixed(2)}m/s (use eased transition)`,
165+
});
166+
}
84167
}
85168
return out;
86169
}
@@ -113,6 +196,13 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
113196
"< 20° leg tilt",
114197
),
115198
phaseCheck("stands-back-up", "Lift", torsoPitchDeg, (v) => v < 12, "< 12° pitch"),
199+
phaseCheck(
200+
"feet-stay-planted",
201+
"Lower",
202+
(p) => Math.max(heightOf(p, "ankle_left"), heightOf(p, "ankle_right")),
203+
(v) => v < 0.15,
204+
"both ankles < 0.15m",
205+
),
116206
],
117207
},
118208
{
@@ -195,4 +285,42 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
195285
),
196286
],
197287
},
288+
{
289+
movement: "plank-hold",
290+
checks: [
291+
phaseCheck(
292+
"forearms-support-body",
293+
"Hold",
294+
(p) => Math.max(heightOf(p, "elbow_left"), heightOf(p, "elbow_right")),
295+
(v) => v < 0.08,
296+
"both elbows < 0.08m",
297+
),
298+
],
299+
},
300+
{
301+
movement: "crunch",
302+
checks: [
303+
phaseCheck("pelvis-stays-down", "Curl up", (p) => heightOf(p, "pelvis"), (v) => v < 0.18, "pelvis < 0.18m"),
304+
phaseCheck(
305+
"feet-stay-down",
306+
"Curl up",
307+
(p) => Math.max(heightOf(p, "ankle_left"), heightOf(p, "ankle_right")),
308+
(v) => v < 0.18,
309+
"both ankles < 0.18m",
310+
),
311+
],
312+
},
313+
{
314+
movement: "bicycle-crunch",
315+
checks: [
316+
phaseCheck("right-elbow-nears-left-knee", "Right to left", (p) => distanceBetween(p, "elbow_right", "knee_left"), (v) => v < 0.45, "distance < 0.45m"),
317+
phaseCheck("left-elbow-nears-right-knee", "Left to right", (p) => distanceBetween(p, "elbow_left", "knee_right"), (v) => v < 0.45, "distance < 0.45m"),
318+
],
319+
},
320+
{
321+
movement: "superman",
322+
checks: [
323+
phaseCheck("pelvis-remains-supported", "Lift", (p) => heightOf(p, "pelvis"), (v) => v < 0.16, "pelvis < 0.16m"),
324+
],
325+
},
198326
];

packages/posecode-eval/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
/** posecode-eval: public API. */
22

33
export { probeMovement } from "./probe.js";
4-
export type { ProbeResult, PhasePose, Vec3 } from "./probe.js";
4+
export type { ProbeResult, PhasePose, Quat, Vec3 } from "./probe.js";
55
export {
66
angleBetweenDeg,
7+
balanceOverflow,
78
bone,
9+
centerOfMass,
10+
distanceBetween,
811
feetHeight,
12+
feetCenterSkateDistance,
13+
footIsSupported,
14+
footSkateDistance,
15+
headPropClearance,
916
heightOf,
1017
jointAngleDeg,
1118
kneeFlexionDeg,
1219
lowestPoint,
20+
palmFloorAngleDeg,
21+
phaseMaxLandmarkSpeed,
1322
segmentTiltDeg,
1423
spineCurlDeg,
1524
torsoPitchDeg,

packages/posecode-eval/src/metrics.ts

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* invariant checks are written in. All angles in degrees, distances in metres.
44
*/
55

6-
import type { PhasePose, Vec3 } from "./probe.js";
6+
import type { PhasePose, ProbeResult, Quat, Vec3 } from "./probe.js";
77

88
const RAD2DEG = 180 / Math.PI;
99

@@ -67,6 +67,157 @@ export function heightOf(pose: PhasePose, id: string): number {
6767
return bone(pose, id)[1];
6868
}
6969

70+
/** World-space distance between two body landmarks. */
71+
export function distanceBetween(pose: PhasePose, a: string, b: string): number {
72+
return norm(sub(bone(pose, a), bone(pose, b)));
73+
}
74+
75+
function rotateByQuat(v: Vec3, q: Quat): Vec3 {
76+
const [x, y, z, w] = q;
77+
const tx = 2 * (y * v[2] - z * v[1]);
78+
const ty = 2 * (z * v[0] - x * v[2]);
79+
const tz = 2 * (x * v[1] - y * v[0]);
80+
return [
81+
v[0] + w * tx + (y * tz - z * ty),
82+
v[1] + w * ty + (z * tx - x * tz),
83+
v[2] + w * tz + (x * ty - y * tx),
84+
];
85+
}
86+
87+
/** Angle between the palm face normal and the downward floor normal. */
88+
export function palmFloorAngleDeg(pose: PhasePose, side: "left" | "right"): number {
89+
const q = pose.boneQuaternions.get(`wrist_${side}`);
90+
if (!q) return 180;
91+
// The flattened palm's face normal is mirrored local X on the two wrists.
92+
return angleBetweenDeg(rotateByQuat(side === "left" ? [1, 0, 0] : [-1, 0, 0], q), [0, -1, 0]);
93+
}
94+
95+
const MASS_WEIGHTS: ReadonlyArray<readonly [string, number]> = [
96+
["pelvis", 0.22], ["spine", 0.13], ["chest", 0.2], ["head", 0.08],
97+
["hip_left", 0.07], ["hip_right", 0.07], ["knee_left", 0.05], ["knee_right", 0.05],
98+
["shoulder_left", 0.025], ["shoulder_right", 0.025],
99+
["elbow_left", 0.025], ["elbow_right", 0.025],
100+
["ankle_left", 0.015], ["ankle_right", 0.015],
101+
];
102+
103+
/** Approximate whole-body COM from anthropometrically weighted landmarks. */
104+
export function centerOfMass(pose: PhasePose): Vec3 {
105+
let x = 0, y = 0, z = 0, total = 0;
106+
for (const [id, weight] of MASS_WEIGHTS) {
107+
const p = pose.bones.get(id);
108+
if (!p) continue;
109+
x += p[0] * weight; y += p[1] * weight; z += p[2] * weight; total += weight;
110+
}
111+
return total > 0 ? [x / total, y / total, z / total] : [0, 0, 0];
112+
}
113+
114+
function supportBoneIds(pose: PhasePose): string[] {
115+
const ids = new Set<string>();
116+
const addGroup = (name: string) => {
117+
if (name === "feet") { ids.add("ankle_left"); ids.add("ankle_right"); }
118+
if (name === "hands") { ids.add("wrist_left"); ids.add("wrist_right"); }
119+
if (name === "forearms") { ids.add("elbow_left"); ids.add("elbow_right"); }
120+
};
121+
pose.groundLock.forEach(addGroup);
122+
for (const reach of pose.reaches) {
123+
if (reach.target !== "floor") continue;
124+
addGroup(reach.effector);
125+
const mapped = reach.effector.replace("hand_", "wrist_").replace("foot_", "ankle_");
126+
if (pose.bones.has(mapped)) ids.add(mapped);
127+
}
128+
for (const pin of pose.pins) {
129+
addGroup(pin.effector);
130+
const mapped = pin.effector.replace("hand_", "wrist_").replace("foot_", "ankle_");
131+
if (pose.bones.has(mapped)) ids.add(mapped);
132+
}
133+
// Floor poses also distribute load through the torso/pelvis even when the
134+
// authored contact declaration only mentions hands or feet.
135+
for (const id of ["pelvis", "chest", "head"]) {
136+
const p = pose.bones.get(id);
137+
if (p && p[1] < 0.5) ids.add(id);
138+
}
139+
return [...ids];
140+
}
141+
142+
/** Horizontal COM distance outside the active support bounding box (0 = inside). */
143+
export function balanceOverflow(pose: PhasePose): number {
144+
const supports = supportBoneIds(pose).map((id) => bone(pose, id));
145+
if (supports.length < 2) return 0;
146+
const com = centerOfMass(pose);
147+
const margin = 0.14;
148+
const minX = Math.min(...supports.map((p) => p[0])) - margin;
149+
const maxX = Math.max(...supports.map((p) => p[0])) + margin;
150+
const minZ = Math.min(...supports.map((p) => p[2])) - margin;
151+
const maxZ = Math.max(...supports.map((p) => p[2])) + margin;
152+
const dx = Math.max(minX - com[0], 0, com[0] - maxX);
153+
const dz = Math.max(minZ - com[2], 0, com[2] - maxZ);
154+
return Math.hypot(dx, dz);
155+
}
156+
157+
/** Clearance between the head sphere and known prop geometry; Infinity if none. */
158+
export function headPropClearance(result: ProbeResult, pose: PhasePose): number {
159+
const h = bone(pose, "head");
160+
let clearance = Infinity;
161+
if (result.propTypes.includes("bar")) {
162+
const closestX = Math.max(-0.6, Math.min(0.6, h[0]));
163+
clearance = Math.min(clearance, Math.hypot(h[0] - closestX, h[1] - 2.3, h[2]) - 0.13);
164+
}
165+
if (result.propTypes.includes("wall")) {
166+
clearance = Math.min(clearance, Math.abs(h[2] - (-0.29)) - 0.105);
167+
}
168+
if (result.propTypes.includes("chair")) {
169+
const dx = Math.max(Math.abs(h[0]) - 0.21, 0);
170+
const dy = Math.max(Math.abs(h[1] - 0.78) - 0.25, 0);
171+
const dz = Math.max(Math.abs(h[2] - (-0.34)) - 0.03, 0);
172+
clearance = Math.min(clearance, Math.hypot(dx, dy, dz) - 0.105);
173+
}
174+
return clearance;
175+
}
176+
177+
/** Fastest landmark's average speed from the previous endpoint into this phase. */
178+
export function phaseMaxLandmarkSpeed(previous: PhasePose | null, pose: PhasePose): number {
179+
if (!previous || pose.durationSec <= 0) return 0;
180+
let max = 0;
181+
for (const [id, p] of pose.bones) {
182+
const before = previous.bones.get(id);
183+
if (before) max = Math.max(max, norm(sub(p, before)) / pose.durationSec);
184+
}
185+
return max;
186+
}
187+
188+
export function footSkateDistance(previous: PhasePose, pose: PhasePose, side: "left" | "right"): number {
189+
const id = `ankle_${side}`;
190+
const local = (p: Vec3, phase: PhasePose): readonly [number, number] => {
191+
const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2];
192+
const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw);
193+
return [x * c - z * s, x * s + z * c];
194+
};
195+
const a = local(bone(previous, id), previous), b = local(bone(pose, id), pose);
196+
return Math.hypot(b[0] - a[0], b[1] - a[1]);
197+
}
198+
199+
/** Drift of the planted foot-pair center, ignoring intentional stance-width changes. */
200+
export function feetCenterSkateDistance(previous: PhasePose, pose: PhasePose): number {
201+
const delta = (side: "left" | "right") => {
202+
const id = `ankle_${side}`;
203+
const a = bone(previous, id), b = bone(pose, id);
204+
const unyaw = (p: Vec3, phase: PhasePose) => {
205+
const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2];
206+
const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw);
207+
return [x * c - z * s, x * s + z * c] as const;
208+
};
209+
const aa = unyaw(a, previous), bb = unyaw(b, pose);
210+
return [bb[0] - aa[0], bb[1] - aa[1]] as const;
211+
};
212+
const l = delta("left"), r = delta("right");
213+
return Math.hypot((l[0] + r[0]) / 2, (l[1] + r[1]) / 2);
214+
}
215+
216+
export function footIsSupported(pose: PhasePose, side: "left" | "right"): boolean {
217+
return pose.groundLock.includes("feet") || pose.pins.some((p) =>
218+
(p.effector === "feet" || p.effector === `foot_${side}`) && p.anchor === "floor");
219+
}
220+
70221
/** Lowest bone height in the pose (should never be much below 0). */
71222
export function lowestPoint(pose: PhasePose): number {
72223
let min = Infinity;

0 commit comments

Comments
 (0)