Skip to content

Commit f13bf59

Browse files
committed
feat: optional mocap-clip layer with Mixamo retargeting + crossfade
Add an optional layer so movements can play a retargeted Mixamo animation clip, crossfaded over the procedural DSL keyframes. The procedural path stays the fallback: a missing clip, absent character, or retarget failure all leave the keyframes driving the figure. - Parser: new document directive `clip "<name>"` → optional `PosecodeIR.clip` (parser, clamp, types), documented in SPEC.md grammar and language vocab. - Render: `packages/posecode-render/src/clips.ts` loads an FBX/GLB, picks its longest AnimationClip, retargets it onto the Mixamo-named character skeleton via SkeletonUtils.retargetClip, and crossfades it with a THREE.AnimationMixer. Retarget hardening: match bones by plain mixamo name (strip mixamorig prefix); snapshot+restore target bone locals (retargetClip resets to bind pose, which would destroy character.ts's rest calibration); drop tracks for unmapped bones (they'd snap to T-pose); pin hip X/Z so the clip plays in place and composes with DSL travel/turn. - Viewer: opt in via `createViewer(canvas, { clips: { walk: url } })`; new `clipActive` getter; weight eases per frame so switching docs fades not pops. - Character: expose the skinned retarget target mesh + the driver-synced bone set for the blend layer. - Playground: map `walk`→ the repo FBX (served via public/clips symlink) and add `clip "walk"` to the walk-cycle example. Tests: 14 new (parser clip directive; clip retarget baking, bone filtering, rest preservation, hip-pin, crossfade blend). Full suite 205 passing; all packages + playground type-check clean; verified end-to-end in the browser.
1 parent d734877 commit f13bf59

14 files changed

Lines changed: 641 additions & 4 deletions

File tree

.claude/launch.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
{
55
"name": "playground",
66
"runtimeExecutable": "npm",
7-
"runtimeArgs": ["run", "dev", "-w", "playground"],
8-
"port": 5173,
7+
"runtimeArgs": ["run", "dev", "-w", "playground", "--", "--port", "5199", "--strictPort"],
8+
"port": 5199,
99
"autoPort": true
1010
}
1111
]

packages/posecode-language/src/vocab.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const REACH_EFFECTORS = EFFECTOR_NAMES;
2222
export const PROPS = ["chair", "wall", "bar", "box", "dip-bars"];
2323

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

2727
/** Keywords valid as step children. */
2828
export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "turn", "travel", "cue"];
@@ -34,6 +34,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
3434
prop: "Adds a scene object: `prop chair | wall | bar | box | dip-bars`. Supplies reach/pin anchors.",
3535
pose: "Sets the starting pose: `pose start = standing | neutral | plank | supine | prone | seated`.",
3636
start: "Used in `pose start = <pose>`.",
37+
clip: 'Optional mocap clip: `clip "walk"`. A renderer with a matching retargeted animation plays it crossfaded over the procedural pose; others ignore it.',
3738
step: 'A movement phase: `step "<name>" <Ns> <easing>:`.',
3839
repeat: "How many times the movement loops.",
3940
"ground-lock": "Pins effectors (hands / feet) to the floor for this phase.",

packages/posecode-parser/src/clamp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function resolve(ast: AstDoc): ResolveResult {
5252
rig: ast.rig,
5353
...(ast.startPose ? { startPose: ast.startPose } : {}),
5454
props: ast.props,
55+
...(ast.clip ? { clip: ast.clip } : {}),
5556
repeat: ast.repeat,
5657
phases,
5758
};

packages/posecode-parser/src/parser.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface AstDoc {
5050
rig: string;
5151
startPose?: string;
5252
props: string[];
53+
/** Optional mocap clip name (`clip "<name>"`), resolved to an asset by hosts. */
54+
clip?: string;
5355
repeat: number;
5456
steps: AstStep[];
5557
}
@@ -124,6 +126,13 @@ export function parseToAst(source: string): ParseAstResult {
124126
else doc.props.push(p);
125127
break;
126128
}
129+
case "clip": {
130+
// `clip "<name>"`: an optional mocap clip the renderer may play
131+
// (retargeted) instead of / blended with the procedural phases.
132+
if (t[1]?.type === "str") doc.clip = t[1].value;
133+
else errors.push({ line: ln.line, message: 'expected `clip "<name>"`' });
134+
break;
135+
}
127136
case "pose": {
128137
// `pose start = <name>`
129138
const name = t.length > 0 ? t[t.length - 1] : undefined;

packages/posecode-parser/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ export interface PosecodeIR {
8282
startPose?: string;
8383
/** Scene props declared with `prop <type>`, e.g. ["chair", "bar"]. */
8484
props: string[];
85+
/**
86+
* Optional mocap clip name declared with `clip "<name>"`. A renderer MAY
87+
* play a retargeted animation clip of this name (resolved by the host to an
88+
* asset URL) instead of, or blended with, the procedural phase keyframes.
89+
* Renderers without a matching clip ignore it: phases always fully describe
90+
* the movement, so the procedural path remains the source of truth.
91+
*/
92+
clip?: string;
8593
repeat: number;
8694
phases: Phase[];
8795
}

packages/posecode-parser/test/parse.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,36 @@ describe("reach/pin effectors", () => {
209209
expect(errors[0]!.message).toContain("tentacle");
210210
});
211211
});
212+
213+
describe("clip directive", () => {
214+
const doc = (clipLine: string): string =>
215+
[
216+
'posecode exercise "Walk"',
217+
" rig humanoid",
218+
" pose start = standing",
219+
clipLine,
220+
' step "Step" 1s linear:',
221+
" hips: flex 20",
222+
" repeat 1",
223+
].join("\n");
224+
225+
it("parses a document-level clip name into the IR", () => {
226+
const { ir, errors } = parse(doc(' clip "walk"'));
227+
expect(errors).toEqual([]);
228+
expect(ir!.clip).toBe("walk");
229+
});
230+
231+
it("omits clip from the IR when the directive is absent", () => {
232+
const { ir, errors } = parse(doc(""));
233+
expect(errors).toEqual([]);
234+
expect(ir!.clip).toBeUndefined();
235+
});
236+
237+
it("rejects a clip directive without a quoted name", () => {
238+
const { ir, errors } = parse(doc(" clip walk"));
239+
expect(ir).toBeNull();
240+
expect(errors).toHaveLength(1);
241+
expect(errors[0]!.line).toBe(4);
242+
expect(errors[0]!.message).toContain("clip");
243+
});
244+
});

packages/posecode-render/src/character.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ export interface Character {
7171
proportions: Proportions;
7272
/** Copy the driver's current pose onto the character skeleton. */
7373
sync(driver: Mannequin): void;
74+
/**
75+
* The character's first skinned mesh, the retarget target for mocap clips
76+
* (see clips.ts). Null on bare skeletons, which then can't play clips.
77+
*/
78+
skinnedMesh: THREE.SkinnedMesh | null;
79+
/** Bones `sync` writes every frame; the mocap layer blends against these. */
80+
drivenNodes: ReadonlySet<THREE.Object3D>;
7481
/** Free GPU resources. */
7582
dispose(): void;
7683
}
@@ -372,10 +379,23 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
372379
group.updateMatrixWorld(true);
373380
}
374381

382+
// Surface for the optional mocap-clip layer (clips.ts): the retarget target
383+
// mesh and the set of bones sync() rewrites each frame.
384+
let skinnedMesh: THREE.SkinnedMesh | null = null;
385+
charScene.traverse((o) => {
386+
if (!skinnedMesh && (o as THREE.SkinnedMesh).isSkinnedMesh) {
387+
skinnedMesh = o as THREE.SkinnedMesh;
388+
}
389+
});
390+
const drivenNodes = new Set<THREE.Object3D>(mapped.map((m) => m.node));
391+
for (const ph of phalanges) drivenNodes.add(ph.node);
392+
375393
return {
376394
group,
377395
proportions,
378396
sync,
397+
skinnedMesh,
398+
drivenNodes,
379399
dispose() {
380400
group.traverse((o) => {
381401
const mesh = o as THREE.Mesh;

0 commit comments

Comments
 (0)