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 4eb621a..f4cd48a 100644 --- a/README.md +++ b/README.md @@ -819,24 +819,24 @@ The hosted playground currently uses an Adobe Mixamo character and one showcase The renderer also includes a zero-asset procedural figure and accepts compatible humanoid GLB characters through `characterUrl`. -### Multiple rigs (`rig humanoid` / `avatar1` / `avatar2` / `avatar3`) - -A `.posecode` document's `rig` directive isn't just `humanoid` — `avatar1`, -`avatar2`, and `avatar3` are also valid rig names (see -[`spec/SPEC.md`](spec/SPEC.md)). Pass `characterUrls` (a rig name → GLB URL -map) to `createViewer` instead of a single `characterUrl`, and each loaded -document's `rig` value picks its character automatically — switching -documents, or editing one to declare a different `rig`, swaps the visible -character. A rig with no entry in the map (or any load failure) falls back to -the procedural figure, same as an unset `characterUrl`. See +### 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 `rig`). +to pin one character regardless of `avatar`). ### Bringing your own character rig -Pass a `characterUrl` (fixed) or `characterUrls` (per-rig, see above) pointing +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`. diff --git a/ROADMAP.md b/ROADMAP.md index 03df965..a14a5c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ These are the unlocks, roughly in order of leverage: 1. ~~**Hip / waist hinge primitive**~~: **shipped (v0.1).** `pelvis: hinge ` tips the torso forward over the hips while the legs stay planted (the renderer - counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`,rig + counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`, and `bow`. Next: hinge with a loaded-bar prop. 2. ~~**Reach-IK (reach a world target)**~~: **shipped, now ROM-constrained.** `reach: ` drives a hand/foot to a body landmark, the diff --git a/editors/vscode/syntaxes/posecode.tmLanguage.json b/editors/vscode/syntaxes/posecode.tmLanguage.json index ed9e976..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", diff --git a/package-lock.json b/package-lock.json index fd14006..8d8eab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1282,12 +1282,12 @@ } }, "node_modules/@hono/node-server": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", - "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18.14.1" }, "peerDependencies": { "hono": "^4" @@ -1521,12 +1521,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -3867,15 +3867,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -4730,9 +4730,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -5036,9 +5036,9 @@ } }, "node_modules/hono": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", - "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "version": "4.12.30", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", + "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5181,9 +5181,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -5402,9 +5402,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -6070,9 +6070,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -6487,9 +6487,9 @@ "link": true }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -6507,7 +6507,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6796,9 +6796,9 @@ } }, "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -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 1034490..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` | *(rig-driven)* | Realistic figure. Absent: the loaded document's `rig` directive (`humanoid`, `avatar1`, `avatar2`, `avatar3`) picks the hosted character. Set to a GLB URL (Mixamo rig) to pin one character regardless of `rig`, or `off` for the procedural mannequin. Load failures fall back to the mannequin. | +| `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/options.ts b/packages/posecode-embed/src/options.ts index 702e364..e8329c7 100644 --- a/packages/posecode-embed/src/options.ts +++ b/packages/posecode-embed/src/options.ts @@ -20,7 +20,7 @@ export interface PlayerOptions { speed: number; /** * Realistic skinned figure pinned to one GLB URL, from an explicit - * `character=""` attribute. `""` when the attribute is absent (rig + * `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. @@ -29,9 +29,9 @@ export interface PlayerOptions { /** True when `character="off"` (or another falsey word) explicitly disables any skinned character. */ characterDisabled: boolean; /** - * Rig name (the loaded document's `rig` directive) → character GLB URL, + * Document selector (`avatar` when present, otherwise `rig`) → GLB URL, * applied when `characterUrl` is unset and the character isn't disabled. - * Defaults to the hosted characters for every built-in rig name. + * Defaults to the hosted character choices and the humanoid default. */ characterUrls: Record; } @@ -39,10 +39,10 @@ export interface PlayerOptions { /** The character the hosted playground uses, served from the same origin. */ export const DEFAULT_CHARACTER_URL = "https://posecode.org/models/xbot.glb"; -/** Hosted character per built-in rig name, keyed by posecode-parser's RigName. */ +/** Hosted character per built-in selector. Avatar1 intentionally reuses XBot. */ export const DEFAULT_CHARACTER_URLS: Record = { humanoid: DEFAULT_CHARACTER_URL, - avatar1: "https://posecode.org/models/avatar1.glb", + avatar1: DEFAULT_CHARACTER_URL, avatar2: "https://posecode.org/models/avatar2.glb", avatar3: "https://posecode.org/models/avatar3.glb", }; @@ -86,8 +86,8 @@ 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 (pinned regardless of the document's rig), - // a falsey word to disable any skinned character, or absent to let each - // loaded document's `rig` directive pick from characterUrls. + // 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 characterDisabled = characterRaw !== undefined && characterRaw !== null && FALSEY.has(characterRaw.toLowerCase()); 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 957d415..a6885c2 100644 --- a/packages/posecode-embed/test/options.test.ts +++ b/packages/posecode-embed/test/options.test.ts @@ -10,14 +10,15 @@ describe("parseOptions", () => { it("returns sensible defaults for an element with no attributes", () => { expect(parseOptions({})).toEqual(DEFAULT_OPTIONS); expect(DEFAULT_CHARACTER_URL).toBe("https://posecode.org/models/xbot.glb"); - // No explicit `character` attribute: rig-driven, not pinned to one URL. + // 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 rig-driven selection", () => { + 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); diff --git a/packages/posecode-language/src/completion.ts b/packages/posecode-language/src/completion.ts index 9599d92..5ef5433 100644 --- a/packages/posecode-language/src/completion.ts +++ b/packages/posecode-language/src/completion.ts @@ -8,6 +8,7 @@ import { KINDS, POSES, + AVATARS, RIGS, EFFECTORS, REACH_EFFECTORS, @@ -26,6 +27,7 @@ export type CompletionKind = | "keyword" | "kind" | "pose" + | "avatar" | "rig" | "easing" | "joint" @@ -41,6 +43,7 @@ export interface CompletionItem { type Context = | "kind" | "pose" + | "avatar" | "rig" | "easing" | "effector" @@ -71,6 +74,7 @@ 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; @@ -122,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; @@ -149,6 +153,8 @@ 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": diff --git a/packages/posecode-language/src/vocab.ts b/packages/posecode-language/src/vocab.ts index 2076c04..766a1b1 100644 --- a/packages/posecode-language/src/vocab.ts +++ b/packages/posecode-language/src/vocab.ts @@ -17,6 +17,7 @@ import { MOVEMENT_KINDS, START_POSE_NAMES, PROP_TYPES, + AVATAR_NAMES, RIG_NAMES, actionsForJoint, } from "posecode-parser"; @@ -39,6 +40,9 @@ 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]; @@ -49,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"]; @@ -57,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: `humanoid` | `avatar1` | `avatar2` | `avatar3`.", + 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 7277d19..b396829 100644 --- a/packages/posecode-language/test/language.test.ts +++ b/packages/posecode-language/test/language.test.ts @@ -107,8 +107,12 @@ describe("getCompletions", () => { }); it("suggests rig names after `rig `", () => { - expect(onLine(" rig ", 6)).toEqual( - expect.arrayContaining(["humanoid", "avatar1", "avatar2", "avatar3"]), + expect(onLine(" rig ", 6)).toEqual(["humanoid"]); + }); + + it("suggests character appearances after `avatar `", () => { + expect(onLine(" avatar ", 9)).toEqual( + expect.arrayContaining(["avatar1", "avatar2", "avatar3"]), ); }); diff --git a/packages/posecode-lsp/src/convert.ts b/packages/posecode-lsp/src/convert.ts index d6741d2..b68ad83 100644 --- a/packages/posecode-lsp/src/convert.ts +++ b/packages/posecode-lsp/src/convert.ts @@ -42,6 +42,7 @@ 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, 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 3b67d86..7dcafd4 100644 --- a/packages/posecode-parser/src/protocol.ts +++ b/packages/posecode-parser/src/protocol.ts @@ -3,9 +3,13 @@ export const MOVEMENT_KINDS = ["exercise", "stretch", "posture"] as const; export type MovementKind = (typeof MOVEMENT_KINDS)[number]; -export const RIG_NAMES = ["humanoid", "avatar1", "avatar2", "avatar3"] as const; +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 9165064..bf50603 100644 --- a/packages/posecode-parser/test/parse.test.ts +++ b/packages/posecode-parser/test/parse.test.ts @@ -33,23 +33,35 @@ describe("parse", () => { expect(ir!.phases).toHaveLength(2); }); - it.each(["humanoid", "avatar1", "avatar2", "avatar3"])( - "accepts rig %s", - (rig) => { + it.each(["avatar1", "avatar2", "avatar3"])( + "accepts avatar %s independently of the humanoid rig", + (avatar) => { const { ir, errors } = parse( [ 'posecode exercise "X"', - ` rig ${rig}`, + " rig humanoid", + ` avatar ${avatar}`, " pose start = standing", ' step "Raise" 1s flow:', " shoulders: abduct 45", ].join("\n"), ); expect(errors).toEqual([]); - expect(ir!.rig).toBe(rig); + 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 f696e74..b1a31ac 100644 --- a/packages/posecode-render/README.md +++ b/packages/posecode-render/README.md @@ -32,14 +32,13 @@ const viewer = createViewer(canvas, { // 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 the character from each loaded - // document's `rig` directive instead of pinning one. `rig avatar1` in a - // .posecode document swaps to this URL on load(); a rig absent from the map - // (or any load failure) falls back to the procedural figure. Ignored when - // characterUrl is set. + // 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/avatar1.glb", + // avatar1: "https://posecode.org/models/xbot.glb", // avatar2: "https://posecode.org/models/avatar2.glb", // avatar3: "https://posecode.org/models/avatar3.glb", // }, 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 a060d7c..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, @@ -147,17 +148,16 @@ export interface ViewerOptions { * 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 `rig` value, so callers that only ever want one + * a loaded document's `avatar` value, so callers that only ever want one * character can ignore `characterUrls` entirely. */ characterUrl?: string; /** - * Rig name (a document's `rig` directive, e.g. `"humanoid"`, `"avatar1"`) → - * character GLB URL. When `characterUrl` is unset, `load(ir)` looks up - * `ir.rig` here and swaps to the matching character, so different documents - * (or the same document edited to declare a different `rig`) can show - * different characters. A rig absent from this map — or any load failure — - * falls back to the procedural figure, same as an unset `characterUrl`. + * 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>; /** @@ -297,15 +297,13 @@ export function createViewer( enableShadows(mannequin.root); scene.add(mannequin.root); - // When a skinned character is requested (fixed, or rig-driven via - // characterUrls) 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, or if the - // first loaded document's `rig` has no entry in characterUrls. + // 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.characterUrls) && opts.showProceduralWhileLoading === false; + Boolean(opts.characterUrl) && opts.showProceduralWhileLoading === false; if (deferProceduralMeshes) setMeshVisibility(mannequin.root, false); // Skinned character layer (optional). While loading (and on failure) the @@ -314,68 +312,51 @@ export function createViewer( // (they keep feeding the bounding-box grounding), and the character mirrors it // every frame. let character: Character | null = null; - // The GLB URL currently active or in flight, so a resolved/failed load that - // has since been superseded by a newer request is ignored, and so repeated - // `load()` calls naming the same rig don't re-fetch anything. - let activeCharacterUrl: string | null = null; - /** - * Load a character GLB and, once ready, swap it in for whatever is showing - * (procedural figure or a previous character): rebuild the driver skeleton - * to the new proportions, drop the old visuals and clip layer (which is - * retargeted onto a specific skinned mesh and can't carry over), and - * re-solve the last loaded document against the new rig. A load failure, or - * a newer swap/revert superseding this one before it resolves, leaves - * whatever was already showing in place — the scene never blanks. - */ - function swapCharacter(url: string): void { - activeCharacterUrl = url; - void loadCharacter(url) - .then((char) => { - if (activeCharacterUrl !== url) return; - 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); - }) - .catch((error: unknown) => { - if (activeCharacterUrl !== url) return; - console.warn("Posecode character load failed; using procedural fallback", error); - if (deferProceduralMeshes) setMeshVisibility(mannequin.root, true); - }); + /** 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 { - activeCharacterUrl = null; - if (!character) return; - scene.remove(character.group); - character.dispose(); - character = null; - scene.remove(mannequin.root); - disposeTree(mannequin.root); - mannequin = buildMannequin(); - enableShadows(mannequin.root); + 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); - scene.add(mannequin.root); eyes = ["eye_left", "eye_right"] .map((n) => mannequin.root.getObjectByName(n)) .filter((o): o is THREE.Object3D => Boolean(o)); @@ -383,18 +364,31 @@ export function createViewer( 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's `rig` should show and + * 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 `rig`), and when the resolved URL already - * matches what's active or in flight. + * always wins over any document's `avatar`). */ function requestCharacter(ir: PosecodeIR): void { if (opts.characterUrl) return; - const url = opts.characterUrls?.[ir.rig] ?? null; - if (url === activeCharacterUrl) return; - if (url) swapCharacter(url); - else revertToProcedural(); + 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 @@ -1429,6 +1423,7 @@ export function createViewer( }, dispose() { cancelAnimationFrame(raf); + characterLoader.dispose(); controls.dispose(); clipLayer?.dispose(); character?.dispose(); @@ -1441,11 +1436,9 @@ export function createViewer( }, }; - // Kick off the fixed character load, if the caller pinned one. (Without a - // fixed `characterUrl`, `load(ir)` above resolves the character from - // `characterUrls` per-document via `requestCharacter`.) `swapCharacter` - // handles the success/failure paths identically: the scene never blanks. - if (opts.characterUrl) swapCharacter(opts.characterUrl); + // 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/play.html b/playground/play.html index 1371496..12bb37c 100644 --- a/playground/play.html +++ b/playground/play.html @@ -53,7 +53,7 @@ rel="stylesheet" /> - + @@ -371,6 +371,6 @@

Movement library

- + 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/avatar1.glb b/playground/public/models/avatar1.glb deleted file mode 100644 index 7025913..0000000 Binary files a/playground/public/models/avatar1.glb and /dev/null differ diff --git a/playground/public/models/avatar3.glb b/playground/public/models/avatar3.glb index 99e6392..b8ce2e5 100644 Binary files a/playground/public/models/avatar3.glb 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 7740ca2..e3a19fb 100644
--- a/playground/src/editor.ts
+++ b/playground/src/editor.ts
@@ -55,6 +55,7 @@ import {
 } from "posecode-language";
 import {
   ACTION_NAMES,
+  AVATAR_NAMES,
   EFFECTOR_NAMES,
   GROUND_LOCK_EFFECTOR_NAMES,
   JOINT_NAMES,
@@ -78,6 +79,7 @@ import {
 const KEYWORDS = new Set([
   "posecode",
   "rig",
+  "avatar",
   "prop",
   "pose",
   "start",
@@ -104,6 +106,7 @@ const ATOMS = new Set([
   ...PROP_TYPES,
   ...EFFECTOR_NAMES,
   ...GROUND_LOCK_EFFECTOR_NAMES,
+  ...AVATAR_NAMES,
   ...RIG_NAMES,
 ]);
 const JOINTS = new Set(JOINT_NAMES);
@@ -175,6 +178,7 @@ const CM_TYPE: Record = {
   keyword: "keyword",
   kind: "type",
   pose: "constant",
+  avatar: "constant",
   rig: "constant",
   easing: "constant",
   joint: "variable",
diff --git a/playground/src/main.ts b/playground/src/main.ts
index eaab743..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, RIG_NAMES, type ParseError, type RigName, 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,
@@ -47,13 +47,15 @@ type InteractiveViewer = Viewer & {
 const DEFAULT_PRESET =
   PRESETS.find((p) => p.id === "superhero-landing") ?? PRESETS[0]!;
 
-// Skinned character per `rig` directive: a loaded document's `rig humanoid` /
-// `rig avatar1` / ... picks its GLB here (see requestCharacter in
-// posecode-render). Keep every RIG_NAMES entry mapped so no rig silently
-// falls back to the procedural figure.
-const CHARACTER_URLS: Record = Object.fromEntries(
-  RIG_NAMES.map((name) => [name, name === "humanoid" ? "/models/xbot.glb" : `/models/${name}.glb`]),
-) as Record;
+// 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";
 
@@ -1075,9 +1077,8 @@ void import("posecode-render").then(({ createViewer }) => {
     ...(classicFigure
       ? {}
       : {
-          // Rig-driven: each loaded document's `rig` directive picks its
-          // character from CHARACTER_URLS (see requestCharacter in
-          // posecode-render's Viewer).
+          // 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.
diff --git a/spec/SPEC.md b/spec/SPEC.md
index 8be9e12..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 ;
-rig        = "rig" ("humanoid"|"avatar1"|"avatar2"|"avatar3") ;
+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" | "avatar1" | "avatar2" | "avatar3"
+  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 2ae5764..825eb8e 100644
--- a/spec/llm-authoring.md
+++ b/spec/llm-authoring.md
@@ -159,7 +159,8 @@ Do not infer new words from anatomy or English. Use only these canonical names.
 ### Document and timing words
 
 - Kinds: `exercise | stretch | posture`
-- Rig: `humanoid | avatar1 | avatar2 | avatar3`
+- 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