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/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 2d81085..8d8eab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8349,7 +8349,7 @@ "@types/three": "^0.185.1" }, "peerDependencies": { - "posecode-parser": ">=0.2.2 <0.5.0" + "posecode-parser": ">=0.2.2 <0.6.0" } }, "packages/posecode-share": { 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 8d2bb77..966d870 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.1" 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