diff --git a/.changeset/harden-avatar-selection.md b/.changeset/harden-avatar-selection.md new file mode 100644 index 0000000..c5cae8d --- /dev/null +++ b/.changeset/harden-avatar-selection.md @@ -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. diff --git a/README.md b/README.md index 0411a09..f4cd48a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/legal/LEGAL_REVIEW.md b/docs/legal/LEGAL_REVIEW.md index 44a9d6a..4ad5c32 100644 --- a/docs/legal/LEGAL_REVIEW.md +++ b/docs/legal/LEGAL_REVIEW.md @@ -23,6 +23,6 @@ This is an engineering audit checklist, not legal advice. Resolve these points w ## Third-party materials -- Recheck the current Adobe Mixamo terms for redistribution of `xbot.glb` and `jumping-jacks.fbx`, especially for npm, source archives, hosted demos, white-label offerings, and customer redistribution. +- Recheck the current Adobe Mixamo terms for redistribution of `xbot.glb`, `avatar2.glb`, `avatar3.glb`, and `jumping-jacks.fbx`, especially for npm, source archives, hosted demos, white-label offerings, and customer redistribution. - Replace the remaining Mixamo-derived files with owned or clearly redistributable assets if the intended distribution is not covered. - Run a dependency and source-provenance audit for every release artifact, including bundled JavaScript, model files, fonts, images, and generated content. Preserve all required notices. diff --git a/docs/legal/THIRD_PARTY_NOTICES.md b/docs/legal/THIRD_PARTY_NOTICES.md index 6846ccb..a338b93 100644 --- a/docs/legal/THIRD_PARTY_NOTICES.md +++ b/docs/legal/THIRD_PARTY_NOTICES.md @@ -7,8 +7,14 @@ Posecode npm packages use dependencies under permissive licenses including MIT, The following committed files originate from Adobe Mixamo character or animation exports and are governed by the applicable Adobe terms, not Apache-2.0 or AGPL-3.0: - `playground/public/models/xbot.glb` +- `playground/public/models/avatar2.glb` (Mixamo "Beta" character) +- `playground/public/models/avatar3.glb` (Mixamo "Remy" character) - `playground/public/clips/jumping-jacks.fbx` +Provenance for the two avatar characters is established by their glTF contents: +both carry the `mixamorig:` skeleton, a `mixamo.com` animation track, and Mixamo +mesh and texture names (`Beta_Joints`/`Beta_Surface` and `Remy_Body_*`). + The software licenses in this repository do not grant independent rights to extract, redistribute, or relicense these assets. Review the current Adobe Mixamo terms before redistribution. Posecode also provides a code-generated procedural figure that does not require these assets. `playground/public/models/character.glb`, an older unused Mixamo-derived character, was removed before the 0.3.0 release. The hosted embed default now uses the same active XBot asset as the playground. diff --git a/editors/vscode/syntaxes/posecode.tmLanguage.json b/editors/vscode/syntaxes/posecode.tmLanguage.json index f997fa8..77b958b 100644 --- a/editors/vscode/syntaxes/posecode.tmLanguage.json +++ b/editors/vscode/syntaxes/posecode.tmLanguage.json @@ -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", @@ -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", diff --git a/package-lock.json b/package-lock.json index 270eb64..1bf225a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8112,12 +8112,12 @@ } }, "packages/posecode-embed": { - "version": "0.4.2", + "version": "0.4.3", "license": "AGPL-3.0-only", "dependencies": { - "posecode-parser": "0.4.2", - "posecode-render": "0.4.2", - "posecode-share": "0.4.2" + "posecode-parser": "0.4.3", + "posecode-render": "0.4.3", + "posecode-share": "0.4.3" }, "devDependencies": { "esbuild": "^0.28.2" @@ -8611,8 +8611,8 @@ "version": "0.2.2", "license": "AGPL-3.0-only", "dependencies": { - "posecode-parser": "0.4.2", - "posecode-render": "0.4.2", + "posecode-parser": "0.4.3", + "posecode-render": "0.4.3", "three": "^0.185.1" }, "devDependencies": { @@ -8624,7 +8624,7 @@ "version": "0.2.2", "license": "Apache-2.0", "dependencies": { - "posecode-parser": "0.4.2" + "posecode-parser": "0.4.3" } }, "packages/posecode-lsp": { @@ -9132,12 +9132,12 @@ "license": "MIT" }, "packages/posecode-mcp": { - "version": "0.4.2", + "version": "0.4.3", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", - "posecode-parser": "0.4.2", - "posecode-share": "0.4.2", + "posecode-parser": "0.4.3", + "posecode-share": "0.4.3", "zod": "^4.4.3" }, "bin": { @@ -9634,7 +9634,7 @@ } }, "packages/posecode-parser": { - "version": "0.4.2", + "version": "0.4.3", "license": "Apache-2.0", "dependencies": { "zod": "^4.4.3" @@ -9647,7 +9647,7 @@ } }, "packages/posecode-render": { - "version": "0.4.2", + "version": "0.4.3", "license": "AGPL-3.0-only", "dependencies": { "three": "^0.185.1" @@ -9656,11 +9656,11 @@ "@types/three": "^0.185.4" }, "peerDependencies": { - "posecode-parser": ">=0.2.2 <0.5.0" + "posecode-parser": ">=0.2.2 <0.6.0" } }, "packages/posecode-share": { - "version": "0.4.2", + "version": "0.4.3", "license": "Apache-2.0" }, "playground": { @@ -9677,9 +9677,9 @@ "@lezer/highlight": "^1.2.1", "@vercel/analytics": "^2.0.1", "posecode-language": "0.2.2", - "posecode-parser": "0.4.2", - "posecode-render": "0.4.2", - "posecode-share": "0.4.2", + "posecode-parser": "0.4.3", + "posecode-render": "0.4.3", + "posecode-share": "0.4.3", "three": "^0.185.1" }, "devDependencies": { diff --git a/packages/posecode-embed/README.md b/packages/posecode-embed/README.md index 2c181dd..f026018 100644 --- a/packages/posecode-embed/README.md +++ b/packages/posecode-embed/README.md @@ -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 diff --git a/packages/posecode-embed/src/element.ts b/packages/posecode-embed/src/element.ts index c9797a3..27a8d9c 100644 --- a/packages/posecode-embed/src/element.ts +++ b/packages/posecode-embed/src/element.ts @@ -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 }) => { diff --git a/packages/posecode-embed/src/options.ts b/packages/posecode-embed/src/options.ts index 39fe0a6..e8329c7 100644 --- a/packages/posecode-embed/src/options.ts +++ b/packages/posecode-embed/src/options.ts @@ -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=""` 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; } /** 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 = { + 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; @@ -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), @@ -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, }; } diff --git a/packages/posecode-embed/test/compat.test.ts b/packages/posecode-embed/test/compat.test.ts index 82198c9..2781935 100644 --- a/packages/posecode-embed/test/compat.test.ts +++ b/packages/posecode-embed/test/compat.test.ts @@ -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"); }); }); diff --git a/packages/posecode-embed/test/options.test.ts b/packages/posecode-embed/test/options.test.ts index 52d6343..a6885c2 100644 --- a/packages/posecode-embed/test/options.test.ts +++ b/packages/posecode-embed/test/options.test.ts @@ -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", () => { diff --git a/packages/posecode-language/src/completion.ts b/packages/posecode-language/src/completion.ts index 5c96d77..5ef5433 100644 --- a/packages/posecode-language/src/completion.ts +++ b/packages/posecode-language/src/completion.ts @@ -8,6 +8,8 @@ import { KINDS, POSES, + AVATARS, + RIGS, EFFECTORS, REACH_EFFECTORS, PIN_EFFECTORS, @@ -25,6 +27,8 @@ export type CompletionKind = | "keyword" | "kind" | "pose" + | "avatar" + | "rig" | "easing" | "joint" | "action" @@ -39,6 +43,8 @@ export interface CompletionItem { type Context = | "kind" | "pose" + | "avatar" + | "rig" | "easing" | "effector" | "reach-effector" @@ -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") { @@ -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; @@ -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": diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts index 4742f81..766a1b1 100644 --- a/packages/posecode-language/src/vocab.ts +++ b/packages/posecode-language/src/vocab.ts @@ -17,6 +17,8 @@ import { MOVEMENT_KINDS, START_POSE_NAMES, PROP_TYPES, + AVATAR_NAMES, + RIG_NAMES, actionsForJoint, } from "posecode-parser"; @@ -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]; @@ -45,7 +53,7 @@ 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"]; @@ -53,7 +61,8 @@ export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "grip", "turn", "t /** Short docs surfaced on hover and as completion detail. */ export const KEYWORD_DOCS: Record = { posecode: 'Document header: `posecode ""`.', - 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 = ` or the custom form `pose start = :` followed by joint overrides.", diff --git a/packages/posecode-language/test/language.test.ts b/packages/posecode-language/test/language.test.ts index c14717b..b396829 100644 --- a/packages/posecode-language/test/language.test.ts +++ b/packages/posecode-language/test/language.test.ts @@ -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"', diff --git a/packages/posecode-lsp/src/convert.ts b/packages/posecode-lsp/src/convert.ts index 4136cf2..b68ad83 100644 --- a/packages/posecode-lsp/src/convert.ts +++ b/packages/posecode-lsp/src/convert.ts @@ -42,6 +42,8 @@ const KIND_MAP: Record = { keyword: CompletionItemKind.Keyword, kind: CompletionItemKind.TypeParameter, pose: CompletionItemKind.Constant, + avatar: CompletionItemKind.Constant, + rig: CompletionItemKind.Constant, easing: CompletionItemKind.Constant, joint: CompletionItemKind.Variable, action: CompletionItemKind.Function, diff --git a/packages/posecode-parser/src/clamp.ts b/packages/posecode-parser/src/clamp.ts index 4519fb8..6c2021e 100644 --- a/packages/posecode-parser/src/clamp.ts +++ b/packages/posecode-parser/src/clamp.ts @@ -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 } diff --git a/packages/posecode-parser/src/index.ts b/packages/posecode-parser/src/index.ts index 85bf857..399f9ff 100644 --- a/packages/posecode-parser/src/index.ts +++ b/packages/posecode-parser/src/index.ts @@ -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, diff --git a/packages/posecode-parser/src/parser.ts b/packages/posecode-parser/src/parser.ts index fdab0e9..34b04b1 100644 --- a/packages/posecode-parser/src/parser.ts +++ b/packages/posecode-parser/src/parser.ts @@ -12,17 +12,19 @@ import { normalizeMode, MODES } from "./schema.js"; import { GROUND_LOCK_EFFECTOR_NAMES } from "./joints.js"; import { MOVEMENT_KINDS, + AVATAR_NAMES, PROP_TYPES, RIG_NAMES, START_POSE_NAMES, isMovementKind, + isAvatarName, isPropType, isRigName, isStartPoseName, } from "./protocol.js"; const GROUND_LOCK_EFFECTORS = new Set(GROUND_LOCK_EFFECTOR_NAMES); -const TOP_LEVEL_HEADS = new Set(["rig", "prop", "clip", "pose", "repeat", "step"]); +const TOP_LEVEL_HEADS = new Set(["rig", "avatar", "prop", "clip", "pose", "repeat", "step"]); export interface AstJointTarget { joint: string; @@ -66,6 +68,8 @@ export interface AstDoc { kind: string; name: string; rig: string; + /** Optional hosted-character appearance, independent of the skeleton rig. */ + avatar?: string; startPose?: string; /** Sparse joint targets layered over the selected built-in start pose. */ startPoseOverrides: AstJointTarget[]; @@ -240,6 +244,20 @@ export function parseToAst(source: string): ParseAstResult { else doc.rig = r; break; } + case "avatar": { + const avatar = word(t[1]); + if (t.length !== 2 || !avatar) { + errors.push({ line: ln.line, message: "expected `avatar `" }); + } else if (!isAvatarName(avatar)) { + errors.push({ + line: ln.line, + message: `unknown avatar "${avatar}"; expected one of ${AVATAR_NAMES.join(", ")}`, + }); + } else { + doc.avatar = avatar; + } + break; + } case "prop": { // `prop `: a built-in scene object, repeatable. const p = word(t[1]); diff --git a/packages/posecode-parser/src/protocol.ts b/packages/posecode-parser/src/protocol.ts index 9044866..7dcafd4 100644 --- a/packages/posecode-parser/src/protocol.ts +++ b/packages/posecode-parser/src/protocol.ts @@ -6,6 +6,10 @@ export type MovementKind = (typeof MOVEMENT_KINDS)[number]; export const RIG_NAMES = ["humanoid"] as const; export type RigName = (typeof RIG_NAMES)[number]; +/** Hosted-character choices are appearance, not skeleton topology. */ +export const AVATAR_NAMES = ["avatar1", "avatar2", "avatar3"] as const; +export type AvatarName = (typeof AVATAR_NAMES)[number]; + export const START_POSE_NAMES = [ "neutral", "standing", @@ -37,6 +41,10 @@ export function isRigName(value: string): value is RigName { return (RIG_NAMES as readonly string[]).includes(value); } +export function isAvatarName(value: string): value is AvatarName { + return (AVATAR_NAMES as readonly string[]).includes(value); +} + export function isStartPoseName(value: string): value is StartPoseName { return (START_POSE_NAMES as readonly string[]).includes(value); } diff --git a/packages/posecode-parser/src/schema.ts b/packages/posecode-parser/src/schema.ts index 1d31215..507f15b 100644 --- a/packages/posecode-parser/src/schema.ts +++ b/packages/posecode-parser/src/schema.ts @@ -12,6 +12,7 @@ import type { ParseError, TimingMode } from "./types.js"; import type { AstDoc } from "./parser.js"; import { MOVEMENT_KINDS, + AVATAR_NAMES, PROP_TYPES, RIG_NAMES, START_POSE_NAMES, @@ -80,6 +81,7 @@ const docSchema = z.object({ kind: z.enum(MOVEMENT_KINDS), name: z.string().min(1), rig: z.enum(RIG_NAMES), + avatar: z.enum(AVATAR_NAMES).optional(), startPose: z.enum(START_POSE_NAMES).optional(), startPoseOverrides: z.array(jointTargetSchema), props: z.array(z.enum(PROP_TYPES)), diff --git a/packages/posecode-parser/src/types.ts b/packages/posecode-parser/src/types.ts index 947e1fc..7f33a92 100644 --- a/packages/posecode-parser/src/types.ts +++ b/packages/posecode-parser/src/types.ts @@ -8,7 +8,7 @@ */ /** Version of the parsed Posecode language/IR contract. */ -export const POSECODE_VERSION = "0.3"; +export const POSECODE_VERSION = "0.4"; export type Axis = "x" | "y" | "z"; @@ -103,6 +103,8 @@ export interface PosecodeIR { kind: string; name: string; rig: string; + /** Optional character appearance, independent of the skeleton topology. */ + avatar?: string; startPose?: string; /** Sparse, ROM-clamped joint channels layered over the built-in start pose. */ startPoseOverrides?: JointTarget[]; diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts index 5055b35..bf50603 100644 --- a/packages/posecode-parser/test/parse.test.ts +++ b/packages/posecode-parser/test/parse.test.ts @@ -33,6 +33,35 @@ describe("parse", () => { expect(ir!.phases).toHaveLength(2); }); + it.each(["avatar1", "avatar2", "avatar3"])( + "accepts avatar %s independently of the humanoid rig", + (avatar) => { + const { ir, errors } = parse( + [ + 'posecode exercise "X"', + " rig humanoid", + ` avatar ${avatar}`, + " pose start = standing", + ' step "Raise" 1s flow:', + " shoulders: abduct 45", + ].join("\n"), + ); + expect(errors).toEqual([]); + expect(ir!.rig).toBe("humanoid"); + expect(ir!.avatar).toBe(avatar); + }, + ); + + it("rejects an avatar name in the rig directive", () => { + const { errors } = parse([ + 'posecode posture "Wrong selector"', + " rig avatar2", + ' step "Hold" 1s linear:', + " spine: hold neutral", + ].join("\n")); + expect(errors[0]?.message).toContain('unknown rig "avatar2"'); + }); + it("expands symmetric joints and resolves rotation axes", () => { const { ir } = parse(PUSHUP); const lower = ir!.phases[0]!; diff --git a/packages/posecode-render/README.md b/packages/posecode-render/README.md index 35e04b5..b1a31ac 100644 --- a/packages/posecode-render/README.md +++ b/packages/posecode-render/README.md @@ -29,9 +29,19 @@ const viewer = createViewer(canvas, { // Metric grid, load origin, live +Z facing arrow, and authored travel path. // Enabled by default; disable it for a clean presentation-only embed. floorGuide: true, - // Optional: realistic skinned character (Mixamo bone naming). Omit for the - // zero-asset procedural figure. + // Optional: realistic skinned character (Mixamo bone naming). Omit both + // characterUrl and characterUrls for the zero-asset procedural figure. characterUrl: "https://posecode.org/models/xbot.glb", + // Alternative to characterUrl: pick from the optional `avatar` directive. + // Documents without one use the `humanoid` entry. A selector absent from the + // map (or any load failure) falls back to the procedural figure. Ignored + // when characterUrl is set. + // characterUrls: { + // humanoid: "https://posecode.org/models/xbot.glb", + // avatar1: "https://posecode.org/models/xbot.glb", + // avatar2: "https://posecode.org/models/avatar2.glb", + // avatar3: "https://posecode.org/models/avatar3.glb", + // }, }); const { ir } = parse(myPosecodeSource); diff --git a/packages/posecode-render/package.json b/packages/posecode-render/package.json index a6ca005..71b28e0 100644 --- a/packages/posecode-render/package.json +++ b/packages/posecode-render/package.json @@ -17,7 +17,7 @@ "three": "^0.185.1" }, "peerDependencies": { - "posecode-parser": ">=0.2.2 <0.5.0" + "posecode-parser": ">=0.2.2 <0.6.0" }, "devDependencies": { "@types/three": "^0.185.4" diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index 3600ce6..f81e6c7 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -38,6 +38,7 @@ import { type ClipLayer, type ClipSource, } from "./clips.js"; +import { createLatestResourceLoader } from "./latest-resource-loader.js"; import { depenetrate } from "./depenetrate.js"; import { measureConstraintDiagnostics, @@ -145,8 +146,20 @@ export interface ViewerOptions { * it fails — the viewer shows the procedural figure, so a missing or slow * asset can never blank the scene. All solving still runs on the driver * skeleton, rebuilt to the character's exact proportions (see character.ts). + * + * Fixed for the viewer's lifetime: it wins over `characterUrls` regardless of + * a loaded document's `avatar` value, so callers that only ever want one + * character can ignore `characterUrls` entirely. */ characterUrl?: string; + /** + * Character selector → GLB URL. `load(ir)` uses `ir.avatar` when present and + * otherwise falls back to `ir.rig`, so hosts can map `humanoid` to their + * default character while `avatar avatar2` selects a different appearance. + * A selector absent from this map — or any load failure — falls back to the + * procedural figure. + */ + characterUrls?: Partial>; /** * Mocap clip library: clip name (as written in a document's `clip ""` * directive) → FBX/GLB asset URL. When a loaded document names a clip found @@ -284,11 +297,11 @@ export function createViewer( enableShadows(mannequin.root); scene.add(mannequin.root); - // When a skinned character is requested and the caller opted out of the - // procedural fallback during load, hide the procedural meshes up front so the - // crude figure never flashes for the character's fetch time on a page load. - // The skeleton still drives animation and grounding; only the meshes hide - // (same as the post-load swap). Revealed again if the character fails to load. + // A fixed character URL is known immediately, so callers can hide the + // procedural meshes up front to avoid a flash while it loads. A + // document-driven URL is hidden later, in requestCharacter(), once load(ir) + // has actually selected a mapped asset. Either path reveals the fallback if + // loading fails. const deferProceduralMeshes = Boolean(opts.characterUrl) && opts.showProceduralWhileLoading === false; if (deferProceduralMeshes) setMeshVisibility(mannequin.root, false); @@ -300,6 +313,84 @@ export function createViewer( // every frame. let character: Character | null = null; + /** Install a newly loaded character and re-solve the current document. */ + function installCharacter(char: Character): void { + scene.remove(mannequin.root); + disposeTree(mannequin.root); + mannequin = buildMannequin(undefined, char.proportions); + setMeshVisibility(mannequin.root, false); + scene.add(mannequin.root); + if (character) { + scene.remove(character.group); + character.dispose(); + } + scene.add(char.group); + character = char; + clipLayer?.dispose(); + clipLayer = null; + clipLayerName = null; + clipWeight = 0; + clipTargetWeight = 0; + // The life layer's mesh handles died with the old procedural figure. + eyes = []; + ribcage = undefined; + ribcageRestScale = null; + if (lastIR) api.load(lastIR); + else char.sync(mannequin); + } + + /** Drop the active character (if any) and go back to the procedural figure. */ + function revertToProcedural(): void { + clipLayer?.dispose(); + clipLayer = null; + clipLayerName = null; + clipWeight = 0; + clipTargetWeight = 0; + if (character) { + scene.remove(character.group); + character.dispose(); + character = null; + scene.remove(mannequin.root); + disposeTree(mannequin.root); + mannequin = buildMannequin(); + enableShadows(mannequin.root); + scene.add(mannequin.root); + } + setMeshVisibility(mannequin.root, true); + eyes = ["eye_left", "eye_right"] + .map((n) => mannequin.root.getObjectByName(n)) + .filter((o): o is THREE.Object3D => Boolean(o)); + ribcage = mannequin.root.getObjectByName("ribcage"); + ribcageRestScale = ribcage ? ribcage.scale.clone() : null; + } + + const characterLoader = createLatestResourceLoader({ + load: loadCharacter, + activate: installCharacter, + fallback: revertToProcedural, + onError(error) { + console.warn("Posecode character load failed; using procedural fallback", error); + }, + }); + + /** + * Resolve which character (if any) this document should show and + * switch to it. No-ops when the caller pinned a fixed `characterUrl` (that + * always wins over any document's `avatar`). + */ + function requestCharacter(ir: PosecodeIR): void { + if (opts.characterUrl) return; + const selector = ir.avatar ?? ir.rig; + const url = opts.characterUrls?.[selector] ?? null; + // Unlike a fixed URL, a document-driven URL is unknown until `load(ir)`. + // Hide only once that request actually starts, so a viewer that has not + // loaded a document can never sit blank indefinitely. + if (url && !character && opts.showProceduralWhileLoading === false) { + setMeshVisibility(mannequin.root, false); + } + characterLoader.request(url); + } + // Mocap-clip layer (optional, character-only). When the loaded document // names a clip present in opts.clips, the asset is fetched once, retargeted // onto the character skeleton, and crossfaded over the procedural pose. The @@ -1015,6 +1106,7 @@ export function createViewer( const api: Viewer = { load(ir: PosecodeIR) { lastIR = ir; + requestCharacter(ir); timeline = buildTimeline(ir); floorGuideData = buildFloorGuideData(ir, timeline); const pinnedFootSides = new Set(); @@ -1331,6 +1423,7 @@ export function createViewer( }, dispose() { cancelAnimationFrame(raf); + characterLoader.dispose(); controls.dispose(); clipLayer?.dispose(); character?.dispose(); @@ -1343,38 +1436,9 @@ export function createViewer( }, }; - // Kick off the character load (if requested). On success, swap the driver - // skeleton for one congruent with the character, hide the procedural meshes - // (still feeding the bounding-box grounding), and re-solve the current - // document against the new proportions. On failure, the procedural figure - // simply remains: the scene is never blank. - if (opts.characterUrl) { - void loadCharacter(opts.characterUrl) - .then((char) => { - scene.remove(mannequin.root); - disposeTree(mannequin.root); - mannequin = buildMannequin(undefined, char.proportions); - setMeshVisibility(mannequin.root, false); - scene.add(mannequin.root); - scene.add(char.group); - character = char; - // The life layer's mesh handles died with the procedural figure. - eyes = []; - ribcage = undefined; - ribcageRestScale = null; - if (lastIR) api.load(lastIR); - else char.sync(mannequin); - }) - .catch((error: unknown) => { - // Character failed (offline embed, blocked/404 CDN): reveal the - // procedural figure we may have hidden, so the scene degrades to the - // working fallback instead of staying blank. Keep a developer-facing - // diagnostic because malformed or incompatible rigs otherwise look - // exactly like a network fallback and are impossible to calibrate. - console.warn("Posecode character load failed; using procedural fallback", error); - if (deferProceduralMeshes) setMeshVisibility(mannequin.root, true); - }); - } + // Kick off a fixed character load when the caller pinned one. Otherwise + // `load(ir)` resolves from `characterUrls` using the document selector. + if (opts.characterUrl) characterLoader.request(opts.characterUrl); return api; } diff --git a/packages/posecode-render/src/latest-resource-loader.ts b/packages/posecode-render/src/latest-resource-loader.ts new file mode 100644 index 0000000..51ba254 --- /dev/null +++ b/packages/posecode-render/src/latest-resource-loader.ts @@ -0,0 +1,67 @@ +/** Keep only the newest asynchronous resource request alive. */ +export function createLatestResourceLoader(options: { + load(url: string): Promise; + activate(resource: T): void; + fallback(): void; + onError?(error: unknown): void; +}): { + request(url: string | null): void; + dispose(): void; +} { + let generation = 0; + let requestedUrl: string | null = null; + let activeUrl: string | null = null; + let disposed = false; + + return { + request(url) { + if (disposed) return; + + if (!url) { + generation++; + requestedUrl = null; + activeUrl = null; + options.fallback(); + return; + } + + // Returning to the resource already on screen cancels a newer in-flight + // request without refetching the resource that is still valid. + if (url === activeUrl) { + if (requestedUrl !== null) generation++; + requestedUrl = null; + return; + } + if (url === requestedUrl) return; + + const token = ++generation; + requestedUrl = url; + void options.load(url).then( + (resource) => { + // A generation token (not only URL equality) handles A → B → A: + // the first A must be disposed instead of winning the final request. + if (disposed || token !== generation) { + resource.dispose(); + return; + } + requestedUrl = null; + activeUrl = url; + options.activate(resource); + }, + (error: unknown) => { + if (disposed || token !== generation) return; + requestedUrl = null; + activeUrl = null; + options.fallback(); + options.onError?.(error); + }, + ); + }, + dispose() { + disposed = true; + generation++; + requestedUrl = null; + activeUrl = null; + }, + }; +} diff --git a/packages/posecode-render/test/latest-resource-loader.test.ts b/packages/posecode-render/test/latest-resource-loader.test.ts new file mode 100644 index 0000000..9fbd567 --- /dev/null +++ b/packages/posecode-render/test/latest-resource-loader.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLatestResourceLoader } from "../src/latest-resource-loader.js"; + +function deferred(): { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +class Resource { + disposed = false; + constructor(readonly name: string) {} + dispose(): void { + this.disposed = true; + } +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("createLatestResourceLoader", () => { + it("always reveals the fallback for an unmapped request", () => { + const fallback = vi.fn(); + const loader = createLatestResourceLoader({ + load: async () => new Resource("unused"), + activate: vi.fn(), + fallback, + }); + + loader.request(null); + + expect(fallback).toHaveBeenCalledOnce(); + }); + + it("disposes a superseded load and activates only the newest resource", async () => { + const a = deferred(); + const b = deferred(); + const activate = vi.fn(); + const loader = createLatestResourceLoader({ + load: (url) => (url === "a" ? a.promise : b.promise), + activate, + fallback: vi.fn(), + }); + const stale = new Resource("stale-a"); + const newest = new Resource("b"); + + loader.request("a"); + loader.request("b"); + a.resolve(stale); + b.resolve(newest); + await flush(); + + expect(stale.disposed).toBe(true); + expect(activate).toHaveBeenCalledOnce(); + expect(activate).toHaveBeenCalledWith(newest); + }); + + it("uses generations rather than URL equality for A to B to A", async () => { + const firstA = deferred(); + const b = deferred(); + const finalA = deferred(); + const requests = [firstA, b, finalA]; + const activate = vi.fn(); + const loader = createLatestResourceLoader({ + load: () => requests.shift()!.promise, + activate, + fallback: vi.fn(), + }); + const stale = new Resource("first-a"); + const newest = new Resource("final-a"); + + loader.request("a"); + loader.request("b"); + loader.request("a"); + firstA.resolve(stale); + finalA.resolve(newest); + await flush(); + + expect(stale.disposed).toBe(true); + expect(activate).toHaveBeenCalledOnce(); + expect(activate).toHaveBeenCalledWith(newest); + }); + + it("keeps the active resource when a pending replacement is cancelled", async () => { + const firstA = deferred(); + const b = deferred(); + const load = vi.fn((url: string) => (url === "a" ? firstA.promise : b.promise)); + const activate = vi.fn(); + const loader = createLatestResourceLoader({ + load, + activate, + fallback: vi.fn(), + }); + const active = new Resource("active-a"); + const stale = new Resource("stale-b"); + + loader.request("a"); + firstA.resolve(active); + await flush(); + loader.request("b"); + loader.request("a"); + b.resolve(stale); + await flush(); + + expect(load).toHaveBeenCalledTimes(2); + expect(activate).toHaveBeenCalledOnce(); + expect(active.disposed).toBe(false); + expect(stale.disposed).toBe(true); + }); + + it("falls back after failure and allows a later retry", async () => { + const first = deferred(); + const retry = deferred(); + const load = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(retry.promise); + const fallback = vi.fn(); + const onError = vi.fn(); + const loader = createLatestResourceLoader({ + load, + activate: vi.fn(), + fallback, + onError, + }); + + loader.request("broken"); + first.reject(new Error("offline")); + await flush(); + loader.request("broken"); + + expect(fallback).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("disposes a resource that resolves after the loader is disposed", async () => { + const pending = deferred(); + const activate = vi.fn(); + const loader = createLatestResourceLoader({ + load: () => pending.promise, + activate, + fallback: vi.fn(), + }); + const resource = new Resource("late"); + + loader.request("late"); + loader.dispose(); + pending.resolve(resource); + await flush(); + + expect(resource.disposed).toBe(true); + expect(activate).not.toHaveBeenCalled(); + }); +}); diff --git a/playground/public/llm-guide.html b/playground/public/llm-guide.html index 23c4cfe..996f18e 100644 --- a/playground/public/llm-guide.html +++ b/playground/public/llm-guide.html @@ -180,7 +180,7 @@

Contact mechanisms

Closed-vocabulary quick reference

Do not infer new words from anatomy or English. Use only these canonical names.

Document and timing words

-
  • Kinds: exercise | stretch | posture
  • Rig: humanoid
  • Start poses: neutral | standing | first-position | plank | supine | prone | seated
  • Timing modes: flow | settle | drive | snap | linear
  • Props: chair | wall | bar | box | dip-bars
+
  • Kinds: exercise | stretch | posture
  • Rig: humanoid
  • Optional avatar appearance: avatar1 | avatar2 | avatar3
  • Start poses: neutral | standing | first-position | plank | supine | prone | seated
  • Timing modes: flow | settle | drive | snap | linear
  • Props: chair | wall | bar | box | dip-bars

Joint/action compatibility

diff --git a/playground/public/models/avatar2.glb b/playground/public/models/avatar2.glb new file mode 100644 index 0000000..14af548 Binary files /dev/null and b/playground/public/models/avatar2.glb differ diff --git a/playground/public/models/avatar3.glb b/playground/public/models/avatar3.glb new file mode 100644 index 0000000..b8ce2e5 Binary files /dev/null and b/playground/public/models/avatar3.glb differ diff --git a/playground/public/spec.html b/playground/public/spec.html index 4686142..e1ceec6 100644 --- a/playground/public/spec.html +++ b/playground/public/spec.html @@ -119,19 +119,20 @@

Reference

-

Posecode Protocol Specification v0.3

+

Posecode Protocol Specification v0.4

Posecode is a small text language for describing a single person's kinematic movement so it can be rendered as an animated 3D figure in a web browser.

This document is the normative language and IR contract. The LLM authoring guide is an optional, task-oriented aid. It is self-contained, but it must not define syntax or behavior that differs from this specification.

It is to human movement what Mermaid is to diagrams. A human, animation tool, or an LLM writes a compact document; a client-side parser and renderer turn it into a moving mannequin. The source describes semantic movement phases rather than 3D matrices.

-
  • Version keyword: documents declare nothing; this is posecode 0.3.
  • Compatibility: v0.3 parsers continue to accept v0.2 documents and the v0.1 easing aliases.
  • File extension: .posecode
  • Compute model: parsing and all 3D math run on the client (Three.js). Authoring may be manual, tool-driven, or LLM-assisted.
+
  • Version keyword: documents declare nothing; this is posecode 0.4.
  • Compatibility: v0.4 parsers continue to accept v0.3/v0.2 documents and the v0.1 easing aliases.
  • File extension: .posecode
  • Compute model: parsing and all 3D math run on the client (Three.js). Authoring may be manual, tool-driven, or LLM-assisted.

1. Grammar

Posecode is line- and indentation-oriented. Comments start with # or //.

document   = header { directive } ;
 header     = "posecode" kind STRING ;
 kind       = "exercise" | "stretch" | "posture" ;
-directive  = rig | prop | pose | clip | step | repeat ;
+directive  = rig | avatar | prop | pose | clip | step | repeat ;
 rig        = "rig" "humanoid" ;
+avatar     = "avatar" ("avatar1"|"avatar2"|"avatar3") ;
 prop       = "prop" ("chair"|"wall"|"bar"|"box"|"dip-bars") ;
 pose       = "pose" "start" "=" startPose [ ":" { startOverride } ] ;
 startOverride = jointTarget ;                         (* indented; sparse overlay, not a phase *)
@@ -242,10 +243,11 @@ 

5. Rendering model

6. Intermediate Representation (IR)

parse(source) returns { ir, warnings, errors }. The IR is renderer-agnostic; angles are in degrees.

interface PosecodeIR {
-  version: string;          // "0.3"
+  version: string;          // "0.4"
   kind: string;             // "exercise" | "stretch" | "posture"
   name: string;
   rig: string;              // "humanoid"
+  avatar?: string;          // "avatar1" | "avatar2" | "avatar3"
   startPose?: string;       // "plank" | "standing" | ...
   startPoseOverrides?: {    // sparse, ROM-clamped overlay on startPose
     boneId: string;
diff --git a/playground/src/editor.ts b/playground/src/editor.ts
index 9740f3b..e3a19fb 100644
--- a/playground/src/editor.ts
+++ b/playground/src/editor.ts
@@ -55,12 +55,14 @@ import {
 } from "posecode-language";
 import {
   ACTION_NAMES,
+  AVATAR_NAMES,
   EFFECTOR_NAMES,
   GROUND_LOCK_EFFECTOR_NAMES,
   JOINT_NAMES,
   MODES,
   MOVEMENT_KINDS,
   PROP_TYPES,
+  RIG_NAMES,
   START_POSE_NAMES,
   expandJoint,
 } from "posecode-parser";
@@ -77,6 +79,7 @@ import {
 const KEYWORDS = new Set([
   "posecode",
   "rig",
+  "avatar",
   "prop",
   "pose",
   "start",
@@ -103,7 +106,8 @@ const ATOMS = new Set([
   ...PROP_TYPES,
   ...EFFECTOR_NAMES,
   ...GROUND_LOCK_EFFECTOR_NAMES,
-  "humanoid",
+  ...AVATAR_NAMES,
+  ...RIG_NAMES,
 ]);
 const JOINTS = new Set(JOINT_NAMES);
 
@@ -174,6 +178,8 @@ const CM_TYPE: Record = {
   keyword: "keyword",
   kind: "type",
   pose: "constant",
+  avatar: "constant",
+  rig: "constant",
   easing: "constant",
   joint: "variable",
   action: "function",
diff --git a/playground/src/main.ts b/playground/src/main.ts
index 2189afb..82a48ee 100644
--- a/playground/src/main.ts
+++ b/playground/src/main.ts
@@ -7,7 +7,7 @@
  * the side panel. The same path works for hand-authored and LLM-authored source.
  */
 
-import { parse, type ParseError, type Warning } from "posecode-parser";
+import { parse, type AvatarName, type ParseError, type Warning } from "posecode-parser";
 import type { ConstraintDiagnostic, Viewer } from "posecode-render";
 import {
   trackUsageEvent,
@@ -46,6 +46,16 @@ type InteractiveViewer = Viewer & {
 // Experimental presets should never be the product's first impression.
 const DEFAULT_PRESET =
   PRESETS.find((p) => p.id === "superhero-landing") ?? PRESETS[0]!;
+
+// Character appearance is independent of skeleton topology. Documents without
+// an `avatar` directive use the humanoid default; avatar1 deliberately reuses
+// XBot instead of committing a duplicate binary.
+const CHARACTER_URLS: Record = {
+  humanoid: "/models/xbot.glb",
+  avatar1: "/models/xbot.glb",
+  avatar2: "/models/avatar2.glb",
+  avatar3: "/models/avatar3.glb",
+};
 import { renderWarnings } from "./warnings.js";
 import llmPrompt from "../../spec/llm-authoring.md?raw";
 
@@ -1067,7 +1077,9 @@ void import("posecode-render").then(({ createViewer }) => {
     ...(classicFigure
       ? {}
       : {
-          characterUrl: "/models/xbot.glb",
+          // Document-driven: an optional `avatar` directive picks from this
+          // map; otherwise the humanoid default is used.
+          characterUrls: CHARACTER_URLS,
           // Avoid flashing the procedural/classic figure while the default
           // mannequin asset loads. It still appears if the GLB genuinely fails.
           showProceduralWhileLoading: false,
diff --git a/spec/SPEC.md b/spec/SPEC.md
index 46ab5c1..5bcdeff 100644
--- a/spec/SPEC.md
+++ b/spec/SPEC.md
@@ -1,4 +1,4 @@
-# Posecode Protocol Specification v0.3
+# Posecode Protocol Specification v0.4
 
 Posecode is a small text language for describing a single person's **kinematic
 movement** so it can be rendered as an animated 3D figure in a web browser.
@@ -13,9 +13,9 @@ or an LLM writes a compact document; a client-side parser and renderer turn it
 into a moving mannequin. The source describes semantic movement phases rather
 than 3D matrices.
 
-- **Version keyword:** documents declare nothing; this is `posecode 0.3`.
-- **Compatibility:** v0.3 parsers continue to accept v0.2 documents and the
-  v0.1 easing aliases.
+- **Version keyword:** documents declare nothing; this is `posecode 0.4`.
+- **Compatibility:** v0.4 parsers continue to accept v0.3/v0.2 documents and
+  the v0.1 easing aliases.
 - **File extension:** `.posecode`
 - **Compute model:** parsing and all 3D math run on the client (Three.js).
   Authoring may be manual, tool-driven, or LLM-assisted.
@@ -30,8 +30,9 @@ Posecode is line- and indentation-oriented. Comments start with `#` or `//`.
 document   = header { directive } ;
 header     = "posecode" kind STRING ;
 kind       = "exercise" | "stretch" | "posture" ;
-directive  = rig | prop | pose | clip | step | repeat ;
+directive  = rig | avatar | prop | pose | clip | step | repeat ;
 rig        = "rig" "humanoid" ;
+avatar     = "avatar" ("avatar1"|"avatar2"|"avatar3") ;
 prop       = "prop" ("chair"|"wall"|"bar"|"box"|"dip-bars") ;
 pose       = "pose" "start" "=" startPose [ ":" { startOverride } ] ;
 startOverride = jointTarget ;                         (* indented; sparse overlay, not a phase *)
@@ -327,10 +328,11 @@ angles are in **degrees**.
 
 ```ts
 interface PosecodeIR {
-  version: string;          // "0.3"
+  version: string;          // "0.4"
   kind: string;             // "exercise" | "stretch" | "posture"
   name: string;
   rig: string;              // "humanoid"
+  avatar?: string;          // "avatar1" | "avatar2" | "avatar3"
   startPose?: string;       // "plank" | "standing" | ...
   startPoseOverrides?: {    // sparse, ROM-clamped overlay on startPose
     boneId: string;
diff --git a/spec/llm-authoring.md b/spec/llm-authoring.md
index 3eed263..825eb8e 100644
--- a/spec/llm-authoring.md
+++ b/spec/llm-authoring.md
@@ -160,6 +160,7 @@ Do not infer new words from anatomy or English. Use only these canonical names.
 
 - Kinds: `exercise | stretch | posture`
 - Rig: `humanoid`
+- Optional avatar appearance: `avatar1 | avatar2 | avatar3`
 - Start poses: `neutral | standing | first-position | plank | supine | prone | seated`
 - Timing modes: `flow | settle | drive | snap | linear`
 - Props: `chair | wall | bar | box | dip-bars`
Joint namesAllowed actions
shoulders, shoulder_left, shoulder_rightflex extend abduct adduct rotate-in rotate-out