Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/harden-avatar-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"posecode-parser": minor
"posecode-render": minor
"posecode-embed": minor
---

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

Keep the renderer peer range compatible with the parser's additive language/IR update.
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ These are the unlocks, roughly in order of leverage:

1. ~~**Hip / waist hinge primitive**~~: **shipped (v0.1).** `pelvis: hinge <deg>`
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: <effector> <target>` drives a hand/foot to a body landmark, the
Expand Down
2 changes: 1 addition & 1 deletion editors/vscode/syntaxes/posecode.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"keywords": {
"name": "keyword.control.posecode",
"match": "\\b(posecode|rig|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
"match": "\\b(posecode|rig|avatar|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
},
"kinds": {
"name": "storage.type.posecode",
Expand Down
70 changes: 35 additions & 35 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/posecode-embed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ definePosecodePlayer(); // idempotent
| `controls` | `true` | Show the play/pause bar. |
| `autorotate` | `true` | Slowly orbit the camera when idle. |
| `speed` | `1` | Playback multiplier (`0.1`–`4`). |
| `character` | *(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
Expand Down
14 changes: 7 additions & 7 deletions packages/posecode-embed/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface PlayerOptions {
speed: number;
/**
* Realistic skinned figure pinned to one GLB URL, from an explicit
* `character="<url>"` attribute. `""` when the attribute is absent (rig
* `character="<url>"` attribute. `""` when the attribute is absent (the host
* picks the character from `characterUrls` instead) or the character is
* disabled. Load failures fall back to the procedural figure, so an offline
* page degrades instead of blanking.
Expand All @@ -29,20 +29,20 @@ 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<string, string>;
}

/** The character the hosted playground uses, served from the same origin. */
export const DEFAULT_CHARACTER_URL = "https://posecode.org/models/xbot.glb";

/** Hosted character per built-in rig name, keyed by posecode-parser's RigName. */
/** Hosted character per built-in selector. Avatar1 intentionally reuses XBot. */
export const DEFAULT_CHARACTER_URLS: Record<string, string> = {
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",
};
Expand Down Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion packages/posecode-embed/test/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ describe("embed compatibility contract", () => {
readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8"),
) as { version: string };
expect(version).toBe(pkg.version);
expect(languageVersion).toBe("0.3");
expect(languageVersion).toBe("0.4");
});
});
5 changes: 3 additions & 2 deletions packages/posecode-embed/test/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion packages/posecode-language/src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import {
KINDS,
POSES,
AVATARS,
RIGS,
EFFECTORS,
REACH_EFFECTORS,
Expand All @@ -26,6 +27,7 @@ export type CompletionKind =
| "keyword"
| "kind"
| "pose"
| "avatar"
| "rig"
| "easing"
| "joint"
Expand All @@ -41,6 +43,7 @@ export interface CompletionItem {
type Context =
| "kind"
| "pose"
| "avatar"
| "rig"
| "easing"
| "effector"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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":
Expand Down
9 changes: 7 additions & 2 deletions packages/posecode-language/src/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MOVEMENT_KINDS,
START_POSE_NAMES,
PROP_TYPES,
AVATAR_NAMES,
RIG_NAMES,
actionsForJoint,
} from "posecode-parser";
Expand All @@ -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];

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

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

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

/** Short docs surfaced on hover and as completion detail. */
export const KEYWORD_DOCS: Record<string, string> = {
posecode: 'Document header: `posecode <kind> "<name>"`.',
rig: "Selects the rig: `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 = <pose>` or the custom form `pose start = <pose>:` followed by joint overrides.",
Expand Down
Loading