Skip to content
Open
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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,52 @@ The hosted playground currently uses an Adobe Mixamo character and one showcase

The renderer also includes a zero-asset procedural figure and accepts compatible humanoid GLB characters through `characterUrl`.

### Multiple rigs (`rig humanoid` / `avatar1` / `avatar2` / `avatar3`)

A `.posecode` document's `rig` directive isn't just `humanoid` — `avatar1`,
`avatar2`, and `avatar3` are also valid rig names (see
[`spec/SPEC.md`](spec/SPEC.md)). Pass `characterUrls` (a rig name → GLB URL
map) to `createViewer` instead of a single `characterUrl`, and each loaded
document's `rig` value picks its character automatically — switching
documents, or editing one to declare a different `rig`, swaps the visible
character. A rig with no entry in the map (or any load failure) falls back to
the procedural figure, same as an unset `characterUrl`. See
[`packages/posecode-render/README.md`](packages/posecode-render/README.md#usage)
for the option, and `packages/posecode-embed`'s `character` attribute docs for
the same behavior in the web component (absent by default; set an explicit URL
to pin one character regardless of `rig`).

### Bringing your own character rig

Pass a `characterUrl` (fixed) or `characterUrls` (per-rig, see above) pointing
to a skinned GLB to replace the bundled Mixamo character. Requirements:

- **Format:** glTF binary (`.glb`) containing a `THREE.SkinnedMesh`.
- **Rest pose:** T-pose.
- **Bone naming:** Mixamo convention. Names may carry the `mixamorig:` /
`mixamorigN:` namespace prefix — it's stripped automatically. These bones
must all be present:
- Torso/head: `Hips`, `Spine`, `Spine2`, `Neck`, `Head`
- Arms: `LeftArm`, `LeftForeArm`, `LeftHand`, `RightArm`, `RightForeArm`, `RightHand`
- Legs: `LeftUpLeg`, `LeftLeg`, `LeftFoot`, `RightUpLeg`, `RightLeg`, `RightFoot`
- Fingers (first phalanx only): `LeftHandThumb1`, `LeftHandIndex1`,
`LeftHandMiddle1`, `LeftHandRing1`, `LeftHandPinky1`, and the
`RightHand*1` equivalents

If any required bone is missing, loading the character rejects and the
viewer silently falls back to the zero-asset procedural figure — a bad rig
never breaks the scene.

The simplest way to source a compatible rig is [mixamo.com](https://www.mixamo.com):
export a character in T-pose with "skin with skeleton," then convert
FBX → GLB (e.g. with Blender's glTF exporter or `FBX2glTF`). Bone names come
out Mixamo-compatible automatically.

The bone map and retarget/calibration logic live in
[`packages/posecode-render/src/character.ts`](packages/posecode-render/src/character.ts).
Supporting a different naming convention (e.g. VRM humanoid bones) means
editing the `BONE_MAP` table and `plainName()` prefix-stripping there.

---

## Licensing
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ These are the unlocks, roughly in order of leverage:

1. ~~**Hip / waist hinge primitive**~~: **shipped (v0.1).** `pelvis: hinge <deg>`
tips the torso forward over the hips while the legs stay planted (the renderer
counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`,
counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`,rig
and `bow`. Next: hinge with a loaded-bar prop.
2. ~~**Reach-IK (reach a world target)**~~: **shipped, now ROM-constrained.**
`reach: <effector> <target>` drives a hand/foot to a body landmark, the
Expand Down
2 changes: 1 addition & 1 deletion editors/vscode/syntaxes/posecode.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
},
"constants": {
"name": "constant.language.posecode",
"match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid)\\b"
"match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid|avatar1|avatar2|avatar3)\\b"
},
"numbers": {
"name": "constant.numeric.posecode",
Expand Down
68 changes: 34 additions & 34 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/posecode-embed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ definePosecodePlayer(); // idempotent
| `controls` | `true` | Show the play/pause bar. |
| `autorotate` | `true` | Slowly orbit the camera when idle. |
| `speed` | `1` | Playback multiplier (`0.1`–`4`). |
| `character` | *(hosted default)* | Realistic figure: a GLB URL (Mixamo rig), or `off` for the procedural mannequin. Load failures fall back to the mannequin. |
| `character` | *(rig-driven)* | Realistic figure. Absent: the loaded document's `rig` directive (`humanoid`, `avatar1`, `avatar2`, `avatar3`) picks the hosted character. Set to a GLB URL (Mixamo rig) to pin one character regardless of `rig`, or `off` for the procedural mannequin. Load failures fall back to the mannequin. |
| `playground` | `https://posecode.org/play` | Base URL for the "Edit ↗" link. |

Boolean attributes accept `false` / `0` / `no` / `off` to turn them off, so
Expand Down
6 changes: 5 additions & 1 deletion packages/posecode-embed/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,11 @@ export class PosecodePlayerElement extends HTMLElement {
const { createViewer } = await import("posecode-render");
const viewer = createViewer(this.#canvas, {
autoRotate: opts.autoRotate && !reduceMotion,
...(opts.characterUrl ? { characterUrl: opts.characterUrl } : {}),
...(opts.characterDisabled
? {}
: opts.characterUrl
? { characterUrl: opts.characterUrl }
: { characterUrls: opts.characterUrls }),
});
this.#viewer = viewer;
viewer.onPhase(({ phaseName }) => {
Expand Down
45 changes: 32 additions & 13 deletions packages/posecode-embed/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,43 @@ export interface PlayerOptions {
/** Playback speed multiplier (0.1–4). */
speed: number;
/**
* Realistic skinned figure: a GLB URL, the default hosted character when
* absent, or `""` (attribute `character="off"`) for the procedural figure.
* Load failures fall back to the procedural figure, so an offline page
* degrades instead of blanking.
* Realistic skinned figure pinned to one GLB URL, from an explicit
* `character="<url>"` attribute. `""` when the attribute is absent (rig
* picks the character from `characterUrls` instead) or the character is
* disabled. Load failures fall back to the procedural figure, so an offline
* page degrades instead of blanking.
*/
characterUrl: string;
/** True when `character="off"` (or another falsey word) explicitly disables any skinned character. */
characterDisabled: boolean;
/**
* Rig name (the loaded document's `rig` directive) → character GLB URL,
* applied when `characterUrl` is unset and the character isn't disabled.
* Defaults to the hosted characters for every built-in rig name.
*/
characterUrls: Record<string, string>;
}

/** The character the hosted playground uses, served from the same origin. */
export const DEFAULT_CHARACTER_URL = "https://posecode.org/models/xbot.glb";

/** Hosted character per built-in rig name, keyed by posecode-parser's RigName. */
export const DEFAULT_CHARACTER_URLS: Record<string, string> = {
humanoid: DEFAULT_CHARACTER_URL,
avatar1: "https://posecode.org/models/avatar1.glb",
avatar2: "https://posecode.org/models/avatar2.glb",
avatar3: "https://posecode.org/models/avatar3.glb",
};

export const DEFAULT_OPTIONS: PlayerOptions = {
autoplay: true,
loop: true,
controls: true,
autoRotate: true,
speed: 1,
characterUrl: DEFAULT_CHARACTER_URL,
characterUrl: "",
characterDisabled: false,
characterUrls: DEFAULT_CHARACTER_URLS,
};

const SPEED_MIN = 0.1;
Expand Down Expand Up @@ -66,15 +85,13 @@ function clamp(n: number, lo: number, hi: number): number {

export function parseOptions(attrs: RawAttributes): PlayerOptions {
const speedRaw = attrs.speed != null ? Number(attrs.speed) : NaN;
// `character` accepts a GLB URL, a falsey word to opt out, or absent for
// the hosted default.
// `character` accepts a GLB URL (pinned regardless of the document's rig),
// a falsey word to disable any skinned character, or absent to let each
// loaded document's `rig` directive pick from characterUrls.
const characterRaw = attrs.character?.trim();
const characterUrl =
characterRaw === undefined || characterRaw === null
? DEFAULT_OPTIONS.characterUrl
: FALSEY.has(characterRaw.toLowerCase())
? ""
: characterRaw;
const characterDisabled =
characterRaw !== undefined && characterRaw !== null && FALSEY.has(characterRaw.toLowerCase());
const characterUrl = characterRaw && !characterDisabled ? characterRaw : "";
return {
autoplay: boolAttr(attrs.autoplay, DEFAULT_OPTIONS.autoplay),
loop: boolAttr(attrs.loop, DEFAULT_OPTIONS.loop),
Expand All @@ -84,5 +101,7 @@ export function parseOptions(attrs: RawAttributes): PlayerOptions {
? clamp(speedRaw, SPEED_MIN, SPEED_MAX)
: DEFAULT_OPTIONS.speed,
characterUrl,
characterDisabled,
characterUrls: DEFAULT_OPTIONS.characterUrls,
};
}
24 changes: 23 additions & 1 deletion packages/posecode-embed/test/options.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { describe, it, expect } from "vitest";
import { parseOptions, DEFAULT_CHARACTER_URL, DEFAULT_OPTIONS } from "../src/options.js";
import {
parseOptions,
DEFAULT_CHARACTER_URL,
DEFAULT_CHARACTER_URLS,
DEFAULT_OPTIONS,
} from "../src/options.js";

describe("parseOptions", () => {
it("returns sensible defaults for an element with no attributes", () => {
expect(parseOptions({})).toEqual(DEFAULT_OPTIONS);
expect(DEFAULT_CHARACTER_URL).toBe("https://posecode.org/models/xbot.glb");
// No explicit `character` attribute: rig-driven, not pinned to one URL.
expect(DEFAULT_OPTIONS.characterUrl).toBe("");
expect(DEFAULT_OPTIONS.characterDisabled).toBe(false);
expect(DEFAULT_OPTIONS.characterUrls).toBe(DEFAULT_CHARACTER_URLS);
expect(DEFAULT_CHARACTER_URLS.humanoid).toBe(DEFAULT_CHARACTER_URL);
});

it("pins an explicit character URL and disables rig-driven selection", () => {
const o = parseOptions({ character: "https://example.com/me.glb" });
expect(o.characterUrl).toBe("https://example.com/me.glb");
expect(o.characterDisabled).toBe(false);
});

it("disables the character entirely on a falsey word", () => {
const o = parseOptions({ character: "off" });
expect(o.characterUrl).toBe("");
expect(o.characterDisabled).toBe(true);
});

it("treats boolean attributes as present-means-true", () => {
Expand Down
Loading