Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Expand Down
23 changes: 23 additions & 0 deletions packages/posecode-eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { PhasePose, ProbeResult } from "./probe.js";
import {
balanceOverflow,
barGripError,
distanceBetween,
feetCenterSkateDistance,
footIsSupported,
Expand All @@ -17,8 +18,10 @@ import {
kneeFlexionDeg,
lowestPoint,
palmFloorAngleDeg,
palmBarAngleDeg,
phaseMaxLandmarkSpeed,
segmentTiltDeg,
soleUpAngleDeg,
spineCurlDeg,
torsoPitchDeg,
} from "./metrics.js";
Expand Down Expand Up @@ -244,6 +247,26 @@ export const MOVEMENT_CHECKS: MovementChecks[] = [
(v) => v > 0.9,
"pelvis > 0.9m",
),
phaseCheck("left-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"),
phaseCheck("right-sole-flat", "Descend", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"),
],
},
{
movement: "pull-up",
checks: [
phaseCheck("left-grip-position", "Hang", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"),
phaseCheck("right-grip-position", "Hang", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"),
phaseCheck("left-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "left"), (v) => v < 5, "< 5°"),
phaseCheck("right-palm-wrap", "Hang", (p) => palmBarAngleDeg(p, "right"), (v) => v < 5, "< 5°"),
phaseCheck("left-grip-held", "Pull up", (p) => barGripError(p, "left"), (v) => v < 0.12, "< 0.12m"),
phaseCheck("right-grip-held", "Pull up", (p) => barGripError(p, "right"), (v) => v < 0.12, "< 0.12m"),
],
},
{
movement: "walk-cycle",
checks: [
phaseCheck("left-stance-flat", "Step right", (p) => soleUpAngleDeg(p, "left"), (v) => v < 2, "< 2°"),
phaseCheck("right-stance-flat", "Step left", (p) => soleUpAngleDeg(p, "right"), (v) => v < 2, "< 2°"),
],
},
{
Expand Down
22 changes: 22 additions & 0 deletions packages/posecode-eval/src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,28 @@ export function palmFloorAngleDeg(pose: PhasePose, side: "left" | "right"): numb
return angleBetweenDeg(rotateByQuat(side === "left" ? [1, 0, 0] : [-1, 0, 0], q), [0, -1, 0]);
}

/** Angle between the sole's local up axis and world up (0 = foot flat). */
export function soleUpAngleDeg(pose: PhasePose, side: "left" | "right"): number {
const q = pose.boneQuaternions.get(`ankle_${side}`);
if (!q) return 180;
return angleBetweenDeg(rotateByQuat([0, 1, 0], q), [0, 1, 0]);
}

/** Overhand bar grip: angle between the palm face normal and character-forward. */
export function palmBarAngleDeg(pose: PhasePose, side: "left" | "right"): number {
const q = pose.boneQuaternions.get(`wrist_${side}`);
if (!q) return 180;
const localNormal: Vec3 = side === "left" ? [1, 0, 0] : [-1, 0, 0];
return angleBetweenDeg(rotateByQuat(localNormal, q), [0, 0, 1]);
}

/** Distance from a wrist to its side-specific pull-up-bar grip anchor. */
export function barGripError(pose: PhasePose, side: "left" | "right"): number {
const wrist = bone(pose, `wrist_${side}`);
const anchor: Vec3 = [side === "left" ? 0.24 : -0.24, 2.255, 0.025];
return norm(sub(wrist, anchor));
}

const MASS_WEIGHTS: ReadonlyArray<readonly [string, number]> = [
["pelvis", 0.22], ["spine", 0.13], ["chest", 0.2], ["head", 0.08],
["hip_left", 0.07], ["hip_right", 0.07], ["knee_left", 0.05], ["knee_right", 0.05],
Expand Down
16 changes: 14 additions & 2 deletions packages/posecode-eval/src/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import * as THREE from "three";
import { parse, type Easing, type ParseError, type PinTarget, type ReachTarget, type Warning } from "posecode-parser";
import {
applyGroundLock,
alignBarGrips,
alignFloorPalms,
alignFloorSoles,
buildMannequin,
buildProps,
buildTimeline,
Expand Down Expand Up @@ -122,6 +124,7 @@ export function probeMovement(source: string): ProbeResult {
v.z += info.rootOffset.z;
anchors.set(id, v);
}
alignFloorSoles(m, info.groundLock, info.reaches, info.pins);
applyGroundLock(m, info.groundLock, anchors);
// Resolve scene-independent pins. Unknown names here are prop anchors and
// intentionally remain for browser-level coverage.
Expand All @@ -144,9 +147,17 @@ export function probeMovement(source: string): ProbeResult {
if (pin.anchor === "floor") {
target = effector.getWorldPosition(new THREE.Vector3());
target.y = 0;
} else if (propScene.anchors.has(pin.anchor)) {
target = propScene.anchors.get(pin.anchor)!.clone();
} else {
const side = effectorId.endsWith("_left")
? "left"
: effectorId.endsWith("_right")
? "right"
: null;
const propTarget = (side ? propScene.anchors.get(`${pin.anchor}.${side}`) : undefined)
?? propScene.anchors.get(pin.anchor);
if (propTarget) target = propTarget.clone();
}
if (!target && pin.anchor !== "floor") {
const landmark = m.bones.get(pin.anchor);
if (landmark) target = landmark.getWorldPosition(new THREE.Vector3());
}
Expand All @@ -160,6 +171,7 @@ export function probeMovement(source: string): ProbeResult {
}
}
alignFloorPalms(m, info.reaches, info.pins);
alignBarGrips(m, info.reaches, info.pins);
// Viewer safety net: never leave the lowest mesh point below the floor.
m.root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(m.root);
Expand Down
33 changes: 33 additions & 0 deletions packages/posecode-render/src/character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export interface Character {
proportions: Proportions;
/** Copy the driver's current pose onto the character skeleton. */
sync(driver: Mannequin): void;
/** Restore solved terminal contacts after a mocap layer has overwritten them. */
correctContacts(driver: Mannequin, boneIds: readonly string[]): void;
/**
* The character's first skinned mesh, the retarget target for mocap clips
* (see clips.ts). Null on bare skeletons, which then can't play clips.
Expand Down Expand Up @@ -283,6 +285,7 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
// ---- Capture rest state for the per-frame retarget. ----
const mapped: MappedBone[] = [];
const mappedByNode = new Map<THREE.Object3D, MappedBone>();
const mappedById = new Map<string, MappedBone>();
for (const [driverId] of Object.entries(BONE_MAP)) {
const node = bone(driverId);
const mb: MappedBone = {
Expand All @@ -293,6 +296,7 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
};
mapped.push(mb);
mappedByNode.set(node, mb);
mappedById.set(driverId, mb);
}
// Distal phalanges: capture rest locals + the curl axis expressed in each
// phalanx's rest-local frame (the driver curls fingers as a single bone; the
Expand Down Expand Up @@ -379,6 +383,34 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
group.updateMatrixWorld(true);
}

function correctContacts(driver: Mannequin, boneIds: readonly string[]): void {
const ids = [...new Set(boneIds)].filter((id) => mappedById.has(id) && driver.bones.has(id));
if (ids.length === 0) return;

group.updateMatrixWorld(true);
const delta = new THREE.Vector3();
const driverPos = new THREE.Vector3();
const charPos = new THREE.Vector3();
for (const id of ids) {
driver.bones.get(id)!.getWorldPosition(driverPos);
mappedById.get(id)!.node.getWorldPosition(charPos);
delta.add(driverPos).sub(charPos);
}
group.position.add(delta.multiplyScalar(1 / ids.length));
group.updateMatrixWorld(true);

for (const id of ids) {
const mb = mappedById.get(id)!;
if (!mb.node.parent) continue;
driver.bones.get(id)!.getWorldQuaternion(TMP_Q);
const desiredWorld = TMP_Q2.copy(TMP_Q).multiply(mb.restWorld);
mb.node.parent.getWorldQuaternion(TMP_Q);
mb.node.quaternion.copy(TMP_Q.invert().multiply(desiredWorld));
mb.node.updateMatrixWorld(true);
}
group.updateMatrixWorld(true);
}

// Surface for the optional mocap-clip layer (clips.ts): the retarget target
// mesh and the set of bones sync() rewrites each frame.
let skinnedMesh: THREE.SkinnedMesh | null = null;
Expand All @@ -394,6 +426,7 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
group,
proportions,
sync,
correctContacts,
skinnedMesh,
drivenNodes,
dispose() {
Expand Down
47 changes: 43 additions & 4 deletions packages/posecode-render/src/clips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* walk) on the skinned character instead of — or crossfaded with — the
* procedural DSL keyframes.
*
* Pipeline: `loadClipSource` fetches an FBX/GLB and picks its longest
* Pipeline: `loadClipSource` fetches an FBX/GLB and picks its strongest moving
* AnimationClip; `retargetMocapClip` bakes it onto the character's skeleton
* with SkeletonUtils.retargetClip (both rigs follow Mixamo naming, so bones
* pair up by suffix); `createClipLayer` plays the result through a
Expand All @@ -29,12 +29,51 @@ function plainName(name: string): string {
export interface ClipSource {
/** The loaded asset's scene root (holds the source skeleton). */
root: THREE.Object3D;
/** The longest animation found in the asset. */
/** The most motion-rich animation found in the asset. */
clip: THREE.AnimationClip;
}

/**
* Load a mocap asset (.fbx or .glb/.gltf) and pick its longest clip. Rejects
* Prefer the take with real changing bone tracks over long bind-pose/default
* takes commonly embedded beside a Mixamo animation in FBX exports.
*/
export function selectMotionClip(animations: readonly THREE.AnimationClip[]): THREE.AnimationClip | null {
let best: THREE.AnimationClip | null = null;
let bestScore = -Infinity;
for (const clip of animations) {
let movingTracks = 0;
let motion = 0;
for (const track of clip.tracks) {
const frames = track.times.length;
const stride = frames > 0 ? track.values.length / frames : 0;
if (frames < 2 || stride < 1) continue;
let trackMotion = 0;
for (let frame = 1; frame < frames; frame++) {
let deltaSq = 0;
for (let component = 0; component < stride; component++) {
const a = track.values[(frame - 1) * stride + component]!;
const b = track.values[frame * stride + component]!;
deltaSq += (b - a) * (b - a);
}
trackMotion += Math.sqrt(deltaSq);
}
trackMotion /= frames - 1;
if (trackMotion > 1e-5) {
movingTracks++;
motion += Math.min(trackMotion, 10);
}
}
const score = movingTracks * 100 + motion + Math.min(clip.duration, 10) * 0.001;
if (score > bestScore) {
best = clip;
bestScore = score;
}
}
return best;
}

/**
* Load a mocap asset (.fbx or .glb/.gltf) and pick its most motion-rich clip. Rejects
* when the asset has no animations; callers treat any rejection as "keep the
* procedural path".
*/
Expand All @@ -51,7 +90,7 @@ export async function loadClipSource(url: string): Promise<ClipSource> {
root = gltf.scene;
animations = gltf.animations;
}
const clip = [...animations].sort((a, b) => b.duration - a.duration)[0];
const clip = selectMotionClip(animations);
if (!clip) throw new Error(`clip asset has no animations: ${url}`);
return { root, clip };
}
Expand Down
Loading
Loading