Skip to content

Commit 5aea4e3

Browse files
authored
Merge pull request #1 from posecode-dev/agent/pr124-avatar-hardening
Harden avatar selection and loading for posecode#124
2 parents 34cbfce + 591dfec commit 5aea4e3

34 files changed

Lines changed: 495 additions & 190 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"posecode-parser": minor
3+
"posecode-render": minor
4+
"posecode-embed": minor
5+
---
6+
7+
Add an optional avatar selector separate from humanoid rig topology, safely hot-swap document-selected characters with procedural fallback, and add hosted avatar defaults.
8+
9+
Keep the renderer peer range compatible with the parser's additive language/IR update.

README.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -819,24 +819,24 @@ The hosted playground currently uses an Adobe Mixamo character and one showcase
819819

820820
The renderer also includes a zero-asset procedural figure and accepts compatible humanoid GLB characters through `characterUrl`.
821821

822-
### Multiple rigs (`rig humanoid` / `avatar1` / `avatar2` / `avatar3`)
823-
824-
A `.posecode` document's `rig` directive isn't just `humanoid` `avatar1`,
825-
`avatar2`, and `avatar3` are also valid rig names (see
826-
[`spec/SPEC.md`](spec/SPEC.md)). Pass `characterUrls` (a rig name → GLB URL
827-
map) to `createViewer` instead of a single `characterUrl`, and each loaded
828-
document's `rig` value picks its character automatically — switching
829-
documents, or editing one to declare a different `rig`, swaps the visible
830-
character. A rig with no entry in the map (or any load failure) falls back to
831-
the procedural figure, same as an unset `characterUrl`. See
822+
### Multiple character appearances (`avatar avatar1` / `avatar2` / `avatar3`)
823+
824+
All built-in characters use the same `rig humanoid` skeleton topology. An
825+
optional `avatar` directive selects appearance without redefining that rig (see
826+
[`spec/SPEC.md`](spec/SPEC.md)). Pass `characterUrls` (selector → GLB URL map)
827+
to `createViewer` instead of a single `characterUrl`; `ir.avatar` is used when
828+
present and `ir.rig` supplies the default selector otherwise. Switching
829+
documents, or editing the `avatar` directive, swaps the visible character. A
830+
selector with no entry in the map (or any load failure) falls back to the
831+
procedural figure. See
832832
[`packages/posecode-render/README.md`](packages/posecode-render/README.md#usage)
833833
for the option, and `packages/posecode-embed`'s `character` attribute docs for
834834
the same behavior in the web component (absent by default; set an explicit URL
835-
to pin one character regardless of `rig`).
835+
to pin one character regardless of `avatar`).
836836

837837
### Bringing your own character rig
838838

839-
Pass a `characterUrl` (fixed) or `characterUrls` (per-rig, see above) pointing
839+
Pass a `characterUrl` (fixed) or `characterUrls` (per-selector, see above) pointing
840840
to a skinned GLB to replace the bundled Mixamo character. Requirements:
841841

842842
- **Format:** glTF binary (`.glb`) containing a `THREE.SkinnedMesh`.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ These are the unlocks, roughly in order of leverage:
2828

2929
1. ~~**Hip / waist hinge primitive**~~: **shipped (v0.1).** `pelvis: hinge <deg>`
3030
tips the torso forward over the hips while the legs stay planted (the renderer
31-
counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`,rig
31+
counter-rotates the hips). Powers `deadlift`, `bent-over-row`, `good-morning`,
3232
and `bow`. Next: hinge with a loaded-bar prop.
3333
2. ~~**Reach-IK (reach a world target)**~~: **shipped, now ROM-constrained.**
3434
`reach: <effector> <target>` drives a hand/foot to a body landmark, the

editors/vscode/syntaxes/posecode.tmLanguage.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
},
3030
"keywords": {
3131
"name": "keyword.control.posecode",
32-
"match": "\\b(posecode|rig|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
32+
"match": "\\b(posecode|rig|avatar|prop|pose|start|step|repeat|clip|ground-lock|reach|pin|grip|turn|travel|cue|hold)\\b"
3333
},
3434
"kinds": {
3535
"name": "storage.type.posecode",

package-lock.json

Lines changed: 35 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/posecode-embed/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ definePosecodePlayer(); // idempotent
6868
| `controls` | `true` | Show the play/pause bar. |
6969
| `autorotate` | `true` | Slowly orbit the camera when idle. |
7070
| `speed` | `1` | Playback multiplier (`0.1``4`). |
71-
| `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. |
71+
| `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. |
7272
| `playground` | `https://posecode.org/play` | Base URL for the "Edit ↗" link. |
7373

7474
Boolean attributes accept `false` / `0` / `no` / `off` to turn them off, so

packages/posecode-embed/src/options.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface PlayerOptions {
2020
speed: number;
2121
/**
2222
* Realistic skinned figure pinned to one GLB URL, from an explicit
23-
* `character="<url>"` attribute. `""` when the attribute is absent (rig
23+
* `character="<url>"` attribute. `""` when the attribute is absent (the host
2424
* picks the character from `characterUrls` instead) or the character is
2525
* disabled. Load failures fall back to the procedural figure, so an offline
2626
* page degrades instead of blanking.
@@ -29,20 +29,20 @@ export interface PlayerOptions {
2929
/** True when `character="off"` (or another falsey word) explicitly disables any skinned character. */
3030
characterDisabled: boolean;
3131
/**
32-
* Rig name (the loaded document's `rig` directive) → character GLB URL,
32+
* Document selector (`avatar` when present, otherwise `rig`) → GLB URL,
3333
* applied when `characterUrl` is unset and the character isn't disabled.
34-
* Defaults to the hosted characters for every built-in rig name.
34+
* Defaults to the hosted character choices and the humanoid default.
3535
*/
3636
characterUrls: Record<string, string>;
3737
}
3838

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

42-
/** Hosted character per built-in rig name, keyed by posecode-parser's RigName. */
42+
/** Hosted character per built-in selector. Avatar1 intentionally reuses XBot. */
4343
export const DEFAULT_CHARACTER_URLS: Record<string, string> = {
4444
humanoid: DEFAULT_CHARACTER_URL,
45-
avatar1: "https://posecode.org/models/avatar1.glb",
45+
avatar1: DEFAULT_CHARACTER_URL,
4646
avatar2: "https://posecode.org/models/avatar2.glb",
4747
avatar3: "https://posecode.org/models/avatar3.glb",
4848
};
@@ -86,8 +86,8 @@ function clamp(n: number, lo: number, hi: number): number {
8686
export function parseOptions(attrs: RawAttributes): PlayerOptions {
8787
const speedRaw = attrs.speed != null ? Number(attrs.speed) : NaN;
8888
// `character` accepts a GLB URL (pinned regardless of the document's rig),
89-
// a falsey word to disable any skinned character, or absent to let each
90-
// loaded document's `rig` directive pick from characterUrls.
89+
// a falsey word to disable any skinned character, or absent to let the
90+
// document's optional `avatar` directive pick from characterUrls.
9191
const characterRaw = attrs.character?.trim();
9292
const characterDisabled =
9393
characterRaw !== undefined && characterRaw !== null && FALSEY.has(characterRaw.toLowerCase());

packages/posecode-embed/test/compat.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@ describe("embed compatibility contract", () => {
2828
readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8"),
2929
) as { version: string };
3030
expect(version).toBe(pkg.version);
31-
expect(languageVersion).toBe("0.3");
31+
expect(languageVersion).toBe("0.4");
3232
});
3333
});

packages/posecode-embed/test/options.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ describe("parseOptions", () => {
1010
it("returns sensible defaults for an element with no attributes", () => {
1111
expect(parseOptions({})).toEqual(DEFAULT_OPTIONS);
1212
expect(DEFAULT_CHARACTER_URL).toBe("https://posecode.org/models/xbot.glb");
13-
// No explicit `character` attribute: rig-driven, not pinned to one URL.
13+
// No explicit `character` attribute: document-driven, not pinned to one URL.
1414
expect(DEFAULT_OPTIONS.characterUrl).toBe("");
1515
expect(DEFAULT_OPTIONS.characterDisabled).toBe(false);
1616
expect(DEFAULT_OPTIONS.characterUrls).toBe(DEFAULT_CHARACTER_URLS);
1717
expect(DEFAULT_CHARACTER_URLS.humanoid).toBe(DEFAULT_CHARACTER_URL);
18+
expect(DEFAULT_CHARACTER_URLS.avatar1).toBe(DEFAULT_CHARACTER_URL);
1819
});
1920

20-
it("pins an explicit character URL and disables rig-driven selection", () => {
21+
it("pins an explicit character URL and disables document-driven selection", () => {
2122
const o = parseOptions({ character: "https://example.com/me.glb" });
2223
expect(o.characterUrl).toBe("https://example.com/me.glb");
2324
expect(o.characterDisabled).toBe(false);

packages/posecode-language/src/completion.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import {
99
KINDS,
1010
POSES,
11+
AVATARS,
1112
RIGS,
1213
EFFECTORS,
1314
REACH_EFFECTORS,
@@ -26,6 +27,7 @@ export type CompletionKind =
2627
| "keyword"
2728
| "kind"
2829
| "pose"
30+
| "avatar"
2931
| "rig"
3032
| "easing"
3133
| "joint"
@@ -41,6 +43,7 @@ export interface CompletionItem {
4143
type Context =
4244
| "kind"
4345
| "pose"
46+
| "avatar"
4447
| "rig"
4548
| "easing"
4649
| "effector"
@@ -71,6 +74,7 @@ function contextFor(
7174
const atDocumentIndent =
7275
enclosingBlock === null && indent > 0 && (documentIndent === null || indent === documentIndent);
7376
if (atDocumentIndent && /^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose";
77+
if (atDocumentIndent && /^\s*avatar\s+[\w-]*$/.test(prefix)) return "avatar";
7478
if (atDocumentIndent && /^\s*rig\s+[\w-]*$/.test(prefix)) return "rig";
7579
if (atDocumentIndent && /^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing";
7680
const isActualChild = enclosingBlock !== null && indent > enclosingBlock.indent;
@@ -122,7 +126,7 @@ function documentIndentBefore(lines: readonly string[], line: number): number |
122126
const candidate = lines[i]!;
123127
const trimmed = candidate.trim();
124128
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
125-
if (!/^(?:rig|prop|pose|clip|step|repeat)\b/.test(trimmed)) continue;
129+
if (!/^(?:rig|avatar|prop|pose|clip|step|repeat)\b/.test(trimmed)) continue;
126130
return candidate.length - candidate.trimStart().length;
127131
}
128132
return null;
@@ -149,6 +153,8 @@ export function getCompletions(
149153
return KINDS.map((k) => item(k, "kind"));
150154
case "pose":
151155
return POSES.map((p) => item(p, "pose"));
156+
case "avatar":
157+
return AVATARS.map((avatar) => item(avatar, "avatar"));
152158
case "rig":
153159
return RIGS.map((r) => item(r, "rig"));
154160
case "easing":

0 commit comments

Comments
 (0)