Skip to content

Commit 7eeacdd

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/position-movement-format
# Conflicts: # playground/public/moves/box-step.html # playground/public/moves/chasse.html # playground/public/moves/grapevine.html # playground/public/moves/index.html # playground/public/moves/seated-torso-twist.html # playground/public/moves/shoulder-blade-retraction.html # playground/public/moves/walk-cycle.html # playground/public/moves/waltz-box.html # playground/public/moves/wrist-forearm-rolls.html # playground/src/main.ts
2 parents bd9e0b4 + 3a72ea4 commit 7eeacdd

70 files changed

Lines changed: 1914 additions & 1149 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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.

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ The repository root retains the canonical [`LICENSE`](../LICENSE) and [`NOTICE`]
2626

2727
## Development references
2828

29+
- [Product usage analytics](product-analytics.md)
2930
- [Vercel agent notes](development/VERCEL_AGENTS.md)
3031
- [Pose diagnostics summary](diagnostics/pose-summary.json)

docs/product-analytics.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Product usage analytics
2+
3+
Posecode keeps Vercel pageviews and product-usage events separate. Pageviews
4+
answer “which routes were visited?”; the events below answer “did someone use
5+
the product?”
6+
7+
## Provider and production configuration
8+
9+
The implementation is provider-neutral at the call sites. Event names, payload
10+
types, failure isolation, and session deduplication live in
11+
`playground/src/analytics.ts`. The current adapter is
12+
`playground/src/vercel-analytics.ts`.
13+
14+
Vercel Web Analytics pageviews remain enabled without extra configuration.
15+
Vercel's current plan table says custom events are **not available on Hobby**;
16+
they are available on Pro and Enterprise. Pro allows at most two properties per
17+
custom event. The schema below deliberately stays within that limit.
18+
19+
To enable product events on a Vercel Pro or Enterprise production project:
20+
21+
1. Enable Web Analytics for the project in Vercel.
22+
2. Set `VITE_PRODUCT_ANALYTICS_PROVIDER=vercel` for the Production environment.
23+
3. Redeploy so Vite includes the provider choice in the client bundle.
24+
4. Exercise one event and confirm it in **Project → Analytics → Events**.
25+
26+
Do not set the variable on Hobby expecting dashboard data: Hobby continues to
27+
show pageviews but does not expose custom events. No alternate paid analytics
28+
vendor is installed. A future adapter can call `configureUsageAnalytics`
29+
without changing UI event call sites.
30+
31+
Sources:
32+
33+
- [Vercel custom events](https://vercel.com/docs/analytics/custom-events)
34+
- [Vercel Web Analytics limits and pricing](https://vercel.com/docs/analytics/limits-and-pricing)
35+
36+
## Event dictionary
37+
38+
| Event | Fires when | Properties |
39+
|---|---|---|
40+
| `preset_opened` | A bundled movement is actually opened at initial load or selected in the library. | `source`: `library`, `direct_url`, `shared_link`, or `landing_cta`; `preset_id`: bundled stable ID |
41+
| `editor_changed` | The first real CodeMirror user edit in the page session. Programmatic preset loads do not count. | `document_kind`: `preset`, `shared`, or `custom` |
42+
| `render_succeeded` | `viewer.load()` successfully accepts a new meaningful document revision. Lazy boot and repeated recompiles of the same revision are deduplicated. The animation frame loop never emits this event. | `trigger`: `initial`, `preset_open`, `shared_link`, or `editor_change`; `document_kind` |
43+
| `share_created` | The generated preset/encoded URL has successfully been written to the clipboard. | `share_kind`: `preset` or `encoded` |
44+
| `embed_docs_clicked` | The embed documentation CTA on `/for-products` is clicked. | `location`: `for_products` |
45+
| `install_command_copied` | An npm/npx command on `/for-products` is successfully written to the clipboard. | `command`: `embed`, `packages`, or `mcp`; `location`: `for_products` |
46+
47+
## Reading the dashboard
48+
49+
Open **Analytics → Events**, select an event, then drill into its properties.
50+
Useful readings include:
51+
52+
- `preset_opened` grouped by `source` separates library discovery from direct,
53+
shared, and landing-page entry.
54+
- Compare `editor_changed` and `render_succeeded` counts to see whether editing
55+
reaches a valid renderer update. They are intentionally not a strict funnel:
56+
initial and preset renders also count.
57+
- `share_created` is a confirmed clipboard outcome, not a button-click count.
58+
- Group `install_command_copied` by `command` to compare integration intent.
59+
60+
Vercel reports aggregate events rather than a user-level funnel. Do not attempt
61+
to join individual visitors or reconstruct sessions from these payloads.
62+
63+
## Privacy and resilience
64+
65+
Events never contain Posecode source text, authoring prompts, personal data,
66+
full share tokens, query strings, referrers, or sensitive URLs. `preset_id` is a
67+
bounded public catalogue identifier; all other values are closed enums.
68+
69+
Every analytics call is best-effort and catches provider failures. Ad blockers,
70+
network failures, a missing provider configuration, or plan limitations do not
71+
change editing, rendering, sharing, navigation, or clipboard behavior.

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:

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/posecode-eval/src/diagnostics.ts

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ export interface ClipDiagnosticsCollector {
180180
finish(): ClipDiagnostics;
181181
}
182182

183-
export function createClipDiagnosticsCollector(sampleRateHz: number): ClipDiagnosticsCollector {
183+
export function createClipDiagnosticsCollector(
184+
sampleRateHz: number,
185+
isLocomotion = false,
186+
): ClipDiagnosticsCollector {
184187
const rate = Math.max(
185188
1,
186189
Math.min(120, Number.isFinite(sampleRateHz) ? sampleRateHz : DEFAULT_DIAGNOSTIC_SAMPLE_RATE_HZ),
@@ -195,16 +198,24 @@ export function createClipDiagnosticsCollector(sampleRateHz: number): ClipDiagno
195198
const state = feet[side];
196199
const foot = measureFootContact(m, side);
197200
let kind = supportKind(frame, side);
198-
// The generic `feet` group also contains a deliberately lifted swing
199-
// foot. Match ground-lock's own near-floor selection so that swing height
200-
// is not mislabeled as a failed planted contact; an explicit foot lock or
201-
// floor pin is always evaluated.
201+
// A supported foot whose sole is well off the floor is mid-swing, not
202+
// planted. Skip it in two cases: (1) the generic `feet` group's lifted
203+
// swing foot, and (2) any airborne foot in a locomotion clip — at a step
204+
// transition the stance pin alternates a beat before the landing foot is
205+
// actually down, so the descending foot is swinging, not a failed plant.
206+
// The endpoint contact-position check still catches a pin left airborne.
207+
const airborne =
208+
kind !== null
209+
&& foot !== null
210+
&& !isGroundLockFootPlanted(floorContactHeight(m, `foot_${side}`) ?? NaN);
202211
if (
203-
kind === "ground-lock"
204-
&& frame.groundLock.includes("feet")
205-
&& !frame.groundLock.includes(`foot_${side}`)
206-
&& foot
207-
&& !isGroundLockFootPlanted(floorContactHeight(m, `foot_${side}`) ?? NaN)
212+
airborne
213+
&& (
214+
(kind === "ground-lock"
215+
&& frame.groundLock.includes("feet")
216+
&& !frame.groundLock.includes(`foot_${side}`))
217+
|| isLocomotion
218+
)
208219
) kind = null;
209220
if (!kind || !foot) {
210221
state.supportKind = null;
@@ -214,21 +225,32 @@ export function createClipDiagnosticsCollector(sampleRateHz: number): ClipDiagno
214225
}
215226
state.supportedSamples++;
216227
const location = { timeSec: frame.timeSec, phaseName: frame.phaseName };
217-
state.minToeHeightMeters = Math.min(state.minToeHeightMeters ?? Infinity, foot.toeHeight);
218-
state.maxToeHeightMeters = Math.max(state.maxToeHeightMeters ?? -Infinity, foot.toeHeight);
219-
if (Math.abs(foot.toeHeight) > state.worstToeAbs) {
220-
state.worstToeAbs = Math.abs(foot.toeHeight);
221-
state.worstToe = location;
222-
}
223-
// Flat-sole grounding checks only apply when a flat foot is expected: the
224-
// ankle is not plantarflexed AND the shin stands near-vertical. A foot on
225-
// its ball with the shin laid down (plank, knee-drive) legitimately shows
226-
// a steep sole and lifted heel, so measuring it as a failed flat plant
227-
// fabricates warnings.
228+
// Flat-foot grounding checks (heel/toe height, sole tilt) only apply when a
229+
// flat foot is expected: the ankle is not plantarflexed AND the shin stands
230+
// near-vertical. A foot on its ball with the shin laid down (plank,
231+
// knee-drive) legitimately shows a lifted heel/toe and a steep sole, so
232+
// measuring it as a failed flat plant fabricates warnings.
228233
const shinDeg = shinFromVerticalDeg(m, side);
234+
// A stance foot in a locomotion clip rolls onto its ball as the body
235+
// travels over it — the toe stays planted while the heel lifts (push-off).
236+
// That roll is correct gait, not a failed flat plant, so it is exempt from
237+
// the flat-foot checks. A fully airborne foot (toe also lifted) is not a
238+
// roll and stays measured; static clips keep the strict flat-foot bar.
239+
const pushOffRoll =
240+
isLocomotion
241+
&& Math.abs(foot.toeHeight) <= FOOT_CONTACT_HEIGHT_MAX
242+
&& foot.heelHeight > FOOT_CONTACT_HEIGHT_MAX;
229243
const expectedFlat =
230-
foot.plantigrade && (shinDeg === null || shinDeg <= FLAT_SOLE_SHIN_MAX_DEG);
244+
foot.plantigrade
245+
&& !pushOffRoll
246+
&& (shinDeg === null || shinDeg <= FLAT_SOLE_SHIN_MAX_DEG);
231247
if (expectedFlat) {
248+
state.minToeHeightMeters = Math.min(state.minToeHeightMeters ?? Infinity, foot.toeHeight);
249+
state.maxToeHeightMeters = Math.max(state.maxToeHeightMeters ?? -Infinity, foot.toeHeight);
250+
if (Math.abs(foot.toeHeight) > state.worstToeAbs) {
251+
state.worstToeAbs = Math.abs(foot.toeHeight);
252+
state.worstToe = location;
253+
}
232254
state.plantigradeSamples++;
233255
state.minHeelHeightMeters = Math.min(state.minHeelHeightMeters ?? Infinity, foot.heelHeight);
234256
state.maxHeelHeightMeters = Math.max(state.maxHeelHeightMeters ?? -Infinity, foot.heelHeight);

packages/posecode-eval/src/probe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -846,7 +846,7 @@ export function probeMovement(
846846
// Endpoint probes above power semantic movement checks. Separately sample
847847
// the solved clip between endpoints so a heel lift or collision that appears
848848
// only mid-transition cannot hide behind two valid terminal poses.
849-
const diagnosticsCollector = createClipDiagnosticsCollector(diagnosticSampleRateHz);
849+
const diagnosticsCollector = createClipDiagnosticsCollector(diagnosticSampleRateHz, clipHasTravel);
850850
for (let phaseIndex = 0; phaseIndex < tl.segments.length; phaseIndex++) {
851851
const seg = tl.segments[phaseIndex]!;
852852
const steps = Math.max(1, Math.ceil((seg.end - seg.start) * diagnosticSampleRateHz));

0 commit comments

Comments
 (0)