Skip to content

Commit e354036

Browse files
committed
render: add BVH motion export (#63)
Adds exportBVH() to posecode-render: bakes a movement's authored joint motion plus root travel/turn choreography into a standard Biovision Hierarchy (.bvh) file, headlessly (no WebGL), by sampling the timeline at a configurable frame rate. - Skeleton mirrors the mannequin bone tree; leaves terminate with End Sites. - Right-handed Y-up, +Z facing, metres (configurable scale), ZXY Euler order (Zrotation Xrotation Yrotation channels) for wide tool compatibility. - Full looped runtime (cycle incl. loop-reset wrap x repeats) is baked so duration and loop count survive; root position channels carry travel/turn. - Fingers excluded by default (opt in via includeFingers). - Playground gains a 'Download BVH' button; renderer chunk stays lazy-loaded. - Documented scope: authored motion, not the contact/IK-solved motion (future). Tested headlessly: hierarchy/channel-count/frame-count structure, a rotation round-trip that reconstructs the sampled pose, root translation for a travelling movement, finger opt-in, and scale. Signed-off-by: Claude <noreply@anthropic.com>
1 parent 4daf396 commit e354036

7 files changed

Lines changed: 480 additions & 1 deletion

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,34 @@ if (!ir || errors.length > 0) {
523523

524524
The `#viewer` element is an HTML `<canvas>`.
525525

526+
### Exporting motion (BVH)
527+
528+
`posecode-render` can bake a movement into a [Biovision Hierarchy](https://en.wikipedia.org/wiki/Biovision_Hierarchy)
529+
(`.bvh`) file for import into Blender and other animation tools. In the
530+
playground, use the **Download BVH** button; programmatically:
531+
532+
```ts
533+
import { parse } from "posecode-parser";
534+
import { exportBVH } from "posecode-render";
535+
536+
const { ir } = parse(source);
537+
const bvh = exportBVH(ir!, { fps: 30 }); // string, ready to write to disk
538+
```
539+
540+
Options: `fps` (default 30), `scale` (default 1 = metres; pass `100` for
541+
centimetres), `includeFingers` (default false), and `proportions` for a
542+
calibrated rig.
543+
544+
- **Coordinate system:** right-handed, **Y-up**, figure faces **+Z** in the
545+
rest pose (identical to the renderer and Three.js). Enable Blender's "Y up"
546+
BVH import option.
547+
- **Units:** metres by default.
548+
- **Rotation channels:** `Zrotation Xrotation Yrotation` (Euler order `ZXY`).
549+
- **Scope:** this exports the *authored* joint motion plus root travel/turn. It
550+
does not yet re-run the renderer's contact/IK solve, so IK-dependent movements
551+
(e.g. `reach: hand_left floor`) export the authored pose rather than the
552+
solved one. See [issue #63](https://github.com/posecode-dev/posecode/issues/63).
553+
526554
---
527555

528556
## How Posecode Stays Honest

ROADMAP.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ Each prop is a small scene object + an anchor type; movements then reference it
122122
- Self-collision is a bounded corrective pass over selected body pairs, not a
123123
comprehensive physics system. It exposes residuals for those sampled pairs,
124124
but does not detect every possible body-body collision.
125-
- There is no glTF/GLB or BVH motion export yet.
125+
- BVH motion export bakes the **authored** joint motion and root choreography
126+
(travel/turn); it does not yet re-run the renderer's contact/IK solve, so
127+
IK-dependent movements export their authored pose rather than the solved one.
128+
There is no glTF/GLB export yet.
126129
- A **starter** prop set (chair / wall / bar / box / dip bars): no bench,
127130
rings, bands, or loaded implements yet, and props sit at fixed default
128131
placements.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* BVH motion export.
3+
*
4+
* Bakes a Posecode movement into a Biovision Hierarchy (`.bvh`) file so an
5+
* authored movement can be imported into Blender and other animation tools.
6+
*
7+
* ## What is exported
8+
*
9+
* This exporter bakes the **authored joint motion**: the timeline's forward
10+
* kinematics (joint rotations per phase) plus the root choreography (travel
11+
* translation and turn yaw). It does NOT run the renderer's contact solve
12+
* (ground-lock, reach/pin/grip IK, floor clamping), so movements whose final
13+
* look depends on IK — e.g. a `reach: hand_left floor` — will export the
14+
* authored pose rather than the solved one. Purely FK-authored movements
15+
* (squats, curls, ballet port de bras, most of the library) round-trip
16+
* faithfully. Exporting the fully solved motion is a documented future
17+
* enhancement; see issue #63.
18+
*
19+
* ## Coordinate system and scale
20+
*
21+
* - Right-handed, **Y-up**, figure facing **+Z** in the rest pose — identical
22+
* to the renderer's rig, and to Three.js' default. Blender's BVH importer
23+
* has a "Y up" option; enable it (or apply a +90° X rotation on import).
24+
* - Units are **metres** by default. Pass `scale: 100` to emit centimetres if
25+
* your tool expects that.
26+
* - Joint rotation channels use the `Zrotation Xrotation Yrotation` order
27+
* (Euler order `ZXY`), the most widely compatible BVH convention.
28+
*/
29+
30+
import * as THREE from "three";
31+
import type { PosecodeIR } from "posecode-parser";
32+
import { buildMannequin, type Proportions } from "./mannequin.js";
33+
import { buildTimeline } from "./timeline.js";
34+
35+
/** Finger joints, excluded by default to keep the skeleton importer-friendly. */
36+
const FINGER_PREFIXES = ["thumb", "index", "middle", "ring", "pinky"];
37+
38+
const DEFAULT_FPS = 30;
39+
/** BVH channel order `Zrotation Xrotation Yrotation` ⇔ Three.js Euler `ZXY`. */
40+
const EULER_ORDER = "ZXY" as const;
41+
42+
export interface BvhExportOptions {
43+
/** Sample rate for the baked keyframes. Defaults to 30 fps. */
44+
fps?: number;
45+
/** Include the per-finger curl joints (30 extra channels). Defaults to false. */
46+
includeFingers?: boolean;
47+
/** Multiply every length by this factor. 1 = metres (default), 100 = cm. */
48+
scale?: number;
49+
/** Rig proportions, if exporting for a calibrated character. */
50+
proportions?: Proportions;
51+
}
52+
53+
function isFingerBone(id: string): boolean {
54+
return FINGER_PREFIXES.some((p) => id.startsWith(p));
55+
}
56+
57+
/** A joint in the export skeleton, mirroring the live mannequin bone tree. */
58+
interface ExportJoint {
59+
id: string;
60+
node: THREE.Object3D;
61+
children: ExportJoint[];
62+
}
63+
64+
/**
65+
* Sensible End Site tip offset (metres, local frame) for a leaf joint, so the
66+
* exported skeleton reads correctly in an importer. Cosmetic — any non-zero
67+
* value produces a valid file.
68+
*/
69+
function endSiteOffset(id: string): [number, number, number] {
70+
if (id === "head") return [0, 0.12, 0];
71+
if (id.startsWith("ankle")) return [0, -0.04, 0.14]; // toe, forward
72+
if (isFingerBone(id)) return [0, -0.03, 0];
73+
return [0, -0.08, 0]; // generic distal extension
74+
}
75+
76+
/** Build the export skeleton tree from a fresh, rest-posed mannequin. */
77+
function buildExportSkeleton(
78+
bones: Map<string, THREE.Object3D>,
79+
root: THREE.Object3D,
80+
includeFingers: boolean,
81+
): ExportJoint {
82+
const boneNodes = new Set(bones.values());
83+
const make = (id: string, node: THREE.Object3D): ExportJoint => {
84+
const children: ExportJoint[] = [];
85+
for (const [childId, childNode] of bones) {
86+
if (childNode.parent !== node) continue;
87+
if (!includeFingers && isFingerBone(childId)) continue;
88+
children.push(make(childId, childNode));
89+
}
90+
// Deterministic child order keeps output stable across runs.
91+
children.sort((a, b) => a.id.localeCompare(b.id));
92+
return { id, node, children };
93+
};
94+
// The root joint is the single bone parented directly to the rig group.
95+
for (const [id, node] of bones) {
96+
if (node.parent === root || !boneNodes.has(node.parent as THREE.Object3D)) {
97+
return make(id, node);
98+
}
99+
}
100+
throw new Error("posecode BVH export: could not locate a root joint");
101+
}
102+
103+
function fmt(n: number): string {
104+
// Trim to 6 decimals, then strip trailing zeros for compact, stable output.
105+
return Number(n.toFixed(6)).toString();
106+
}
107+
108+
/** Enumerate joints in the exact depth-first order channel values are written. */
109+
function flatten(joint: ExportJoint, out: ExportJoint[]): void {
110+
out.push(joint);
111+
for (const child of joint.children) flatten(child, out);
112+
}
113+
114+
function writeHierarchy(
115+
joint: ExportJoint,
116+
scale: number,
117+
depth: number,
118+
isRoot: boolean,
119+
): string {
120+
const pad = " ".repeat(depth);
121+
const lines: string[] = [];
122+
if (isRoot) {
123+
lines.push(`${pad}ROOT ${joint.id}`);
124+
lines.push(`${pad}{`);
125+
// The root joint carries the world translation in its position channels,
126+
// so its own OFFSET is the origin.
127+
lines.push(`${pad} OFFSET 0.000000 0.000000 0.000000`);
128+
lines.push(
129+
`${pad} CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation`,
130+
);
131+
} else {
132+
const o = joint.node.position;
133+
lines.push(`${pad}JOINT ${joint.id}`);
134+
lines.push(`${pad}{`);
135+
lines.push(
136+
`${pad} OFFSET ${fmt(o.x * scale)} ${fmt(o.y * scale)} ${fmt(o.z * scale)}`,
137+
);
138+
lines.push(`${pad} CHANNELS 3 Zrotation Xrotation Yrotation`);
139+
}
140+
if (joint.children.length > 0) {
141+
for (const child of joint.children) {
142+
lines.push(writeHierarchy(child, scale, depth + 1, false));
143+
}
144+
} else {
145+
// Leaf joint: BVH requires a terminating End Site with a tip offset.
146+
const [ex, ey, ez] = endSiteOffset(joint.id);
147+
lines.push(`${pad} End Site`);
148+
lines.push(`${pad} {`);
149+
lines.push(`${pad} OFFSET ${fmt(ex * scale)} ${fmt(ey * scale)} ${fmt(ez * scale)}`);
150+
lines.push(`${pad} }`);
151+
}
152+
lines.push(`${pad}}`);
153+
return lines.join("\n");
154+
}
155+
156+
const _euler = new THREE.Euler();
157+
const _yawQuat = new THREE.Quaternion();
158+
const _rootQuat = new THREE.Quaternion();
159+
const _up = new THREE.Vector3(0, 1, 0);
160+
161+
/** Extract `Zrotation Xrotation Yrotation` degrees from a local quaternion. */
162+
function eulerChannels(q: THREE.Quaternion): [number, number, number] {
163+
_euler.setFromQuaternion(q, EULER_ORDER);
164+
const RAD2DEG = 180 / Math.PI;
165+
return [_euler.z * RAD2DEG, _euler.x * RAD2DEG, _euler.y * RAD2DEG];
166+
}
167+
168+
/**
169+
* Export a parsed Posecode movement as BVH text.
170+
*
171+
* The returned string is a complete `.bvh` document (HIERARCHY + MOTION) ready
172+
* to write to disk or hand to a browser download.
173+
*/
174+
export function exportBVH(ir: PosecodeIR, options: BvhExportOptions = {}): string {
175+
const fps = options.fps && options.fps > 0 ? options.fps : DEFAULT_FPS;
176+
const scale = options.scale && options.scale > 0 ? options.scale : 1;
177+
const includeFingers = options.includeFingers ?? false;
178+
179+
const mannequin = buildMannequin(undefined, options.proportions);
180+
const timeline = buildTimeline(ir);
181+
const skeleton = buildExportSkeleton(
182+
mannequin.bones,
183+
mannequin.root,
184+
includeFingers,
185+
);
186+
187+
const flat: ExportJoint[] = [];
188+
flatten(skeleton, flat);
189+
const rootRestY = skeleton.node.position.y;
190+
191+
// Bake one frame per 1/fps across the full played duration (all repeats), so
192+
// the loop count and total runtime survive the export as literal keyframes.
193+
const cycle = Math.max(timeline.duration, 1e-6);
194+
const total = cycle * Math.max(1, timeline.repeat);
195+
const dt = 1 / fps;
196+
const frameCount = Math.max(1, Math.round(total * fps)) + 1;
197+
198+
const motionRows: string[] = [];
199+
for (let f = 0; f < frameCount; f++) {
200+
const t = Math.min(f * dt, total);
201+
const info = timeline.sample(t, mannequin.bones);
202+
203+
// Root world orientation folds the body yaw into the pelvis local rotation
204+
// (the root joint has no parent, so its channels are world-space).
205+
_yawQuat.setFromAxisAngle(_up, info.rootYaw);
206+
_rootQuat.copy(_yawQuat).multiply(skeleton.node.quaternion);
207+
208+
const row: number[] = [];
209+
for (const joint of flat) {
210+
if (joint === skeleton) {
211+
row.push(info.rootOffset.x * scale, rootRestY * scale, info.rootOffset.z * scale);
212+
row.push(...eulerChannels(_rootQuat));
213+
} else {
214+
row.push(...eulerChannels(joint.node.quaternion));
215+
}
216+
}
217+
motionRows.push(row.map(fmt).join(" "));
218+
}
219+
220+
const header = [
221+
"HIERARCHY",
222+
writeHierarchy(skeleton, scale, 0, true),
223+
"MOTION",
224+
`Frames: ${frameCount}`,
225+
`Frame Time: ${fmt(dt)}`,
226+
];
227+
return `${header.join("\n")}\n${motionRows.join("\n")}\n`;
228+
}

packages/posecode-render/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,3 +1546,4 @@ export {
15461546
type FootContactMeasurement,
15471547
} from "./contacts.js";
15481548
export type { PhaseSegment } from "./timeline.js";
1549+
export { exportBVH, type BvhExportOptions } from "./bvh.js";

0 commit comments

Comments
 (0)