diff --git a/README.md b/README.md index 0411a09..4eb621a 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 rigs (`rig humanoid` / `avatar1` / `avatar2` / `avatar3`) + +A `.posecode` document's `rig` directive isn't just `humanoid` — `avatar1`, +`avatar2`, and `avatar3` are also valid rig names (see +[`spec/SPEC.md`](spec/SPEC.md)). Pass `characterUrls` (a rig name → GLB URL +map) to `createViewer` instead of a single `characterUrl`, and each loaded +document's `rig` value picks its character automatically — switching +documents, or editing one to declare a different `rig`, swaps the visible +character. A rig with no entry in the map (or any load failure) falls back to +the procedural figure, same as an unset `characterUrl`. See +[`packages/posecode-render/README.md`](packages/posecode-render/README.md#usage) +for the option, and `packages/posecode-embed`'s `character` attribute docs for +the same behavior in the web component (absent by default; set an explicit URL +to pin one character regardless of `rig`). + +### Bringing your own character rig + +Pass a `characterUrl` (fixed) or `characterUrls` (per-rig, see above) pointing +to a skinned GLB to replace the bundled Mixamo character. Requirements: + +- **Format:** glTF binary (`.glb`) containing a `THREE.SkinnedMesh`. +- **Rest pose:** T-pose. +- **Bone naming:** Mixamo convention. Names may carry the `mixamorig:` / + `mixamorigN:` namespace prefix — it's stripped automatically. These bones + must all be present: + - Torso/head: `Hips`, `Spine`, `Spine2`, `Neck`, `Head` + - Arms: `LeftArm`, `LeftForeArm`, `LeftHand`, `RightArm`, `RightForeArm`, `RightHand` + - Legs: `LeftUpLeg`, `LeftLeg`, `LeftFoot`, `RightUpLeg`, `RightLeg`, `RightFoot` + - Fingers (first phalanx only): `LeftHandThumb1`, `LeftHandIndex1`, + `LeftHandMiddle1`, `LeftHandRing1`, `LeftHandPinky1`, and the + `RightHand*1` equivalents + +If any required bone is missing, loading the character rejects and the +viewer silently falls back to the zero-asset procedural figure — a bad rig +never breaks the scene. + +The simplest way to source a compatible rig is [mixamo.com](https://www.mixamo.com): +export a character in T-pose with "skin with skeleton," then convert +FBX → GLB (e.g. with Blender's glTF exporter or `FBX2glTF`). Bone names come +out Mixamo-compatible automatically. + +The bone map and retarget/calibration logic live in +[`packages/posecode-render/src/character.ts`](packages/posecode-render/src/character.ts). +Supporting a different naming convention (e.g. VRM humanoid bones) means +editing the `BONE_MAP` table and `plainName()` prefix-stripping there. + --- ## Licensing diff --git a/ROADMAP.md b/ROADMAP.md index a14a5c1..03df965 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`, + counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`,rig and `bow`. Next: hinge with a loaded-bar prop. 2. ~~**Reach-IK (reach a world target)**~~: **shipped, now ROM-constrained.** `reach: ` 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 f997fa8..ed9e976 100644 --- a/editors/vscode/syntaxes/posecode.tmLanguage.json +++ b/editors/vscode/syntaxes/posecode.tmLanguage.json @@ -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..fd14006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1282,12 +1282,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -1521,12 +1521,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "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.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -4730,9 +4730,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5036,9 +5036,9 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5181,9 +5181,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -5402,9 +5402,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -6070,9 +6070,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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==", "dev": true, "funding": [ { @@ -6487,9 +6487,9 @@ "link": true }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -6507,7 +6507,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "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.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { diff --git a/packages/posecode-embed/README.md b/packages/posecode-embed/README.md index 2c181dd..1034490 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` | *(rig-driven)* | Realistic figure. Absent: the loaded document's `rig` directive (`humanoid`, `avatar1`, `avatar2`, `avatar3`) picks the hosted character. Set to a GLB URL (Mixamo rig) to pin one character regardless of `rig`, or `off` for the procedural mannequin. Load failures fall back to the mannequin. | | `playground` | `https://posecode.org/play` | Base URL for the "Edit ↗" link. | Boolean attributes accept `false` / `0` / `no` / `off` to turn them off, so 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..702e364 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 (rig + * picks the character from `characterUrls` instead) or the character is + * disabled. Load failures fall back to the procedural figure, so an offline + * page degrades instead of blanking. */ characterUrl: string; + /** True when `character="off"` (or another falsey word) explicitly disables any skinned character. */ + characterDisabled: boolean; + /** + * Rig name (the loaded document's `rig` directive) → character GLB URL, + * applied when `characterUrl` is unset and the character isn't disabled. + * Defaults to the hosted characters for every built-in rig name. + */ + characterUrls: Record; } /** The character the hosted playground uses, served from the same origin. */ export const DEFAULT_CHARACTER_URL = "https://posecode.org/models/xbot.glb"; +/** Hosted character per built-in rig name, keyed by posecode-parser's RigName. */ +export const DEFAULT_CHARACTER_URLS: Record = { + humanoid: DEFAULT_CHARACTER_URL, + avatar1: "https://posecode.org/models/avatar1.glb", + avatar2: "https://posecode.org/models/avatar2.glb", + avatar3: "https://posecode.org/models/avatar3.glb", +}; + export const DEFAULT_OPTIONS: PlayerOptions = { autoplay: true, loop: true, controls: true, autoRotate: true, speed: 1, - characterUrl: DEFAULT_CHARACTER_URL, + characterUrl: "", + characterDisabled: false, + characterUrls: DEFAULT_CHARACTER_URLS, }; const SPEED_MIN = 0.1; @@ -66,15 +85,13 @@ function clamp(n: number, lo: number, hi: number): number { export function parseOptions(attrs: RawAttributes): PlayerOptions { const speedRaw = attrs.speed != null ? Number(attrs.speed) : NaN; - // `character` accepts a GLB URL, a falsey word to opt out, or absent for - // the hosted default. + // `character` accepts a GLB URL (pinned regardless of the document's rig), + // a falsey word to disable any skinned character, or absent to let each + // loaded document's `rig` directive pick from characterUrls. const characterRaw = attrs.character?.trim(); - const characterUrl = - characterRaw === undefined || characterRaw === null - ? DEFAULT_OPTIONS.characterUrl - : FALSEY.has(characterRaw.toLowerCase()) - ? "" - : characterRaw; + const characterDisabled = + characterRaw !== undefined && characterRaw !== null && FALSEY.has(characterRaw.toLowerCase()); + const characterUrl = characterRaw && !characterDisabled ? characterRaw : ""; return { autoplay: boolAttr(attrs.autoplay, DEFAULT_OPTIONS.autoplay), loop: boolAttr(attrs.loop, DEFAULT_OPTIONS.loop), @@ -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/options.test.ts b/packages/posecode-embed/test/options.test.ts index 52d6343..957d415 100644 --- a/packages/posecode-embed/test/options.test.ts +++ b/packages/posecode-embed/test/options.test.ts @@ -1,10 +1,32 @@ import { describe, it, expect } from "vitest"; -import { parseOptions, DEFAULT_CHARACTER_URL, DEFAULT_OPTIONS } from "../src/options.js"; +import { + parseOptions, + DEFAULT_CHARACTER_URL, + DEFAULT_CHARACTER_URLS, + DEFAULT_OPTIONS, +} from "../src/options.js"; describe("parseOptions", () => { it("returns sensible defaults for an element with no attributes", () => { expect(parseOptions({})).toEqual(DEFAULT_OPTIONS); expect(DEFAULT_CHARACTER_URL).toBe("https://posecode.org/models/xbot.glb"); + // No explicit `character` attribute: rig-driven, not pinned to one URL. + expect(DEFAULT_OPTIONS.characterUrl).toBe(""); + expect(DEFAULT_OPTIONS.characterDisabled).toBe(false); + expect(DEFAULT_OPTIONS.characterUrls).toBe(DEFAULT_CHARACTER_URLS); + expect(DEFAULT_CHARACTER_URLS.humanoid).toBe(DEFAULT_CHARACTER_URL); + }); + + it("pins an explicit character URL and disables rig-driven selection", () => { + const o = parseOptions({ character: "https://example.com/me.glb" }); + expect(o.characterUrl).toBe("https://example.com/me.glb"); + expect(o.characterDisabled).toBe(false); + }); + + it("disables the character entirely on a falsey word", () => { + const o = parseOptions({ character: "off" }); + expect(o.characterUrl).toBe(""); + expect(o.characterDisabled).toBe(true); }); it("treats boolean attributes as present-means-true", () => { diff --git a/packages/posecode-language/src/completion.ts b/packages/posecode-language/src/completion.ts index 5c96d77..9599d92 100644 --- a/packages/posecode-language/src/completion.ts +++ b/packages/posecode-language/src/completion.ts @@ -8,6 +8,7 @@ import { KINDS, POSES, + RIGS, EFFECTORS, REACH_EFFECTORS, PIN_EFFECTORS, @@ -25,6 +26,7 @@ export type CompletionKind = | "keyword" | "kind" | "pose" + | "rig" | "easing" | "joint" | "action" @@ -39,6 +41,7 @@ export interface CompletionItem { type Context = | "kind" | "pose" + | "rig" | "easing" | "effector" | "reach-effector" @@ -68,6 +71,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*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") { @@ -145,6 +149,8 @@ export function getCompletions( return KINDS.map((k) => item(k, "kind")); case "pose": return POSES.map((p) => item(p, "pose")); + 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..2076c04 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, + RIG_NAMES, actionsForJoint, } from "posecode-parser"; @@ -35,6 +36,9 @@ 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]; + /** Floor contacts that can be ground-locked. */ export const EFFECTORS = [...GROUND_LOCK_EFFECTOR_NAMES]; @@ -53,7 +57,7 @@ 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 rig: `humanoid` | `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..7277d19 100644 --- a/packages/posecode-language/test/language.test.ts +++ b/packages/posecode-language/test/language.test.ts @@ -106,6 +106,12 @@ describe("getCompletions", () => { expect(onLine(" pose start = ", 15)).toContain("standing"); }); + it("suggests rig names after `rig `", () => { + expect(onLine(" rig ", 6)).toEqual( + expect.arrayContaining(["humanoid", "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..d6741d2 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, + rig: CompletionItemKind.Constant, easing: CompletionItemKind.Constant, joint: CompletionItemKind.Variable, action: CompletionItemKind.Function, diff --git a/packages/posecode-parser/src/protocol.ts b/packages/posecode-parser/src/protocol.ts index 9044866..3b67d86 100644 --- a/packages/posecode-parser/src/protocol.ts +++ b/packages/posecode-parser/src/protocol.ts @@ -3,7 +3,7 @@ export const MOVEMENT_KINDS = ["exercise", "stretch", "posture"] as const; export type MovementKind = (typeof MOVEMENT_KINDS)[number]; -export const RIG_NAMES = ["humanoid"] as const; +export const RIG_NAMES = ["humanoid", "avatar1", "avatar2", "avatar3"] as const; export type RigName = (typeof RIG_NAMES)[number]; export const START_POSE_NAMES = [ diff --git a/packages/posecode-parser/test/parse.test.ts b/packages/posecode-parser/test/parse.test.ts index 5055b35..9165064 100644 --- a/packages/posecode-parser/test/parse.test.ts +++ b/packages/posecode-parser/test/parse.test.ts @@ -33,6 +33,23 @@ describe("parse", () => { expect(ir!.phases).toHaveLength(2); }); + it.each(["humanoid", "avatar1", "avatar2", "avatar3"])( + "accepts rig %s", + (rig) => { + const { ir, errors } = parse( + [ + 'posecode exercise "X"', + ` rig ${rig}`, + " pose start = standing", + ' step "Raise" 1s flow:', + " shoulders: abduct 45", + ].join("\n"), + ); + expect(errors).toEqual([]); + expect(ir!.rig).toBe(rig); + }, + ); + 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..f696e74 100644 --- a/packages/posecode-render/README.md +++ b/packages/posecode-render/README.md @@ -29,9 +29,20 @@ 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 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. + // characterUrls: { + // humanoid: "https://posecode.org/models/xbot.glb", + // avatar1: "https://posecode.org/models/avatar1.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/src/index.ts b/packages/posecode-render/src/index.ts index 3600ce6..a060d7c 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -145,8 +145,21 @@ 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 `rig` 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`. + */ + 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,13 +297,15 @@ 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. + // 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. const deferProceduralMeshes = - Boolean(opts.characterUrl) && opts.showProceduralWhileLoading === false; + Boolean(opts.characterUrl ?? opts.characterUrls) && opts.showProceduralWhileLoading === false; if (deferProceduralMeshes) setMeshVisibility(mannequin.root, false); // Skinned character layer (optional). While loading (and on failure) the @@ -299,6 +314,88 @@ 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); + }); + } + + /** 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); + 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)); + ribcage = mannequin.root.getObjectByName("ribcage"); + ribcageRestScale = ribcage ? ribcage.scale.clone() : null; + } + + /** + * Resolve which character (if any) this document's `rig` 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. + */ + 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(); + } // Mocap-clip layer (optional, character-only). When the loaded document // names a clip present in opts.clips, the asset is fetched once, retargeted @@ -1015,6 +1112,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(); @@ -1343,38 +1441,11 @@ 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 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); return api; } diff --git a/playground/play.html b/playground/play.html index 12bb37c..1371496 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/models/avatar1.glb b/playground/public/models/avatar1.glb new file mode 100644 index 0000000..7025913 Binary files /dev/null and b/playground/public/models/avatar1.glb differ 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..99e6392 Binary files /dev/null and b/playground/public/models/avatar3.glb differ diff --git a/playground/src/editor.ts b/playground/src/editor.ts index 9740f3b..7740ca2 100644 --- a/playground/src/editor.ts +++ b/playground/src/editor.ts @@ -61,6 +61,7 @@ import { MODES, MOVEMENT_KINDS, PROP_TYPES, + RIG_NAMES, START_POSE_NAMES, expandJoint, } from "posecode-parser"; @@ -103,7 +104,7 @@ const ATOMS = new Set([ ...PROP_TYPES, ...EFFECTOR_NAMES, ...GROUND_LOCK_EFFECTOR_NAMES, - "humanoid", + ...RIG_NAMES, ]); const JOINTS = new Set(JOINT_NAMES); @@ -174,6 +175,7 @@ const CM_TYPE: Record = { keyword: "keyword", kind: "type", pose: "constant", + rig: "constant", easing: "constant", joint: "variable", action: "function", diff --git a/playground/src/main.ts b/playground/src/main.ts index 2189afb..eaab743 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, RIG_NAMES, type ParseError, type RigName, type Warning } from "posecode-parser"; import type { ConstraintDiagnostic, Viewer } from "posecode-render"; import { trackUsageEvent, @@ -46,6 +46,14 @@ 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]!; + +// 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; import { renderWarnings } from "./warnings.js"; import llmPrompt from "../../spec/llm-authoring.md?raw"; @@ -1067,7 +1075,10 @@ void import("posecode-render").then(({ createViewer }) => { ...(classicFigure ? {} : { - characterUrl: "/models/xbot.glb", + // Rig-driven: each loaded document's `rig` directive picks its + // character from CHARACTER_URLS (see requestCharacter in + // posecode-render's Viewer). + 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..8be9e12 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -31,7 +31,7 @@ document = header { directive } ; header = "posecode" kind STRING ; kind = "exercise" | "stretch" | "posture" ; directive = rig | prop | pose | clip | step | repeat ; -rig = "rig" "humanoid" ; +rig = "rig" ("humanoid"|"avatar1"|"avatar2"|"avatar3") ; prop = "prop" ("chair"|"wall"|"bar"|"box"|"dip-bars") ; pose = "pose" "start" "=" startPose [ ":" { startOverride } ] ; startOverride = jointTarget ; (* indented; sparse overlay, not a phase *) @@ -330,7 +330,7 @@ interface PosecodeIR { version: string; // "0.3" kind: string; // "exercise" | "stretch" | "posture" name: string; - rig: string; // "humanoid" + rig: string; // "humanoid" | "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..2ae5764 100644 --- a/spec/llm-authoring.md +++ b/spec/llm-authoring.md @@ -159,7 +159,7 @@ Do not infer new words from anatomy or English. Use only these canonical names. ### Document and timing words - Kinds: `exercise | stretch | posture` -- Rig: `humanoid` +- Rig: `humanoid | 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`