Skip to content

Commit 1dd20bc

Browse files
Merge pull request #31 from posecode-dev/codex/improve-motion-quality
Improve contact-aware motion quality
2 parents 07fb4a2 + 18a6ac7 commit 1dd20bc

74 files changed

Lines changed: 846 additions & 223 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ROADMAP.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ These are the unlocks, roughly in order of leverage:
3939
3. ~~**Scene props with contact anchors**~~: **shipped (starter set).** `prop
4040
chair|wall|bar` adds a scene object with named anchors (`seat`, `wall`, `bar`).
4141
Powers `sit-to-stand`, `box-squat`, `wall-sit`, `dead-hang`, `hanging-knee-raise`.
42-
Next: more props (bench, rings, bands), load cues, anchor-aware ground-lock.
42+
Bar and dip-bar contacts now resolve to independent left/right anchors with
43+
terminal wrist orientation; mocap is contact-corrected after blending.
44+
Next: more props (bench, rings, bands), load cues, arbitrary surface shapes.
4345
4. ~~**Lying & seated base poses**~~: **shipped.** `supine | prone | seated`
4446
start poses (grounded by a bounding-box drop). Powers `glute-bridge`,
4547
`dead-bug`, `cobra`, `seated-forward-fold`. Next: quadruped + chair-seated.
@@ -51,8 +53,9 @@ These are the unlocks, roughly in order of leverage:
5153
both absolute + carried across phases and returning home on the loop wrap.
5254
Powers `pirouette`, `box-step`, `grapevine`, `waltz-box`, `chasse`,
5355
`walk-cycle`, `quarter-turns`: pirouettes, traveling combos, and gait.
54-
Standing poses only. Next: footstep-locked travel (true gait), motion
55-
aliveness (velocity-continuous flow + weight shift).
56+
Standing poses only. Floor-contacting soles are orientation-locked and the
57+
visible mocap rig is re-planted after blending. Next: a larger curated clip
58+
library, explicit gait phase metadata, and motion matching/inertialization.
5659
7. **Two-person + collision**: partner stretches, assisted rehab, contact sports
5760
(still deferred in the spec).
5861

packages/posecode-eval/src/checks.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type { PhasePose, ProbeResult } from "./probe.js";
99
import {
1010
balanceOverflow,
11+
barGripError,
1112
distanceBetween,
1213
feetCenterSkateDistance,
1314
footIsSupported,
@@ -17,8 +18,10 @@ import {
1718
kneeFlexionDeg,
1819
lowestPoint,
1920
palmFloorAngleDeg,
21+
palmBarAngleDeg,
2022
phaseMaxLandmarkSpeed,
2123
segmentTiltDeg,
24+
soleUpAngleDeg,
2225
spineCurlDeg,
2326
torsoPitchDeg,
2427
} from "./metrics.js";
@@ -244,6 +247,26 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
244247
(v) => v > 0.9,
245248
"pelvis > 0.9m",
246249
),
250+
phaseCheck("left-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"),
251+
phaseCheck("right-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"),
252+
],
253+
},
254+
{
255+
movement: "pull-up",
256+
checks: [
257+
phaseCheck("left-grip-position", "Hang", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"),
258+
phaseCheck("right-grip-position", "Hang", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"),
259+
phaseCheck("left-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "left"), (v) => v < 5, "< 5°"),
260+
phaseCheck("right-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "right"), (v) => v < 5, "< 5°"),
261+
phaseCheck("left-grip-held", "Pull up", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"),
262+
phaseCheck("right-grip-held", "Pull up", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"),
263+
],
264+
},
265+
{
266+
movement: "walk-cycle",
267+
checks: [
268+
phaseCheck("left-stance-flat", "Step right", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"),
269+
phaseCheck("right-stance-flat", "Step left", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"),
247270
],
248271
},
249272
{

packages/posecode-eval/src/metrics.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,28 @@ export function palmFloorAngleDeg(pose: PhasePose, side: "left" | "right"): numb
9292
return angleBetweenDeg(rotateByQuat(side === "left" ? [1, 0, 0] : [-1, 0, 0], q), [0, -1, 0]);
9393
}
9494

95+
/** Angle between the sole's local up axis and world up (0 = foot flat). */
96+
export function soleUpAngleDeg(pose: PhasePose, side: "left" | "right"): number {
97+
const q = pose.boneQuaternions.get(`ankle_${side}`);
98+
if (!q) return 180;
99+
return angleBetweenDeg(rotateByQuat([0, 1, 0], q), [0, 1, 0]);
100+
}
101+
102+
/** Overhand bar grip: angle between the palm face normal and character-forward. */
103+
export function palmBarAngleDeg(pose: PhasePose, side: "left" | "right"): number {
104+
const q = pose.boneQuaternions.get(`wrist_${side}`);
105+
if (!q) return 180;
106+
const localNormal: Vec3 = side === "left" ? [1, 0, 0] : [-1, 0, 0];
107+
return angleBetweenDeg(rotateByQuat(localNormal, q), [0, 0, 1]);
108+
}
109+
110+
/** Distance from a wrist to its side-specific pull-up-bar grip anchor. */
111+
export function barGripError(pose: PhasePose, side: "left" | "right"): number {
112+
const wrist = bone(pose, `wrist_${side}`);
113+
const anchor: Vec3 = [side === "left" ? 0.24 : -0.24, 2.255, 0.025];
114+
return norm(sub(wrist, anchor));
115+
}
116+
95117
const MASS_WEIGHTS: ReadonlyArray<readonly [string, number]> = [
96118
["pelvis", 0.22], ["spine", 0.13], ["chest", 0.2], ["head", 0.08],
97119
["hip_left", 0.07], ["hip_right", 0.07], ["knee_left", 0.05], ["knee_right", 0.05],

packages/posecode-eval/src/probe.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import * as THREE from "three";
1616
import { parse, type Easing, type ParseError, type PinTarget, type ReachTarget, type Warning } from "posecode-parser";
1717
import {
1818
applyGroundLock,
19+
alignBarGrips,
1920
alignFloorPalms,
21+
alignFloorSoles,
2022
buildMannequin,
2123
buildProps,
2224
buildTimeline,
@@ -122,6 +124,7 @@ export function probeMovement(source: string): ProbeResult {
122124
v.z += info.rootOffset.z;
123125
anchors.set(id, v);
124126
}
127+
alignFloorSoles(m, info.groundLock, info.reaches, info.pins);
125128
applyGroundLock(m, info.groundLock, anchors);
126129
// Resolve scene-independent pins. Unknown names here are prop anchors and
127130
// intentionally remain for browser-level coverage.
@@ -144,9 +147,17 @@ export function probeMovement(source: string): ProbeResult {
144147
if (pin.anchor === "floor") {
145148
target = effector.getWorldPosition(new THREE.Vector3());
146149
target.y = 0;
147-
} else if (propScene.anchors.has(pin.anchor)) {
148-
target = propScene.anchors.get(pin.anchor)!.clone();
149150
} else {
151+
const side = effectorId.endsWith("_left")
152+
? "left"
153+
: effectorId.endsWith("_right")
154+
? "right"
155+
: null;
156+
const propTarget = (side ? propScene.anchors.get(`${pin.anchor}.${side}`) : undefined)
157+
?? propScene.anchors.get(pin.anchor);
158+
if (propTarget) target = propTarget.clone();
159+
}
160+
if (!target && pin.anchor !== "floor") {
150161
const landmark = m.bones.get(pin.anchor);
151162
if (landmark) target = landmark.getWorldPosition(new THREE.Vector3());
152163
}
@@ -160,6 +171,7 @@ export function probeMovement(source: string): ProbeResult {
160171
}
161172
}
162173
alignFloorPalms(m, info.reaches, info.pins);
174+
alignBarGrips(m, info.reaches, info.pins);
163175
// Viewer safety net: never leave the lowest mesh point below the floor.
164176
m.root.updateMatrixWorld(true);
165177
const box = new THREE.Box3().setFromObject(m.root);

packages/posecode-render/src/character.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export interface Character {
7171
proportions: Proportions;
7272
/** Copy the driver's current pose onto the character skeleton. */
7373
sync(driver: Mannequin): void;
74+
/** Restore solved terminal contacts after a mocap layer has overwritten them. */
75+
correctContacts(driver: Mannequin, boneIds: readonly string[]): void;
7476
/**
7577
* The character's first skinned mesh, the retarget target for mocap clips
7678
* (see clips.ts). Null on bare skeletons, which then can't play clips.
@@ -283,6 +285,7 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
283285
// ---- Capture rest state for the per-frame retarget. ----
284286
const mapped: MappedBone[] = [];
285287
const mappedByNode = new Map<THREE.Object3D, MappedBone>();
288+
const mappedById = new Map<string, MappedBone>();
286289
for (const [driverId] of Object.entries(BONE_MAP)) {
287290
const node = bone(driverId);
288291
const mb: MappedBone = {
@@ -293,6 +296,7 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
293296
};
294297
mapped.push(mb);
295298
mappedByNode.set(node, mb);
299+
mappedById.set(driverId, mb);
296300
}
297301
// Distal phalanges: capture rest locals + the curl axis expressed in each
298302
// phalanx's rest-local frame (the driver curls fingers as a single bone; the
@@ -379,6 +383,34 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
379383
group.updateMatrixWorld(true);
380384
}
381385

386+
function correctContacts(driver: Mannequin, boneIds: readonly string[]): void {
387+
const ids = [...new Set(boneIds)].filter((id) => mappedById.has(id) && driver.bones.has(id));
388+
if (ids.length === 0) return;
389+
390+
group.updateMatrixWorld(true);
391+
const delta = new THREE.Vector3();
392+
const driverPos = new THREE.Vector3();
393+
const charPos = new THREE.Vector3();
394+
for (const id of ids) {
395+
driver.bones.get(id)!.getWorldPosition(driverPos);
396+
mappedById.get(id)!.node.getWorldPosition(charPos);
397+
delta.add(driverPos).sub(charPos);
398+
}
399+
group.position.add(delta.multiplyScalar(1 / ids.length));
400+
group.updateMatrixWorld(true);
401+
402+
for (const id of ids) {
403+
const mb = mappedById.get(id)!;
404+
if (!mb.node.parent) continue;
405+
driver.bones.get(id)!.getWorldQuaternion(TMP_Q);
406+
const desiredWorld = TMP_Q2.copy(TMP_Q).multiply(mb.restWorld);
407+
mb.node.parent.getWorldQuaternion(TMP_Q);
408+
mb.node.quaternion.copy(TMP_Q.invert().multiply(desiredWorld));
409+
mb.node.updateMatrixWorld(true);
410+
}
411+
group.updateMatrixWorld(true);
412+
}
413+
382414
// Surface for the optional mocap-clip layer (clips.ts): the retarget target
383415
// mesh and the set of bones sync() rewrites each frame.
384416
let skinnedMesh: THREE.SkinnedMesh | null = null;
@@ -394,6 +426,7 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
394426
group,
395427
proportions,
396428
sync,
429+
correctContacts,
397430
skinnedMesh,
398431
drivenNodes,
399432
dispose() {

packages/posecode-render/src/clips.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* walk) on the skinned character instead of — or crossfaded with — the
44
* procedural DSL keyframes.
55
*
6-
* Pipeline: `loadClipSource` fetches an FBX/GLB and picks its longest
6+
* Pipeline: `loadClipSource` fetches an FBX/GLB and picks its strongest moving
77
* AnimationClip; `retargetMocapClip` bakes it onto the character's skeleton
88
* with SkeletonUtils.retargetClip (both rigs follow Mixamo naming, so bones
99
* pair up by suffix); `createClipLayer` plays the result through a
@@ -29,12 +29,51 @@ function plainName(name: string): string {
2929
export interface ClipSource {
3030
/** The loaded asset's scene root (holds the source skeleton). */
3131
root: THREE.Object3D;
32-
/** The longest animation found in the asset. */
32+
/** The most motion-rich animation found in the asset. */
3333
clip: THREE.AnimationClip;
3434
}
3535

3636
/**
37-
* Load a mocap asset (.fbx or .glb/.gltf) and pick its longest clip. Rejects
37+
* Prefer the take with real changing bone tracks over long bind-pose/default
38+
* takes commonly embedded beside a Mixamo animation in FBX exports.
39+
*/
40+
export function selectMotionClip(animations: readonly THREE.AnimationClip[]): THREE.AnimationClip | null {
41+
let best: THREE.AnimationClip | null = null;
42+
let bestScore = -Infinity;
43+
for (const clip of animations) {
44+
let movingTracks = 0;
45+
let motion = 0;
46+
for (const track of clip.tracks) {
47+
const frames = track.times.length;
48+
const stride = frames > 0 ? track.values.length / frames : 0;
49+
if (frames < 2 || stride < 1) continue;
50+
let trackMotion = 0;
51+
for (let frame = 1; frame < frames; frame++) {
52+
let deltaSq = 0;
53+
for (let component = 0; component < stride; component++) {
54+
const a = track.values[(frame - 1) * stride + component]!;
55+
const b = track.values[frame * stride + component]!;
56+
deltaSq += (b - a) * (b - a);
57+
}
58+
trackMotion += Math.sqrt(deltaSq);
59+
}
60+
trackMotion /= frames - 1;
61+
if (trackMotion > 1e-5) {
62+
movingTracks++;
63+
motion += Math.min(trackMotion, 10);
64+
}
65+
}
66+
const score = movingTracks * 100 + motion + Math.min(clip.duration, 10) * 0.001;
67+
if (score > bestScore) {
68+
best = clip;
69+
bestScore = score;
70+
}
71+
}
72+
return best;
73+
}
74+
75+
/**
76+
* Load a mocap asset (.fbx or .glb/.gltf) and pick its most motion-rich clip. Rejects
3877
* when the asset has no animations; callers treat any rejection as "keep the
3978
* procedural path".
4079
*/
@@ -51,7 +90,7 @@ export async function loadClipSource(url: string): Promise<ClipSource> {
5190
root = gltf.scene;
5291
animations = gltf.animations;
5392
}
54-
const clip = [...animations].sort((a, b) => b.duration - a.duration)[0];
93+
const clip = selectMotionClip(animations);
5594
if (!clip) throw new Error(`clip asset has no animations: ${url}`);
5695
return { root, clip };
5796
}

0 commit comments

Comments
 (0)