Skip to content

Commit 9b784d2

Browse files
committed
Ground Xbot across every movement
1 parent a75674f commit 9b784d2

6 files changed

Lines changed: 256 additions & 14 deletions

File tree

packages/posecode-eval/src/checks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] {
9494
// must actually rest on the floor — not hover above it. Guards the
9595
// levitating-squat/deadlift regression where levelPlantedFeet lifted the
9696
// sole after ground-lock and an up-only clamp left the whole figure floating.
97-
if (p.groundLock.length > 0) {
97+
if (p.floorBound) {
9898
out.push({
9999
id: `grounded-not-floating:${p.name}`,
100100
pass: p.meshMinY < 0.02,

packages/posecode-eval/src/probe.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface PhasePose {
4141
rootYaw: number;
4242
/** True when the phase relies on pins/reach-IK the probe cannot solve. */
4343
usesSceneIk: boolean;
44+
/** Whether the phase should rest on the floor (no elevated prop/grip support). */
45+
floorBound: boolean;
4446
/**
4547
* Height of the lowest visible-mesh point above the floor after the full
4648
* contact solve. ~0 for a grounded pose; a positive value means the figure
@@ -175,8 +177,8 @@ export function probeMovement(source: string): ProbeResult {
175177
// airborne, so only rescue parts that dip below y=0. Mirror index.ts.
176178
m.root.updateMatrixWorld(true);
177179
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)) {
180+
const floorBound = info.grips.length === 0 && !info.pins.some((pin) => pin.anchor !== "floor");
181+
if (box.min.y < 0 || (floorBound && box.min.y > 0)) {
180182
m.root.position.y -= box.min.y;
181183
m.root.updateMatrixWorld(true);
182184
}
@@ -191,6 +193,7 @@ export function probeMovement(source: string): ProbeResult {
191193
rootOffset: [info.rootOffset.x, 0, info.rootOffset.z],
192194
rootYaw: info.rootYaw,
193195
usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0,
196+
floorBound,
194197
meshMinY: Number.isFinite(finalBox.min.y) ? finalBox.min.y : 0,
195198
bones: snapshotBones(m.bones),
196199
boneQuaternions: snapshotBoneQuaternions(m.bones),

packages/posecode-render/src/character.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export interface Character {
7373
sync(driver: Mannequin): void;
7474
/** Restore solved terminal contacts after a mocap layer has overwritten them. */
7575
correctContacts(driver: Mannequin, boneIds: readonly string[]): void;
76+
/** Precise CPU-skinned world bounds for diagnostics/export validation. */
77+
getBounds(): THREE.Box3;
78+
/** Fast sampled visible-surface correction; returns the applied Y delta. */
79+
reconcileFloor(floorY?: number): number;
7680
/**
7781
* The character's first skinned mesh, the retarget target for mocap clips
7882
* (see clips.ts). Null on bare skeletons, which then can't play clips.
@@ -414,19 +418,86 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
414418
// Surface for the optional mocap-clip layer (clips.ts): the retarget target
415419
// mesh and the set of bones sync() rewrites each frame.
416420
let skinnedMesh: THREE.SkinnedMesh | null = null;
421+
const skinnedMeshes: THREE.SkinnedMesh[] = [];
417422
charScene.traverse((o) => {
418-
if (!skinnedMesh && (o as THREE.SkinnedMesh).isSkinnedMesh) {
419-
skinnedMesh = o as THREE.SkinnedMesh;
423+
if ((o as THREE.SkinnedMesh).isSkinnedMesh) {
424+
const skin = o as THREE.SkinnedMesh;
425+
skinnedMeshes.push(skin);
426+
if (!skinnedMesh) skinnedMesh = skin;
420427
}
421428
});
422429
const drivenNodes = new Set<THREE.Object3D>(mapped.map((m) => m.node));
423430
for (const ph of phalanges) drivenNodes.add(ph.node);
424431

432+
function getBounds(): THREE.Box3 {
433+
group.updateMatrixWorld(true);
434+
const box = new THREE.Box3().makeEmpty();
435+
const vertex = new THREE.Vector3();
436+
let foundSkin = false;
437+
for (const mesh of skinnedMeshes) {
438+
foundSkin = true;
439+
mesh.skeleton.update();
440+
const positions = mesh.geometry.getAttribute("position");
441+
for (let i = 0; i < positions.count; i++) {
442+
vertex.fromBufferAttribute(positions, i);
443+
mesh.applyBoneTransform(i, vertex).applyMatrix4(mesh.matrixWorld);
444+
box.expandByPoint(vertex);
445+
}
446+
}
447+
return foundSkin ? box : new THREE.Box3().setFromObject(group);
448+
}
449+
450+
// A dense uniform sample plus every rest-pose axis extremum. Xbot's 28k
451+
// vertices reduce to ~3.6k skin transforms per frame while retaining sole,
452+
// back, head, hand, and limb surface coverage under arbitrary articulation.
453+
const floorSamples = skinnedMeshes.map((mesh) => {
454+
const positions = mesh.geometry.getAttribute("position");
455+
const indices = new Set<number>();
456+
for (let i = 0; i < positions.count; i += 8) indices.add(i);
457+
indices.add(positions.count - 1);
458+
for (const axis of ["x", "y", "z"] as const) {
459+
let min = Infinity;
460+
let max = -Infinity;
461+
let minIndex = 0;
462+
let maxIndex = 0;
463+
for (let i = 0; i < positions.count; i++) {
464+
const value = axis === "x" ? positions.getX(i) : axis === "y" ? positions.getY(i) : positions.getZ(i);
465+
if (value < min) { min = value; minIndex = i; }
466+
if (value > max) { max = value; maxIndex = i; }
467+
}
468+
indices.add(minIndex);
469+
indices.add(maxIndex);
470+
}
471+
return { mesh, indices: [...indices] };
472+
});
473+
474+
function reconcileFloor(floorY = 0): number {
475+
group.updateMatrixWorld(true);
476+
const vertex = new THREE.Vector3();
477+
let minY = Infinity;
478+
for (const { mesh, indices } of floorSamples) {
479+
mesh.skeleton.update();
480+
const positions = mesh.geometry.getAttribute("position");
481+
for (const index of indices) {
482+
vertex.fromBufferAttribute(positions, index);
483+
mesh.applyBoneTransform(index, vertex).applyMatrix4(mesh.matrixWorld);
484+
minY = Math.min(minY, vertex.y);
485+
}
486+
}
487+
if (!Number.isFinite(minY)) return 0;
488+
const delta = floorY - minY;
489+
group.position.y += delta;
490+
group.updateMatrixWorld(true);
491+
return delta;
492+
}
493+
425494
return {
426495
group,
427496
proportions,
428497
sync,
429498
correctContacts,
499+
getBounds,
500+
reconcileFloor,
430501
skinnedMesh,
431502
drivenNodes,
432503
dispose() {

packages/posecode-render/src/index.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export interface Viewer {
5858
/** True while a retargeted mocap clip is driving (or fading over) the pose. */
5959
get clipActive(): boolean;
6060
getTimeline(): TimelineInfo | null;
61+
/** Precise visible world bounds; intended for audits and deterministic export. */
62+
getVisibleBounds(): THREE.Box3;
6163
/**
6264
* Render the current time synchronously and return the frame as a PNG data
6365
* URL. Works without preserveDrawingBuffer because the read happens in the
@@ -223,6 +225,10 @@ export function createViewer(
223225
const url = name ? opts.clips?.[name] : undefined;
224226
if (!name || !url || !character?.skinnedMesh) {
225227
clipTargetWeight = 0;
228+
clipWeight = 0;
229+
clipLayer?.dispose();
230+
clipLayer = null;
231+
clipLayerName = null;
226232
return;
227233
}
228234
if (clipLayerName === name && clipLayer) {
@@ -607,8 +613,10 @@ export function createViewer(
607613
}
608614

609615
function frame(): void {
616+
let solvedInfo: ReturnType<NonNullable<typeof timeline>["sample"]> | null = null;
610617
if (timeline) {
611618
const info = timeline.sample(time, mannequin.bones);
619+
solvedInfo = info;
612620
// Life layer rides on wall-clock time (not timeline time) so the figure
613621
// keeps breathing and blinking while paused or scrubbing.
614622
applyLife(performance.now() / 1000);
@@ -667,15 +675,18 @@ export function createViewer(
667675
// never recover it and the whole figure floated (squat, deadlift,
668676
// good-morning, forward-fold, plank, …).
669677
//
670-
// A phase with NO ground-lock may be intentionally airborne (a prone
671-
// "superman" lift, a jump), so it stays up-only: never yank a lifted body
672-
// down, only rescue parts that dip below y=0. Pinned phases with a
673-
// fixed-height anchor (a low chair seat) also rely on this up-only rescue
674-
// as the legs fold.
678+
// Explicit elevated support is the opt-out: bar grips and non-floor pins
679+
// (box/chair) preserve their solved height. Everything else remains
680+
// floor-bound; airborne choreography should use a future explicit flight
681+
// contact rather than arise accidentally from missing `ground-lock`.
675682
mannequin.root.updateMatrixWorld(true);
676683
const box = new THREE.Box3().setFromObject(mannequin.root);
677-
const planted = info.groundLock.length > 0;
678-
if (box.min.y < 0 || (planted && box.min.y > 0)) {
684+
// Unless an elevated prop/grip is carrying the body, the movement is
685+
// floor-bound even when the author omitted `ground-lock`. This prevents
686+
// ordinary curls, lunges, stretches, and transitions from inheriting a
687+
// floating root when their FK pose raises the previous lowest point.
688+
const floorBound = isFloorBound(info);
689+
if (box.min.y < 0 || (floorBound && box.min.y > 0)) {
679690
mannequin.root.position.y -= box.min.y;
680691
mannequin.root.updateMatrixWorld(true);
681692
}
@@ -694,8 +705,18 @@ export function createViewer(
694705
const gap = clipTargetWeight - clipWeight;
695706
clipWeight += Math.sign(gap) * Math.min(Math.abs(gap), step);
696707
clipLayer.apply(time, clipWeight);
697-
if (clipWeight > 0) character.group.updateMatrixWorld(true);
708+
if (clipWeight > 0) {
709+
character.group.updateMatrixWorld(true);
710+
// Mocap is layered after procedural grounding and can add hip/root bob
711+
// that lifts a planted foot. Restore declared terminal contacts on the
712+
// visible character without removing motion from unconstrained limbs.
713+
if (solvedInfo) character.correctContacts(mannequin, contactBoneIds(solvedInfo));
714+
}
698715
}
716+
// Final visible-surface grounding. The hidden driver uses calibrated proxy
717+
// geometry; a segmented skin can have a different lowest point as limbs
718+
// rotate. Reconcile the actual skinned surface after every animation layer.
719+
if (character && solvedInfo && isFloorBound(solvedInfo)) character.reconcileFloor();
699720
frameDt = 0;
700721
if (easeCamera) {
701722
controls.target.lerp(desiredTarget, 0.07);
@@ -824,6 +845,9 @@ export function createViewer(
824845
segments: timeline.segments,
825846
};
826847
},
848+
getVisibleBounds() {
849+
return character?.getBounds() ?? new THREE.Box3().setFromObject(mannequin.root);
850+
},
827851
captureFrame() {
828852
frame();
829853
return renderer.domElement.toDataURL("image/png");
@@ -882,6 +906,40 @@ export function createViewer(
882906
return api;
883907
}
884908

909+
/** True unless a grip or non-floor pin intentionally suspends/supports the body. */
910+
function isFloorBound(info: {
911+
grips: readonly unknown[];
912+
pins: readonly { anchor: string }[];
913+
}): boolean {
914+
return info.grips.length === 0 && !info.pins.some((pin) => pin.anchor !== "floor");
915+
}
916+
917+
/** Driver terminal bones that must survive a mocap layer unchanged. */
918+
function contactBoneIds(info: {
919+
groundLock: readonly string[];
920+
grips: readonly { effector: string }[];
921+
pins: readonly { effector: string }[];
922+
reaches: readonly { effector: string; target: string }[];
923+
}): string[] {
924+
const ids = new Set<string>();
925+
const addEffector = (effector: string): void => {
926+
if (effector === "feet" || effector === "foot_left") ids.add("ankle_left");
927+
if (effector === "feet" || effector === "foot_right") ids.add("ankle_right");
928+
if (effector === "hands" || effector === "hand_left") ids.add("wrist_left");
929+
if (effector === "hands" || effector === "hand_right") ids.add("wrist_right");
930+
if (effector === "forearms") {
931+
ids.add("elbow_left");
932+
ids.add("elbow_right");
933+
}
934+
};
935+
for (const group of info.groundLock) addEffector(group);
936+
for (const contact of [...info.grips, ...info.pins]) addEffector(contact.effector);
937+
for (const reach of info.reaches) {
938+
if (reach.target === "floor") addEffector(reach.effector);
939+
}
940+
return [...ids];
941+
}
942+
885943
/**
886944
* Show or hide a figure's meshes. The skeleton keeps driving animation and
887945
* bounding-box grounding regardless, so hiding only the meshes lets a hidden

packages/posecode-render/test/xbot-asset.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import fs from "node:fs";
22
import { describe, expect, it } from "vitest";
33
import * as THREE from "three";
44
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
5+
import { rigCharacter } from "../src/character.js";
6+
import { buildMannequin } from "../src/mannequin.js";
7+
import { groundFigure } from "../src/groundlock.js";
8+
import { buildTimeline } from "../src/timeline.js";
9+
import { parse } from "posecode-parser";
510

611
const ASSET = new URL("../../../playground/public/models/xbot.glb", import.meta.url);
712

@@ -38,4 +43,57 @@ describe("normalized Xbot runtime asset", () => {
3843
);
3944
expect(vertices).toBeLessThan(30_000);
4045
});
46+
47+
it("matches the grounded driver's visible floor exactly", async () => {
48+
const character = rigCharacter(await loadXbot());
49+
const driver = buildMannequin(undefined, character.proportions);
50+
groundFigure(driver);
51+
character.sync(driver);
52+
53+
const driverMin = new THREE.Box3().setFromObject(driver.root).min.y;
54+
const characterMin = character.getBounds().min.y;
55+
expect(driverMin).toBeCloseTo(0, 4);
56+
expect(characterMin).toBeCloseTo(driverMin, 3);
57+
});
58+
59+
it("grounds Xbot's exact skinned surface in every floor-bound canonical phase", async () => {
60+
const character = rigCharacter(await loadXbot());
61+
const examples = new URL("../../../spec/examples/", import.meta.url);
62+
const files = fs.readdirSync(examples).filter((name) => name.endsWith(".posecode"));
63+
const failures: string[] = [];
64+
65+
for (const file of files) {
66+
const { ir, errors } = parse(fs.readFileSync(new URL(file, examples), "utf8"));
67+
expect(errors, file).toEqual([]);
68+
if (!ir) continue;
69+
const timeline = buildTimeline(ir);
70+
const base = timeline.basePose.root;
71+
for (let phaseIndex = 0; phaseIndex < timeline.segments.length; phaseIndex++) {
72+
const authored = ir.phases[phaseIndex]!;
73+
const floorBound = authored.grips.length === 0 &&
74+
!authored.pins.some((pin) => pin.anchor !== "floor");
75+
if (!floorBound) continue;
76+
77+
const driver = buildMannequin(undefined, character.proportions);
78+
driver.root.position.set(...(base?.position ?? [0, 0, 0]));
79+
const [rx, ry, rz] = base?.rotationDeg ?? [0, 0, 0];
80+
driver.root.rotation.set(
81+
THREE.MathUtils.degToRad(rx),
82+
THREE.MathUtils.degToRad(ry),
83+
THREE.MathUtils.degToRad(rz),
84+
);
85+
const segment = timeline.segments[phaseIndex]!;
86+
timeline.sample(segment.end - 1e-4, driver.bones);
87+
groundFigure(driver);
88+
character.sync(driver);
89+
character.reconcileFloor();
90+
const minY = character.getBounds().min.y;
91+
if (Math.abs(minY) >= 0.01) {
92+
failures.push(`${file}:${segment.name} minY=${minY.toFixed(4)}`);
93+
}
94+
}
95+
}
96+
97+
expect(failures).toEqual([]);
98+
}, 20_000);
4199
});

0 commit comments

Comments
 (0)