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
5 changes: 5 additions & 0 deletions .changeset/motion-export-bvh-gltf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posecode-render": patch
---

Add motion export. `exportBVH(ir, options?)` bakes a movement's authored joint motion and root travel/turn into a standard Biovision Hierarchy (`.bvh`) file, and `exportGLTF(ir, options?)` / `buildAnimatedRig(ir, options?)` export the mannequin rig plus a baked `AnimationClip` as a glTF/GLB asset that loads with Three.js `GLTFLoader`. Both sample the timeline headlessly (no WebGL) at a configurable frame rate and bake the full looped runtime; they export authored motion, not the contact/IK-solved motion.
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,24 @@ npm run build

---

### Editor support

A VS Code extension provides syntax highlighting, ROM diagnostics, and
completion for `.posecode` files — see
[`editors/vscode`](editors/vscode/README.md). Until it is published, you can
get basic highlighting immediately by associating `.posecode` files with
Markdown:

```json
"files.associations": {
"*.posecode": "markdown"
}
```

See the [editor guide](editors/vscode/README.md#file-association-before-the-extension-is-installed)
for VS Code, Cursor, Sublime Text, and Neovim instructions.

---

## MCP Server

Expand Down Expand Up @@ -505,6 +523,64 @@ if (!ir || errors.length > 0) {

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

### Exporting motion (BVH)

`posecode-render` can bake a movement into a [Biovision Hierarchy](https://en.wikipedia.org/wiki/Biovision_Hierarchy)
(`.bvh`) file for import into Blender and other animation tools. In the
playground, use the **Download BVH** button; programmatically:

```ts
import { parse } from "posecode-parser";
import { exportBVH } from "posecode-render";

const { ir } = parse(source);
const bvh = exportBVH(ir!, { fps: 30 }); // string, ready to write to disk
```

Options: `fps` (default 30), `scale` (default 1 = metres; pass `100` for
centimetres), `includeFingers` (default false), and `proportions` for a
calibrated rig.

- **Coordinate system:** right-handed, **Y-up**, figure faces **+Z** in the
rest pose (identical to the renderer and Three.js). Enable Blender's "Y up"
BVH import option.
- **Units:** metres by default.
- **Rotation channels:** `Zrotation Xrotation Yrotation` (Euler order `ZXY`).
- **Scope:** this exports the *authored* joint motion plus root travel/turn. It
does not yet re-run the renderer's contact/IK solve, so IK-dependent movements
(e.g. `reach: hand_left floor`) export the authored pose rather than the
solved one. See [issue #63](https://github.com/posecode-dev/posecode/issues/63).

### Exporting motion (glTF / GLB)

For web animation pipelines, `posecode-render` can export the rig **and** a
baked animation clip as a glTF/GLB asset. In the playground, use **Download
glTF**; programmatically:

```ts
import { parse } from "posecode-parser";
import { exportGLTF } from "posecode-render";

const { ir } = parse(source);
const glb = await exportGLTF(ir!); // GLB ArrayBuffer (default)
const gltf = await exportGLTF(ir!, { binary: false }); // glTF JSON object
```

The result loads with Three.js [`GLTFLoader`](https://threejs.org/docs/#GLTFLoader.load),
and the clip plays on the included rig:

```ts
const gltf = await new GLTFLoader().loadAsync(url);
const mixer = new THREE.AnimationMixer(gltf.scene);
mixer.clipAction(gltf.animations[0]).play();
```

- Joint nodes are named by Posecode bone id; the animated root is `posecode_root`.
- **Limitations:** exports the procedural mannequin rig, not a humanoid/Mixamo
skeleton, so there is **no retargeting** onto external rigs yet, and (as with
BVH) it bakes the authored motion, not the contact/IK-solved motion. See
[issue #90](https://github.com/posecode-dev/posecode/issues/90).

---

## How Posecode Stays Honest
Expand Down
6 changes: 5 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ Each prop is a small scene object + an anchor type; movements then reference it
- Self-collision is a bounded corrective pass over selected body pairs, not a
comprehensive physics system. It exposes residuals for those sampled pairs,
but does not detect every possible body-body collision.
- There is no glTF/GLB or BVH motion export yet.
- BVH and glTF/GLB motion export bake the **authored** joint motion and root
choreography (travel/turn); they do not yet re-run the renderer's contact/IK
solve, so IK-dependent movements export their authored pose rather than the
solved one. glTF export uses the procedural mannequin rig with no retargeting
onto external/humanoid skeletons yet.
- A **starter** prop set (chair / wall / bar / box / dip bars): no bench,
rings, bands, or loaded implements yet, and props sit at fixed default
placements.
Expand Down
46 changes: 46 additions & 0 deletions editors/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,52 @@ Language support for the **Posecode** (`.posecode`) kinematic motion DSL:

The smart features are provided by [`posecode-lsp`](../../packages/posecode-lsp), which shares its language logic ([`posecode-language`](../../packages/posecode-language)) with the web playground, so the editor and the playground always agree.

## File association (before the extension is installed)

Until the full extension is published to the Marketplace, `.posecode` files
open as plain text. You can get basic highlighting and comment/bracket
behaviour right away by telling your editor to treat `.posecode` files as
Markdown, which is the closest built-in grammar.

### VS Code

Add the following to your `settings.json` (open the Command Palette →
**Preferences: Open User Settings (JSON)**, or use a workspace
`.vscode/settings.json` to scope it to a single project):

```json
{
"files.associations": {
"*.posecode": "markdown"
}
}
```

Alternatively, open any `.posecode` file, click the language indicator in the
bottom-right status bar (it will say "Plain Text"), choose **Configure File
Association for '.posecode'…**, and pick **Markdown**.

### Cursor and other VS Code forks

Cursor, VSCodium, and other VS Code forks read the same `files.associations`
setting, so the JSON snippet above works unchanged.

### Sublime Text

Open a `.posecode` file, then use the menu **View → Syntax → Open all with
current extension as… → Markdown**.

### Neovim

Register the extension in your config:

```lua
vim.filetype.add({ extension = { posecode = "markdown" } })
```

Once the dedicated extension is installed it registers the real `posecode`
language id, and you can remove these fallbacks.

## Develop / run locally

From the repo root:
Expand Down
228 changes: 228 additions & 0 deletions packages/posecode-render/src/bvh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* BVH motion export.
*
* Bakes a Posecode movement into a Biovision Hierarchy (`.bvh`) file so an
* authored movement can be imported into Blender and other animation tools.
*
* ## What is exported
*
* This exporter bakes the **authored joint motion**: the timeline's forward
* kinematics (joint rotations per phase) plus the root choreography (travel
* translation and turn yaw). It does NOT run the renderer's contact solve
* (ground-lock, reach/pin/grip IK, floor clamping), so movements whose final
* look depends on IK — e.g. a `reach: hand_left floor` — will export the
* authored pose rather than the solved one. Purely FK-authored movements
* (squats, curls, ballet port de bras, most of the library) round-trip
* faithfully. Exporting the fully solved motion is a documented future
* enhancement; see issue #63.
*
* ## Coordinate system and scale
*
* - Right-handed, **Y-up**, figure facing **+Z** in the rest pose — identical
* to the renderer's rig, and to Three.js' default. Blender's BVH importer
* has a "Y up" option; enable it (or apply a +90° X rotation on import).
* - Units are **metres** by default. Pass `scale: 100` to emit centimetres if
* your tool expects that.
* - Joint rotation channels use the `Zrotation Xrotation Yrotation` order
* (Euler order `ZXY`), the most widely compatible BVH convention.
*/

import * as THREE from "three";
import type { PosecodeIR } from "posecode-parser";
import { buildMannequin, type Proportions } from "./mannequin.js";
import { buildTimeline } from "./timeline.js";

/** Finger joints, excluded by default to keep the skeleton importer-friendly. */
const FINGER_PREFIXES = ["thumb", "index", "middle", "ring", "pinky"];

const DEFAULT_FPS = 30;
/** BVH channel order `Zrotation Xrotation Yrotation` ⇔ Three.js Euler `ZXY`. */
const EULER_ORDER = "ZXY" as const;

export interface BvhExportOptions {
/** Sample rate for the baked keyframes. Defaults to 30 fps. */
fps?: number;
/** Include the per-finger curl joints (30 extra channels). Defaults to false. */
includeFingers?: boolean;
/** Multiply every length by this factor. 1 = metres (default), 100 = cm. */
scale?: number;
/** Rig proportions, if exporting for a calibrated character. */
proportions?: Proportions;
}

function isFingerBone(id: string): boolean {
return FINGER_PREFIXES.some((p) => id.startsWith(p));
}

/** A joint in the export skeleton, mirroring the live mannequin bone tree. */
interface ExportJoint {
id: string;
node: THREE.Object3D;
children: ExportJoint[];
}

/**
* Sensible End Site tip offset (metres, local frame) for a leaf joint, so the
* exported skeleton reads correctly in an importer. Cosmetic — any non-zero
* value produces a valid file.
*/
function endSiteOffset(id: string): [number, number, number] {
if (id === "head") return [0, 0.12, 0];
if (id.startsWith("ankle")) return [0, -0.04, 0.14]; // toe, forward
if (isFingerBone(id)) return [0, -0.03, 0];
return [0, -0.08, 0]; // generic distal extension
}

/** Build the export skeleton tree from a fresh, rest-posed mannequin. */
function buildExportSkeleton(
bones: Map<string, THREE.Object3D>,
root: THREE.Object3D,
includeFingers: boolean,
): ExportJoint {
const boneNodes = new Set(bones.values());
const make = (id: string, node: THREE.Object3D): ExportJoint => {
const children: ExportJoint[] = [];
for (const [childId, childNode] of bones) {
if (childNode.parent !== node) continue;
if (!includeFingers && isFingerBone(childId)) continue;
children.push(make(childId, childNode));
}
// Deterministic child order keeps output stable across runs.
children.sort((a, b) => a.id.localeCompare(b.id));
return { id, node, children };
};
// The root joint is the single bone parented directly to the rig group.
for (const [id, node] of bones) {
if (node.parent === root || !boneNodes.has(node.parent as THREE.Object3D)) {
return make(id, node);
}
}
throw new Error("posecode BVH export: could not locate a root joint");
}

function fmt(n: number): string {
// Trim to 6 decimals, then strip trailing zeros for compact, stable output.
return Number(n.toFixed(6)).toString();
}

/** Enumerate joints in the exact depth-first order channel values are written. */
function flatten(joint: ExportJoint, out: ExportJoint[]): void {
out.push(joint);
for (const child of joint.children) flatten(child, out);
}

function writeHierarchy(
joint: ExportJoint,
scale: number,
depth: number,
isRoot: boolean,
): string {
const pad = " ".repeat(depth);
const lines: string[] = [];
if (isRoot) {
lines.push(`${pad}ROOT ${joint.id}`);
lines.push(`${pad}{`);
// The root joint carries the world translation in its position channels,
// so its own OFFSET is the origin.
lines.push(`${pad} OFFSET 0.000000 0.000000 0.000000`);
lines.push(
`${pad} CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation`,
);
} else {
const o = joint.node.position;
lines.push(`${pad}JOINT ${joint.id}`);
lines.push(`${pad}{`);
lines.push(
`${pad} OFFSET ${fmt(o.x * scale)} ${fmt(o.y * scale)} ${fmt(o.z * scale)}`,
);
lines.push(`${pad} CHANNELS 3 Zrotation Xrotation Yrotation`);
}
if (joint.children.length > 0) {
for (const child of joint.children) {
lines.push(writeHierarchy(child, scale, depth + 1, false));
}
} else {
// Leaf joint: BVH requires a terminating End Site with a tip offset.
const [ex, ey, ez] = endSiteOffset(joint.id);
lines.push(`${pad} End Site`);
lines.push(`${pad} {`);
lines.push(`${pad} OFFSET ${fmt(ex * scale)} ${fmt(ey * scale)} ${fmt(ez * scale)}`);
lines.push(`${pad} }`);
}
lines.push(`${pad}}`);
return lines.join("\n");
}

const _euler = new THREE.Euler();
const _yawQuat = new THREE.Quaternion();
const _rootQuat = new THREE.Quaternion();
const _up = new THREE.Vector3(0, 1, 0);

/** Extract `Zrotation Xrotation Yrotation` degrees from a local quaternion. */
function eulerChannels(q: THREE.Quaternion): [number, number, number] {
_euler.setFromQuaternion(q, EULER_ORDER);
const RAD2DEG = 180 / Math.PI;
return [_euler.z * RAD2DEG, _euler.x * RAD2DEG, _euler.y * RAD2DEG];
}

/**
* Export a parsed Posecode movement as BVH text.
*
* The returned string is a complete `.bvh` document (HIERARCHY + MOTION) ready
* to write to disk or hand to a browser download.
*/
export function exportBVH(ir: PosecodeIR, options: BvhExportOptions = {}): string {
const fps = options.fps && options.fps > 0 ? options.fps : DEFAULT_FPS;
const scale = options.scale && options.scale > 0 ? options.scale : 1;
const includeFingers = options.includeFingers ?? false;

const mannequin = buildMannequin(undefined, options.proportions);
const timeline = buildTimeline(ir);
const skeleton = buildExportSkeleton(
mannequin.bones,
mannequin.root,
includeFingers,
);

const flat: ExportJoint[] = [];
flatten(skeleton, flat);
const rootRestY = skeleton.node.position.y;

// Bake one frame per 1/fps across the full played duration (all repeats), so
// the loop count and total runtime survive the export as literal keyframes.
const cycle = Math.max(timeline.duration, 1e-6);
const total = cycle * Math.max(1, timeline.repeat);
const dt = 1 / fps;
const frameCount = Math.max(1, Math.round(total * fps)) + 1;

const motionRows: string[] = [];
for (let f = 0; f < frameCount; f++) {
const t = Math.min(f * dt, total);
const info = timeline.sample(t, mannequin.bones);

// Root world orientation folds the body yaw into the pelvis local rotation
// (the root joint has no parent, so its channels are world-space).
_yawQuat.setFromAxisAngle(_up, info.rootYaw);
_rootQuat.copy(_yawQuat).multiply(skeleton.node.quaternion);

const row: number[] = [];
for (const joint of flat) {
if (joint === skeleton) {
row.push(info.rootOffset.x * scale, rootRestY * scale, info.rootOffset.z * scale);
row.push(...eulerChannels(_rootQuat));
} else {
row.push(...eulerChannels(joint.node.quaternion));
}
}
motionRows.push(row.map(fmt).join(" "));
}

const header = [
"HIERARCHY",
writeHierarchy(skeleton, scale, 0, true),
"MOTION",
`Frames: ${frameCount}`,
`Frame Time: ${fmt(dt)}`,
];
return `${header.join("\n")}\n${motionRows.join("\n")}\n`;
}
Loading