Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .changeset/harden-avatar-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"posecode-parser": minor
"posecode-render": minor
"posecode-embed": minor
---

Add an optional avatar selector separate from humanoid rig topology, safely hot-swap document-selected characters with procedural fallback, and add hosted avatar defaults.

Keep the renderer peer range compatible with the parser's additive language/IR update.
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 character appearances (`avatar avatar1` / `avatar2` / `avatar3`)

All built-in characters use the same `rig humanoid` skeleton topology. An
optional `avatar` directive selects appearance without redefining that rig (see
[`spec/SPEC.md`](spec/SPEC.md)). Pass `characterUrls` (selector → GLB URL map)
to `createViewer` instead of a single `characterUrl`; `ir.avatar` is used when
present and `ir.rig` supplies the default selector otherwise. Switching
documents, or editing the `avatar` directive, swaps the visible character. A
selector with no entry in the map (or any load failure) falls back to the
procedural figure. 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 `avatar`).

### Bringing your own character rig

Pass a `characterUrl` (fixed) or `characterUrls` (per-selector, 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
4 changes: 2 additions & 2 deletions editors/vscode/syntaxes/posecode.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"keywords": {
"name": "keyword.control.posecode",
"match": "\\b(posecode|rig|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
"match": "\\b(posecode|rig|avatar|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
},
"kinds": {
"name": "storage.type.posecode",
Expand All @@ -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
2 changes: 1 addition & 1 deletion 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` | *(document-driven)* | Realistic figure. Absent: optional `avatar avatar1|avatar2|avatar3` selects a hosted appearance; documents without it use the humanoid XBot default. Set to a GLB URL to pin one character regardless of `avatar`, 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 (the host
* 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;
/**
* Document selector (`avatar` when present, otherwise `rig`) → GLB URL,
* applied when `characterUrl` is unset and the character isn't disabled.
* Defaults to the hosted character choices and the humanoid default.
*/
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 selector. Avatar1 intentionally reuses XBot. */
export const DEFAULT_CHARACTER_URLS: Record<string, string> = {
humanoid: DEFAULT_CHARACTER_URL,
avatar1: DEFAULT_CHARACTER_URL,
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 the
// document's optional `avatar` 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,
};
}
2 changes: 1 addition & 1 deletion packages/posecode-embed/test/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ describe("embed compatibility contract", () => {
readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8"),
) as { version: string };
expect(version).toBe(pkg.version);
expect(languageVersion).toBe("0.3");
expect(languageVersion).toBe("0.4");
});
});
25 changes: 24 additions & 1 deletion packages/posecode-embed/test/options.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
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: document-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);
expect(DEFAULT_CHARACTER_URLS.avatar1).toBe(DEFAULT_CHARACTER_URL);
});

it("pins an explicit character URL and disables document-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
14 changes: 13 additions & 1 deletion packages/posecode-language/src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import {
KINDS,
POSES,
AVATARS,
RIGS,
EFFECTORS,
REACH_EFFECTORS,
PIN_EFFECTORS,
Expand All @@ -25,6 +27,8 @@ export type CompletionKind =
| "keyword"
| "kind"
| "pose"
| "avatar"
| "rig"
| "easing"
| "joint"
| "action"
Expand All @@ -39,6 +43,8 @@ export interface CompletionItem {
type Context =
| "kind"
| "pose"
| "avatar"
| "rig"
| "easing"
| "effector"
| "reach-effector"
Expand Down Expand Up @@ -68,6 +74,8 @@ function contextFor(
const atDocumentIndent =
enclosingBlock === null && indent > 0 && (documentIndent === null || indent === documentIndent);
if (atDocumentIndent && /^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose";
if (atDocumentIndent && /^\s*avatar\s+[\w-]*$/.test(prefix)) return "avatar";
if (atDocumentIndent && /^\s*rig\s+[\w-]*$/.test(prefix)) return "rig";
if (atDocumentIndent && /^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing";
const isActualChild = enclosingBlock !== null && indent > enclosingBlock.indent;
if (isActualChild && enclosingBlock.kind === "start-pose") {
Expand Down Expand Up @@ -118,7 +126,7 @@ function documentIndentBefore(lines: readonly string[], line: number): number |
const candidate = lines[i]!;
const trimmed = candidate.trim();
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
if (!/^(?:rig|prop|pose|clip|step|repeat)\b/.test(trimmed)) continue;
if (!/^(?:rig|avatar|prop|pose|clip|step|repeat)\b/.test(trimmed)) continue;
return candidate.length - candidate.trimStart().length;
}
return null;
Expand All @@ -145,6 +153,10 @@ export function getCompletions(
return KINDS.map((k) => item(k, "kind"));
case "pose":
return POSES.map((p) => item(p, "pose"));
case "avatar":
return AVATARS.map((avatar) => item(avatar, "avatar"));
case "rig":
return RIGS.map((r) => item(r, "rig"));
case "easing":
return MODES.map((e) => item(e, "easing"));
case "effector":
Expand Down
13 changes: 11 additions & 2 deletions packages/posecode-language/src/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
MOVEMENT_KINDS,
START_POSE_NAMES,
PROP_TYPES,
AVATAR_NAMES,
RIG_NAMES,
actionsForJoint,
} from "posecode-parser";

Expand All @@ -35,6 +37,12 @@ export const KINDS: string[] = [...MOVEMENT_KINDS];
/** Recognised start poses (`pose start = ...`). */
export const POSES: string[] = [...START_POSE_NAMES];

/** Recognised rigs (`rig ...`). */
export const RIGS: string[] = [...RIG_NAMES];

/** Recognised character appearances (`avatar ...`). */
export const AVATARS: string[] = [...AVATAR_NAMES];

/** Floor contacts that can be ground-locked. */
export const EFFECTORS = [...GROUND_LOCK_EFFECTOR_NAMES];

Expand All @@ -45,15 +53,16 @@ export const GRIP_EFFECTORS = [...GRIP_EFFECTOR_NAMES];
export const PROPS: string[] = [...PROP_TYPES];

/** Top-level directives (excluding the `posecode` header keyword). */
export const TOP_KEYWORDS = ["rig", "prop", "pose", "clip", "step", "repeat"];
export const TOP_KEYWORDS = ["rig", "avatar", "prop", "pose", "clip", "step", "repeat"];

/** Keywords valid as step children. */
export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "grip", "turn", "travel", "cue"];

/** Short docs surfaced on hover and as completion detail. */
export const KEYWORD_DOCS: Record<string, string> = {
posecode: 'Document header: `posecode <kind> "<name>"`.',
rig: "Selects the rig (currently `humanoid`).",
rig: "Selects the skeleton topology (currently `humanoid`).",
avatar: "Selects the optional character appearance: `avatar1` | `avatar2` | `avatar3`.",
prop: "Adds a scene object: `prop chair | wall | bar | box | dip-bars`. Supplies declared reach, pin, and grip anchors.",
pose: "Sets the starting pose. Add a trailing `:` and indented joint targets to sparsely override a built-in pose.",
start: "Used in `pose start = <pose>` or the custom form `pose start = <pose>:` followed by joint overrides.",
Expand Down
10 changes: 10 additions & 0 deletions packages/posecode-language/test/language.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ describe("getCompletions", () => {
expect(onLine(" pose start = ", 15)).toContain("standing");
});

it("suggests rig names after `rig `", () => {
expect(onLine(" rig ", 6)).toEqual(["humanoid"]);
});

it("suggests character appearances after `avatar `", () => {
expect(onLine(" avatar ", 9)).toEqual(
expect.arrayContaining(["avatar1", "avatar2", "avatar3"]),
);
});

it("offers only joint targets inside a scoped start-pose override", () => {
const text = [
'posecode posture "Custom"',
Expand Down
2 changes: 2 additions & 0 deletions packages/posecode-lsp/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const KIND_MAP: Record<CompletionKind, CompletionItemKind> = {
keyword: CompletionItemKind.Keyword,
kind: CompletionItemKind.TypeParameter,
pose: CompletionItemKind.Constant,
avatar: CompletionItemKind.Constant,
rig: CompletionItemKind.Constant,
easing: CompletionItemKind.Constant,
joint: CompletionItemKind.Variable,
action: CompletionItemKind.Function,
Expand Down
1 change: 1 addition & 0 deletions packages/posecode-parser/src/clamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function resolve(ast: AstDoc): ResolveResult {
kind: ast.kind,
name: ast.name,
rig: ast.rig,
...(ast.avatar ? { avatar: ast.avatar } : {}),
...(ast.startPose ? { startPose: ast.startPose } : {}),
...(startOverridePhase.targets.length > 0
? { startPoseOverrides: startOverridePhase.targets }
Expand Down
3 changes: 3 additions & 0 deletions packages/posecode-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,20 @@ export {
export { EASINGS, MODES, LEGACY_MODE_ALIASES, normalizeMode } from "./schema.js";
export {
MOVEMENT_KINDS,
AVATAR_NAMES,
RIG_NAMES,
START_POSE_NAMES,
PROP_TYPES,
PROP_ANCHORS,
isMovementKind,
isAvatarName,
isRigName,
isStartPoseName,
isPropType,
propForAnchor,
anchorsForProps,
type MovementKind,
type AvatarName,
type RigName,
type StartPoseName,
type PropType,
Expand Down
Loading