Skip to content

Commit 94399be

Browse files
Issue triage: editor docs, playground speed persistence, desk presets, BVH + glTF export (#108)
1 parent fd26d8f commit 94399be

37 files changed

Lines changed: 1307 additions & 762 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"posecode-render": patch
3+
---
4+
5+
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.

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,24 @@ npm run build
409409

410410
---
411411

412+
### Editor support
413+
414+
A VS Code extension provides syntax highlighting, ROM diagnostics, and
415+
completion for `.posecode` files — see
416+
[`editors/vscode`](editors/vscode/README.md). Until it is published, you can
417+
get basic highlighting immediately by associating `.posecode` files with
418+
Markdown:
419+
420+
```json
421+
"files.associations": {
422+
"*.posecode": "markdown"
423+
}
424+
```
425+
426+
See the [editor guide](editors/vscode/README.md#file-association-before-the-extension-is-installed)
427+
for VS Code, Cursor, Sublime Text, and Neovim instructions.
428+
429+
---
412430

413431
## MCP Server
414432

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

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

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+
554+
### Exporting motion (glTF / GLB)
555+
556+
For web animation pipelines, `posecode-render` can export the rig **and** a
557+
baked animation clip as a glTF/GLB asset. In the playground, use **Download
558+
glTF**; programmatically:
559+
560+
```ts
561+
import { parse } from "posecode-parser";
562+
import { exportGLTF } from "posecode-render";
563+
564+
const { ir } = parse(source);
565+
const glb = await exportGLTF(ir!); // GLB ArrayBuffer (default)
566+
const gltf = await exportGLTF(ir!, { binary: false }); // glTF JSON object
567+
```
568+
569+
The result loads with Three.js [`GLTFLoader`](https://threejs.org/docs/#GLTFLoader.load),
570+
and the clip plays on the included rig:
571+
572+
```ts
573+
const gltf = await new GLTFLoader().loadAsync(url);
574+
const mixer = new THREE.AnimationMixer(gltf.scene);
575+
mixer.clipAction(gltf.animations[0]).play();
576+
```
577+
578+
- Joint nodes are named by Posecode bone id; the animated root is `posecode_root`.
579+
- **Limitations:** exports the procedural mannequin rig, not a humanoid/Mixamo
580+
skeleton, so there is **no retargeting** onto external rigs yet, and (as with
581+
BVH) it bakes the authored motion, not the contact/IK-solved motion. See
582+
[issue #90](https://github.com/posecode-dev/posecode/issues/90).
583+
508584
---
509585

510586
## How Posecode Stays Honest

ROADMAP.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ 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 and glTF/GLB motion export bake the **authored** joint motion and root
126+
choreography (travel/turn); they do not yet re-run the renderer's contact/IK
127+
solve, so IK-dependent movements export their authored pose rather than the
128+
solved one. glTF export uses the procedural mannequin rig with no retargeting
129+
onto external/humanoid skeletons yet.
126130
- A **starter** prop set (chair / wall / bar / box / dip bars): no bench,
127131
rings, bands, or loaded implements yet, and props sit at fixed default
128132
placements.

editors/vscode/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,52 @@ Language support for the **Posecode** (`.posecode`) kinematic motion DSL:
99

1010
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.
1111

12+
## File association (before the extension is installed)
13+
14+
Until the full extension is published to the Marketplace, `.posecode` files
15+
open as plain text. You can get basic highlighting and comment/bracket
16+
behaviour right away by telling your editor to treat `.posecode` files as
17+
Markdown, which is the closest built-in grammar.
18+
19+
### VS Code
20+
21+
Add the following to your `settings.json` (open the Command Palette →
22+
**Preferences: Open User Settings (JSON)**, or use a workspace
23+
`.vscode/settings.json` to scope it to a single project):
24+
25+
```json
26+
{
27+
"files.associations": {
28+
"*.posecode": "markdown"
29+
}
30+
}
31+
```
32+
33+
Alternatively, open any `.posecode` file, click the language indicator in the
34+
bottom-right status bar (it will say "Plain Text"), choose **Configure File
35+
Association for '.posecode'…**, and pick **Markdown**.
36+
37+
### Cursor and other VS Code forks
38+
39+
Cursor, VSCodium, and other VS Code forks read the same `files.associations`
40+
setting, so the JSON snippet above works unchanged.
41+
42+
### Sublime Text
43+
44+
Open a `.posecode` file, then use the menu **View → Syntax → Open all with
45+
current extension as… → Markdown**.
46+
47+
### Neovim
48+
49+
Register the extension in your config:
50+
51+
```lua
52+
vim.filetype.add({ extension = { posecode = "markdown" } })
53+
```
54+
55+
Once the dedicated extension is installed it registers the real `posecode`
56+
language id, and you can remove these fallbacks.
57+
1258
## Develop / run locally
1359

1460
From the repo root:
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+
}

0 commit comments

Comments
 (0)