Skip to content

Commit 80c8e36

Browse files
committed
Merge avatar appearance support from ibrahim edib kökdemir
2 parents f226fe6 + 5aea4e3 commit 80c8e36

33 files changed

Lines changed: 604 additions & 79 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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,52 @@ 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 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
832+
[`packages/posecode-render/README.md`](packages/posecode-render/README.md#usage)
833+
for the option, and `packages/posecode-embed`'s `character` attribute docs for
834+
the same behavior in the web component (absent by default; set an explicit URL
835+
to pin one character regardless of `avatar`).
836+
837+
### Bringing your own character rig
838+
839+
Pass a `characterUrl` (fixed) or `characterUrls` (per-selector, see above) pointing
840+
to a skinned GLB to replace the bundled Mixamo character. Requirements:
841+
842+
- **Format:** glTF binary (`.glb`) containing a `THREE.SkinnedMesh`.
843+
- **Rest pose:** T-pose.
844+
- **Bone naming:** Mixamo convention. Names may carry the `mixamorig:` /
845+
`mixamorigN:` namespace prefix — it's stripped automatically. These bones
846+
must all be present:
847+
- Torso/head: `Hips`, `Spine`, `Spine2`, `Neck`, `Head`
848+
- Arms: `LeftArm`, `LeftForeArm`, `LeftHand`, `RightArm`, `RightForeArm`, `RightHand`
849+
- Legs: `LeftUpLeg`, `LeftLeg`, `LeftFoot`, `RightUpLeg`, `RightLeg`, `RightFoot`
850+
- Fingers (first phalanx only): `LeftHandThumb1`, `LeftHandIndex1`,
851+
`LeftHandMiddle1`, `LeftHandRing1`, `LeftHandPinky1`, and the
852+
`RightHand*1` equivalents
853+
854+
If any required bone is missing, loading the character rejects and the
855+
viewer silently falls back to the zero-asset procedural figure — a bad rig
856+
never breaks the scene.
857+
858+
The simplest way to source a compatible rig is [mixamo.com](https://www.mixamo.com):
859+
export a character in T-pose with "skin with skeleton," then convert
860+
FBX → GLB (e.g. with Blender's glTF exporter or `FBX2glTF`). Bone names come
861+
out Mixamo-compatible automatically.
862+
863+
The bone map and retarget/calibration logic live in
864+
[`packages/posecode-render/src/character.ts`](packages/posecode-render/src/character.ts).
865+
Supporting a different naming convention (e.g. VRM humanoid bones) means
866+
editing the `BONE_MAP` table and `plainName()` prefix-stripping there.
867+
822868
---
823869

824870
## Licensing

editors/vscode/syntaxes/posecode.tmLanguage.json

Lines changed: 2 additions & 2 deletions
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",
@@ -45,7 +45,7 @@
4545
},
4646
"constants": {
4747
"name": "constant.language.posecode",
48-
"match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid)\\b"
48+
"match": "\\b(flow|settle|drive|snap|linear|ease-in-out|ease-in|ease-out|neutral|standing|plank|hands|feet|humanoid|avatar1|avatar2|avatar3)\\b"
4949
},
5050
"numbers": {
5151
"name": "constant.numeric.posecode",

package-lock.json

Lines changed: 1 addition & 1 deletion
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` | *(hosted default)* | Realistic figure: a GLB URL (Mixamo 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/element.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,11 @@ export class PosecodePlayerElement extends HTMLElement {
227227
const { createViewer } = await import("posecode-render");
228228
const viewer = createViewer(this.#canvas, {
229229
autoRotate: opts.autoRotate && !reduceMotion,
230-
...(opts.characterUrl ? { characterUrl: opts.characterUrl } : {}),
230+
...(opts.characterDisabled
231+
? {}
232+
: opts.characterUrl
233+
? { characterUrl: opts.characterUrl }
234+
: { characterUrls: opts.characterUrls }),
231235
});
232236
this.#viewer = viewer;
233237
viewer.onPhase(({ phaseName }) => {

packages/posecode-embed/src/options.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,43 @@ export interface PlayerOptions {
1919
/** Playback speed multiplier (0.1–4). */
2020
speed: number;
2121
/**
22-
* Realistic skinned figure: a GLB URL, the default hosted character when
23-
* absent, or `""` (attribute `character="off"`) for the procedural figure.
24-
* Load failures fall back to the procedural figure, so an offline page
25-
* degrades instead of blanking.
22+
* Realistic skinned figure pinned to one GLB URL, from an explicit
23+
* `character="<url>"` attribute. `""` when the attribute is absent (the host
24+
* picks the character from `characterUrls` instead) or the character is
25+
* disabled. Load failures fall back to the procedural figure, so an offline
26+
* page degrades instead of blanking.
2627
*/
2728
characterUrl: string;
29+
/** True when `character="off"` (or another falsey word) explicitly disables any skinned character. */
30+
characterDisabled: boolean;
31+
/**
32+
* Document selector (`avatar` when present, otherwise `rig`) → GLB URL,
33+
* applied when `characterUrl` is unset and the character isn't disabled.
34+
* Defaults to the hosted character choices and the humanoid default.
35+
*/
36+
characterUrls: Record<string, string>;
2837
}
2938

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

42+
/** Hosted character per built-in selector. Avatar1 intentionally reuses XBot. */
43+
export const DEFAULT_CHARACTER_URLS: Record<string, string> = {
44+
humanoid: DEFAULT_CHARACTER_URL,
45+
avatar1: DEFAULT_CHARACTER_URL,
46+
avatar2: "https://posecode.org/models/avatar2.glb",
47+
avatar3: "https://posecode.org/models/avatar3.glb",
48+
};
49+
3350
export const DEFAULT_OPTIONS: PlayerOptions = {
3451
autoplay: true,
3552
loop: true,
3653
controls: true,
3754
autoRotate: true,
3855
speed: 1,
39-
characterUrl: DEFAULT_CHARACTER_URL,
56+
characterUrl: "",
57+
characterDisabled: false,
58+
characterUrls: DEFAULT_CHARACTER_URLS,
4059
};
4160

4261
const SPEED_MIN = 0.1;
@@ -66,15 +85,13 @@ function clamp(n: number, lo: number, hi: number): number {
6685

6786
export function parseOptions(attrs: RawAttributes): PlayerOptions {
6887
const speedRaw = attrs.speed != null ? Number(attrs.speed) : NaN;
69-
// `character` accepts a GLB URL, a falsey word to opt out, or absent for
70-
// the hosted default.
88+
// `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 the
90+
// document's optional `avatar` directive pick from characterUrls.
7191
const characterRaw = attrs.character?.trim();
72-
const characterUrl =
73-
characterRaw === undefined || characterRaw === null
74-
? DEFAULT_OPTIONS.characterUrl
75-
: FALSEY.has(characterRaw.toLowerCase())
76-
? ""
77-
: characterRaw;
92+
const characterDisabled =
93+
characterRaw !== undefined && characterRaw !== null && FALSEY.has(characterRaw.toLowerCase());
94+
const characterUrl = characterRaw && !characterDisabled ? characterRaw : "";
7895
return {
7996
autoplay: boolAttr(attrs.autoplay, DEFAULT_OPTIONS.autoplay),
8097
loop: boolAttr(attrs.loop, DEFAULT_OPTIONS.loop),
@@ -84,5 +101,7 @@ export function parseOptions(attrs: RawAttributes): PlayerOptions {
84101
? clamp(speedRaw, SPEED_MIN, SPEED_MAX)
85102
: DEFAULT_OPTIONS.speed,
86103
characterUrl,
104+
characterDisabled,
105+
characterUrls: DEFAULT_OPTIONS.characterUrls,
87106
};
88107
}

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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
11
import { describe, it, expect } from "vitest";
2-
import { parseOptions, DEFAULT_CHARACTER_URL, DEFAULT_OPTIONS } from "../src/options.js";
2+
import {
3+
parseOptions,
4+
DEFAULT_CHARACTER_URL,
5+
DEFAULT_CHARACTER_URLS,
6+
DEFAULT_OPTIONS,
7+
} from "../src/options.js";
38

49
describe("parseOptions", () => {
510
it("returns sensible defaults for an element with no attributes", () => {
611
expect(parseOptions({})).toEqual(DEFAULT_OPTIONS);
712
expect(DEFAULT_CHARACTER_URL).toBe("https://posecode.org/models/xbot.glb");
13+
// No explicit `character` attribute: document-driven, not pinned to one URL.
14+
expect(DEFAULT_OPTIONS.characterUrl).toBe("");
15+
expect(DEFAULT_OPTIONS.characterDisabled).toBe(false);
16+
expect(DEFAULT_OPTIONS.characterUrls).toBe(DEFAULT_CHARACTER_URLS);
17+
expect(DEFAULT_CHARACTER_URLS.humanoid).toBe(DEFAULT_CHARACTER_URL);
18+
expect(DEFAULT_CHARACTER_URLS.avatar1).toBe(DEFAULT_CHARACTER_URL);
19+
});
20+
21+
it("pins an explicit character URL and disables document-driven selection", () => {
22+
const o = parseOptions({ character: "https://example.com/me.glb" });
23+
expect(o.characterUrl).toBe("https://example.com/me.glb");
24+
expect(o.characterDisabled).toBe(false);
25+
});
26+
27+
it("disables the character entirely on a falsey word", () => {
28+
const o = parseOptions({ character: "off" });
29+
expect(o.characterUrl).toBe("");
30+
expect(o.characterDisabled).toBe(true);
831
});
932

1033
it("treats boolean attributes as present-means-true", () => {

packages/posecode-language/src/completion.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import {
99
KINDS,
1010
POSES,
11+
AVATARS,
12+
RIGS,
1113
EFFECTORS,
1214
REACH_EFFECTORS,
1315
PIN_EFFECTORS,
@@ -25,6 +27,8 @@ export type CompletionKind =
2527
| "keyword"
2628
| "kind"
2729
| "pose"
30+
| "avatar"
31+
| "rig"
2832
| "easing"
2933
| "joint"
3034
| "action"
@@ -39,6 +43,8 @@ export interface CompletionItem {
3943
type Context =
4044
| "kind"
4145
| "pose"
46+
| "avatar"
47+
| "rig"
4248
| "easing"
4349
| "effector"
4450
| "reach-effector"
@@ -68,6 +74,8 @@ function contextFor(
6874
const atDocumentIndent =
6975
enclosingBlock === null && indent > 0 && (documentIndent === null || indent === documentIndent);
7076
if (atDocumentIndent && /^\s*pose\s+start\s*=\s*[\w-]*$/.test(prefix)) return "pose";
77+
if (atDocumentIndent && /^\s*avatar\s+[\w-]*$/.test(prefix)) return "avatar";
78+
if (atDocumentIndent && /^\s*rig\s+[\w-]*$/.test(prefix)) return "rig";
7179
if (atDocumentIndent && /^\s*step\s+"[^"]*"\s+[0-9.]+s\s+[\w-]*$/.test(prefix)) return "easing";
7280
const isActualChild = enclosingBlock !== null && indent > enclosingBlock.indent;
7381
if (isActualChild && enclosingBlock.kind === "start-pose") {
@@ -118,7 +126,7 @@ function documentIndentBefore(lines: readonly string[], line: number): number |
118126
const candidate = lines[i]!;
119127
const trimmed = candidate.trim();
120128
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
121-
if (!/^(?:rig|prop|pose|clip|step|repeat)\b/.test(trimmed)) continue;
129+
if (!/^(?:rig|avatar|prop|pose|clip|step|repeat)\b/.test(trimmed)) continue;
122130
return candidate.length - candidate.trimStart().length;
123131
}
124132
return null;
@@ -145,6 +153,10 @@ export function getCompletions(
145153
return KINDS.map((k) => item(k, "kind"));
146154
case "pose":
147155
return POSES.map((p) => item(p, "pose"));
156+
case "avatar":
157+
return AVATARS.map((avatar) => item(avatar, "avatar"));
158+
case "rig":
159+
return RIGS.map((r) => item(r, "rig"));
148160
case "easing":
149161
return MODES.map((e) => item(e, "easing"));
150162
case "effector":

0 commit comments

Comments
 (0)