Skip to content

Commit 052aa9f

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/animation-overshoot-and-floor-contacts
# Conflicts: # packages/posecode-eval/src/probe.ts
2 parents e09491f + 67491e8 commit 052aa9f

24 files changed

Lines changed: 1223 additions & 105 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **Realistic human figure**: the playground, landing hero, and `<posecode-player>` embeds now render a fully rigged, textured human character (hands with articulated fingers, sneakers, face) instead of the procedural capsule mannequin. All solving (FK, ground-lock, pins, reach-IK) still runs on the driver skeleton, rebuilt to the character's exact proportions and retargeted bone-for-bone every frame; the procedural figure remains as an automatic fallback (and via `?figure=classic` / `character="off"`).
1313
- **Self-collision resolution**: a capsule-based de-penetration pass keeps forearms/hands out of the torso, head, and legs (and shins out of each other), clamped to healthy ROM, so limbs no longer pass through the body mid-movement.
14+
- **Solid props**: props now declare blocking faces (the wall's surface, the chair's backrest and seat edge, the box's near face) and a contact pass keeps the body out of them — translating the whole figure along the face normal, or bending the offending leg's hip clear (ROM-clamped). Limbs pinned/gripped/reached to a prop anchor stay exempt as declared support. A new `solid-props` eval invariant (independent geometry re-derivation) guards every prop movement against this bug class.
1415
- `viewer.characterActive`, `createViewer({ characterUrl })`, and the embed `character` attribute.
1516
- `scripts/capture-gifs.mjs` (`npm run gifs`): reproducible headless regeneration of the README movement GIFs from the live renderer.
1617

1718
### Fixed
1819

20+
- Wall sit no longer clips through the wall: the body now translates forward until the back rests on the wall's surface (feet walking out, thighs parallel), the physically correct wall-sit geometry. Sit-to-stand and box-squat land against the chair's backrest instead of sinking into it, a standing figure's calves clear the seat edge, and a step-up's trailing shin bends over the box edge instead of sweeping through it.
1921
- Deadlift arms now hang toward the bar during the hinge (were authored as shoulder extension, flying up behind the back).
2022
- Crunch keeps the feet planted with bent knees (shins previously folded through the floor and jacked the body up).
2123
- Touch-toes folds like a human (hinge depth and knee bend were over-authored, collapsing the figure).

ROADMAP.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ These are the unlocks, roughly in order of leverage:
4141
Powers `sit-to-stand`, `box-squat`, `wall-sit`, `dead-hang`, `hanging-knee-raise`.
4242
Bar and dip-bar contacts now resolve to independent left/right anchors with
4343
terminal wrist orientation; mocap is contact-corrected after blending.
44+
Props are now **solid**: declared blocking faces (wall surface, chair
45+
backrest + seat edge, box edge) physically stop the body — a wall-sit
46+
slides down the wall instead of through it, a sit lands against the
47+
backrest, a swing leg steps over the box edge — guarded by a `solid-props`
48+
eval invariant on every prop movement.
4449
Next: more props (bench, rings, bands), load cues, arbitrary surface shapes.
4550
4. ~~**Lying & seated base poses**~~: **shipped.** `supine | prone | seated`
4651
start poses (grounded by a bounding-box drop). Powers `glute-bridge`,
@@ -84,7 +89,9 @@ Each prop is a small scene object + an anchor type; movements then reference it
8489
- A **starter** prop set (chair / wall / bar / box / dip bars): no bench,
8590
rings, bands, or loaded implements yet, and props sit at fixed default
8691
placements.
87-
- Props are visual + reach anchors (no physical sit/lean solve).
92+
- Prop solidity is face-based: each built-in prop declares its blocking
93+
surfaces (wall face, backrest, seat edge, box edge). Arbitrary-shape
94+
collision and load/pressure simulation are future.
8895
- Fingers are **single-DOF** curls, good for grip and rough gesture, not exact
8996
sign language. The head has no facial articulation.
9097

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"coverage": "vitest run --coverage",
1616
"dev": "npm run dev -w playground",
1717
"build": "npm run build -w playground",
18+
"prepare:xbot": "node scripts/prepare-xbot.mjs",
1819
"eval": "tsx packages/posecode-eval/src/cli.ts",
1920
"typecheck": "for p in packages/*/tsconfig.json playground/tsconfig.json editors/*/tsconfig.json; do tsc --noEmit -p \"$p\" || exit 1; done",
2021
"gifs": "node scripts/capture-gifs.mjs"

packages/posecode-eval/src/checks.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
lowestPoint,
1919
palmFloorAngleDeg,
2020
phaseMaxLandmarkSpeed,
21+
propPenetrationDepth,
2122
segmentTiltDeg,
2223
spineCurlDeg,
2324
torsoPitchDeg,
@@ -94,7 +95,7 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] {
9495
// must actually rest on the floor — not hover above it. Guards the
9596
// levitating-squat/deadlift regression where levelPlantedFeet lifted the
9697
// sole after ground-lock and an up-only clamp left the whole figure floating.
97-
if (p.groundLock.length > 0) {
98+
if (p.floorBound) {
9899
out.push({
99100
id: `grounded-not-floating:${p.name}`,
100101
pass: p.meshMinY < 0.02,
@@ -137,6 +138,19 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] {
137138
detail: `${clearance.toFixed(3)}m clearance (want > -0.01m)`,
138139
});
139140
}
141+
142+
// Props are solid: no body capsule may sit inside a prop's blocking face
143+
// (the wall-sit-through-the-wall class of bug). Independent re-derivation
144+
// of the face geometry, so it fails loudly if resolvePropContacts or a
145+
// prop's collider declaration regresses.
146+
const penetration = propPenetrationDepth(result, p);
147+
if (Number.isFinite(penetration)) {
148+
out.push({
149+
id: `solid-props:${p.name}`,
150+
pass: penetration < 0.03,
151+
detail: `${penetration.toFixed(3)}m into a solid prop face (want < 0.030)`,
152+
});
153+
}
140154
}
141155

142156
for (let i = 1; i < result.phases.length; i++) {

packages/posecode-eval/src/metrics.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,90 @@ export function headPropClearance(result: ProbeResult, pose: PhasePose): number
196196
return clearance;
197197
}
198198

199+
interface SolidFace {
200+
point: Vec3;
201+
normal: Vec3;
202+
tangentU: Vec3;
203+
halfU: number;
204+
tangentV: Vec3;
205+
halfV: number;
206+
captureDepth: number;
207+
blocks: readonly string[];
208+
}
209+
210+
/** The solid prop faces, re-derived from the prop geometry independently of
211+
* the renderer's collider declarations so a regression in either is caught. */
212+
function solidFaces(propTypes: readonly string[]): SolidFace[] {
213+
const out: SolidFace[] = [];
214+
const all = ["torso", "head", "thigh", "shin", "arm"];
215+
if (propTypes.includes("wall")) {
216+
out.push({ point: [0, 1.3, -0.29], normal: [0, 0, 1], tangentU: [1, 0, 0], halfU: 1.1, tangentV: [0, 1, 0], halfV: 1.3, captureDepth: 0.8, blocks: all });
217+
}
218+
if (propTypes.includes("chair")) {
219+
out.push(
220+
{ point: [0, 0.78, -0.31], normal: [0, 0, 1], tangentU: [1, 0, 0], halfU: 0.21, tangentV: [0, 1, 0], halfV: 0.25, captureDepth: 0.4, blocks: ["torso", "head"] },
221+
{ point: [0, 0.47, 0.05], normal: [0, 0, 1], tangentU: [1, 0, 0], halfU: 0.21, tangentV: [0, 1, 0], halfV: 0.03, captureDepth: 0.42, blocks: ["shin"] },
222+
);
223+
}
224+
if (propTypes.includes("box")) {
225+
out.push({ point: [0, 0.15, 0.11], normal: [0, 0, -1], tangentU: [1, 0, 0], halfU: 0.25, tangentV: [0, 1, 0], halfV: 0.15, captureDepth: 0.42, blocks: ["shin"] });
226+
}
227+
return out;
228+
}
229+
230+
/** Body capsule radii matching the render mannequin (see mannequin.ts). */
231+
const PART_RADII = { torso: 0.13, head: 0.105, thigh: 0.075, shin: 0.055, arm: 0.038 } as const;
232+
233+
/**
234+
* Worst body penetration into a solid prop face (metres, ≤0 when clear), or
235+
* -Infinity when the document declares no solid-faced prop. Limbs pinned or
236+
* reached to a non-floor anchor are that phase's declared prop support and
237+
* don't count (a foot standing ON the box is not "in" the box).
238+
*/
239+
export function propPenetrationDepth(result: ProbeResult, pose: PhasePose): number {
240+
const faces = solidFaces(result.propTypes);
241+
if (faces.length === 0) return -Infinity;
242+
const exemptLegs = new Set<string>();
243+
const contacts = [
244+
...pose.pins,
245+
...pose.reaches.map((r) => ({ effector: r.effector, anchor: r.target })),
246+
];
247+
for (const c of contacts) {
248+
if (c.anchor === "floor") continue;
249+
if (c.effector === "feet" || c.effector === "foot_left") exemptLegs.add("left");
250+
if (c.effector === "feet" || c.effector === "foot_right") exemptLegs.add("right");
251+
}
252+
const segments: [string, string, keyof typeof PART_RADII][] = [
253+
["pelvis", "neck", "torso"],
254+
["neck", "head", "head"],
255+
];
256+
for (const side of ["left", "right"]) {
257+
segments.push([`shoulder_${side}`, `elbow_${side}`, "arm"], [`elbow_${side}`, `wrist_${side}`, "arm"]);
258+
if (exemptLegs.has(side)) continue;
259+
segments.push([`hip_${side}`, `knee_${side}`, "thigh"], [`knee_${side}`, `ankle_${side}`, "shin"]);
260+
}
261+
let worst = -Infinity;
262+
for (const [aId, bId, part] of segments) {
263+
const a = pose.bones.get(aId);
264+
const b = pose.bones.get(bId);
265+
if (!a || !b) continue;
266+
const r = PART_RADII[part];
267+
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
268+
const p: Vec3 = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
269+
for (const f of faces) {
270+
if (!f.blocks.includes(part)) continue;
271+
const rel = sub(p, f.point);
272+
const d = dot(rel, f.normal);
273+
if (d < -f.captureDepth) continue;
274+
if (Math.abs(dot(rel, f.tangentU)) > f.halfU + r) continue;
275+
if (Math.abs(dot(rel, f.tangentV)) > f.halfV + r) continue;
276+
worst = Math.max(worst, r - d);
277+
}
278+
}
279+
}
280+
return worst;
281+
}
282+
199283
/** Fastest landmark's average speed from the previous endpoint into this phase. */
200284
export function phaseMaxLandmarkSpeed(previous: PhasePose | null, pose: PhasePose): number {
201285
if (!previous || pose.durationSec <= 0) return 0;
@@ -209,8 +293,11 @@ export function phaseMaxLandmarkSpeed(previous: PhasePose | null, pose: PhasePos
209293

210294
export function footSkateDistance(previous: PhasePose, pose: PhasePose, side: "left" | "right"): number {
211295
const id = `ankle_${side}`;
296+
// Authored travel AND the solid-prop contact push both translate the whole
297+
// body deliberately, feet included; skate is what's left after removing them.
212298
const local = (p: Vec3, phase: PhasePose): readonly [number, number] => {
213-
const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2];
299+
const x = p[0] - phase.rootOffset[0] - phase.propPush[0];
300+
const z = p[2] - phase.rootOffset[2] - phase.propPush[2];
214301
const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw);
215302
return [x * c - z * s, x * s + z * c];
216303
};
@@ -224,7 +311,8 @@ export function feetCenterSkateDistance(previous: PhasePose, pose: PhasePose): n
224311
const id = `ankle_${side}`;
225312
const a = bone(previous, id), b = bone(pose, id);
226313
const unyaw = (p: Vec3, phase: PhasePose) => {
227-
const x = p[0] - phase.rootOffset[0], z = p[2] - phase.rootOffset[2];
314+
const x = p[0] - phase.rootOffset[0] - phase.propPush[0];
315+
const z = p[2] - phase.rootOffset[2] - phase.propPush[2];
228316
const c = Math.cos(-phase.rootYaw), s = Math.sin(-phase.rootYaw);
229317
return [x * c - z * s, x * s + z * c] as const;
230318
};

packages/posecode-eval/src/probe.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
depenetrate,
2424
groundFigure,
2525
levelPlantedFeet,
26+
propContactExemptions,
27+
resolvePropContacts,
2628
} from "posecode-render";
2729

2830
export type Vec3 = readonly [x: number, y: number, z: number];
@@ -39,8 +41,18 @@ export interface PhasePose {
3941
reaches: readonly ReachTarget[];
4042
rootOffset: Vec3;
4143
rootYaw: number;
44+
/**
45+
* Horizontal body translation applied by the solid-prop contact solve
46+
* (resolvePropContacts): the feet legitimately glide by this much while the
47+
* body is pressed out of a prop (a wall-sit walks the feet forward as the
48+
* back slides down the wall), so skate metrics compensate for it like they
49+
* do for authored travel.
50+
*/
51+
propPush: Vec3;
4252
/** True when the phase relies on pins/reach-IK the probe cannot solve. */
4353
usesSceneIk: boolean;
54+
/** Whether the phase should rest on the floor (no elevated prop/grip support). */
55+
floorBound: boolean;
4456
/**
4557
* Height of the lowest visible-mesh point above the floor after the full
4658
* contact solve. ~0 for a grounded pose; a positive value means the figure
@@ -87,6 +99,11 @@ export function probeMovement(source: string): ProbeResult {
8799
m.root.updateMatrixWorld(true);
88100
depenetrate(m);
89101
groundFigure(m);
102+
resolvePropContacts(m, propScene.colliders, propContactExemptions([
103+
...(ir.phases[0]?.pins ?? []),
104+
...(ir.phases[0]?.grips ?? []),
105+
...(ir.phases[0]?.reaches ?? []).map((r) => ({ effector: r.effector, anchor: r.target })),
106+
]));
90107
const baseRootPos = m.root.position.clone();
91108
const baseRootQuat = m.root.quaternion.clone();
92109

@@ -100,6 +117,67 @@ export function probeMovement(source: string): ProbeResult {
100117
}
101118
}
102119

120+
// Precompute world positions of all effectors at start of each segment
121+
const segmentStartEffectors: Map<string, THREE.Vector3>[] = [];
122+
const tempYawQ = new THREE.Quaternion();
123+
124+
const getEffectorId = (eff: string) => {
125+
if (eff === "hand_left") return "wrist_left";
126+
if (eff === "hand_right") return "wrist_right";
127+
if (eff === "foot_left") return "ankle_left";
128+
if (eff === "foot_right") return "ankle_right";
129+
return eff;
130+
};
131+
132+
let prevEffectorsMap: Map<string, THREE.Vector3> | null = null;
133+
let prevPins: typeof ir.phases[number]["pins"] = [];
134+
135+
for (let i = 0; i < tl.segments.length; i++) {
136+
const seg = tl.segments[i]!;
137+
for (const bone of m.bones.values()) bone.quaternion.identity();
138+
const info = tl.sample(seg.start, m.bones);
139+
140+
const wasPinned = (id: string) => prevPins.some(p => getEffectorId(p.effector) === id && p.anchor === "floor");
141+
const isPinned = (id: string) => info.pins.some(p => getEffectorId(p.effector) === id && p.anchor === "floor");
142+
143+
m.root.position.copy(baseRootPos);
144+
m.root.quaternion.copy(baseRootQuat);
145+
if (info.rootYaw !== 0) {
146+
tempYawQ.setFromAxisAngle(WORLD_Y, info.rootYaw);
147+
m.root.quaternion.premultiply(tempYawQ);
148+
}
149+
m.root.position.x += info.rootOffset.x;
150+
m.root.position.z += info.rootOffset.z;
151+
m.root.updateMatrixWorld(true);
152+
depenetrate(m);
153+
154+
const effectorsMap = new Map<string, THREE.Vector3>();
155+
for (const ids of Object.values(m.effectors)) {
156+
for (const id of ids) {
157+
const node = m.bones.get(id);
158+
if (node) {
159+
if (i > 0 && wasPinned(id) && isPinned(id) && prevEffectorsMap && prevEffectorsMap.has(id)) {
160+
effectorsMap.set(id, prevEffectorsMap.get(id)!);
161+
} else {
162+
effectorsMap.set(id, node.getWorldPosition(new THREE.Vector3()));
163+
}
164+
}
165+
}
166+
}
167+
segmentStartEffectors.push(effectorsMap);
168+
prevEffectorsMap = effectorsMap;
169+
prevPins = info.pins;
170+
}
171+
172+
// Restore initial state
173+
for (const bone of m.bones.values()) bone.quaternion.identity();
174+
tl.sample(0, m.bones);
175+
m.root.position.copy(baseRootPos);
176+
m.root.quaternion.copy(baseRootQuat);
177+
m.root.updateMatrixWorld(true);
178+
depenetrate(m);
179+
groundFigure(m);
180+
103181
// Sample the end of each phase, applying the viewer's per-frame root
104182
// pipeline: base root → yaw/travel → ground-lock → floor safety clamp.
105183
const yawQ = new THREE.Quaternion();
@@ -149,8 +227,14 @@ export function probeMovement(source: string): ProbeResult {
149227
if (!effector) continue;
150228
let target: THREE.Vector3 | null = null;
151229
if (pin.anchor === "floor") {
152-
target = effector.getWorldPosition(new THREE.Vector3());
153-
target.y = 0;
230+
const startPos = segmentStartEffectors[phaseIndex]?.get(effectorId);
231+
if (startPos) {
232+
target = startPos.clone();
233+
target.y = 0;
234+
} else {
235+
target = effector.getWorldPosition(new THREE.Vector3());
236+
target.y = 0;
237+
}
154238
} else if (propScene.anchors.has(pin.anchor)) {
155239
target = propScene.anchors.get(pin.anchor)!.clone();
156240
} else {
@@ -166,6 +250,16 @@ export function probeMovement(source: string): ProbeResult {
166250
m.root.updateMatrixWorld(true);
167251
}
168252
}
253+
// Props are solid (viewer parity): after the root solvers place the body,
254+
// push it back out of any prop face it crossed and bend swing legs clear.
255+
// Limbs pinned/gripped to a prop anchor are declared support, exempt.
256+
const prePush = m.root.position.clone();
257+
resolvePropContacts(m, propScene.colliders, propContactExemptions([
258+
...info.pins,
259+
...info.grips,
260+
...info.reaches.map((r) => ({ effector: r.effector, anchor: r.target })),
261+
]));
262+
const propPush: Vec3 = [m.root.position.x - prePush.x, 0, m.root.position.z - prePush.z];
169263
alignFloorPalms(m, info.reaches, info.pins, info.groundLock);
170264
// Plantigrade correction (viewer parity): flatten planted soles. This lifts
171265
// the foot mesh a little, so it must run BEFORE the floor clamp reconciles.
@@ -175,8 +269,8 @@ export function probeMovement(source: string): ProbeResult {
175269
// airborne, so only rescue parts that dip below y=0. Mirror index.ts.
176270
m.root.updateMatrixWorld(true);
177271
const box = new THREE.Box3().setFromObject(m.root);
178-
const planted = info.groundLock.length > 0;
179-
if (box.min.y < 0 || (planted && box.min.y > 0)) {
272+
const floorBound = info.grips.length === 0 && !info.pins.some((pin) => pin.anchor !== "floor");
273+
if (box.min.y < 0 || (floorBound && box.min.y > 0)) {
180274
m.root.position.y -= box.min.y;
181275
m.root.updateMatrixWorld(true);
182276
}
@@ -190,7 +284,9 @@ export function probeMovement(source: string): ProbeResult {
190284
reaches: [...info.reaches],
191285
rootOffset: [info.rootOffset.x, 0, info.rootOffset.z],
192286
rootYaw: info.rootYaw,
287+
propPush,
193288
usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0,
289+
floorBound,
194290
meshMinY: Number.isFinite(finalBox.min.y) ? finalBox.min.y : 0,
195291
bones: snapshotBones(m.bones),
196292
boneQuaternions: snapshotBoneQuaternions(m.bones),

0 commit comments

Comments
 (0)