Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
{
"name": "playground",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "-w", "playground"],
"port": 5173,
"runtimeArgs": ["run", "dev", "-w", "playground", "--", "--port", "5199", "--strictPort"],
"port": 5199,
"autoPort": true
}
]
Expand Down
3 changes: 2 additions & 1 deletion packages/posecode-language/src/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const REACH_EFFECTORS = EFFECTOR_NAMES;
export const PROPS = ["chair", "wall", "bar", "box", "dip-bars"];

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

/** Keywords valid as step children. */
export const CHILD_KEYWORDS = ["ground-lock", "reach", "pin", "turn", "travel", "cue"];
Expand All @@ -34,6 +34,7 @@ export const KEYWORD_DOCS: Record<string, string> = {
prop: "Adds a scene object: `prop chair | wall | bar | box | dip-bars`. Supplies reach/pin anchors.",
pose: "Sets the starting pose: `pose start = standing | neutral | plank | supine | prone | seated`.",
start: "Used in `pose start = <pose>`.",
clip: 'Optional mocap clip: `clip "walk"`. A renderer with a matching retargeted animation plays it crossfaded over the procedural pose; others ignore it.',
step: 'A movement phase: `step "<name>" <Ns> <easing>:`.',
repeat: "How many times the movement loops.",
"ground-lock": "Pins effectors (hands / feet) to the floor for this phase.",
Expand Down
1 change: 1 addition & 0 deletions packages/posecode-parser/src/clamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function resolve(ast: AstDoc): ResolveResult {
rig: ast.rig,
...(ast.startPose ? { startPose: ast.startPose } : {}),
props: ast.props,
...(ast.clip ? { clip: ast.clip } : {}),
repeat: ast.repeat,
phases,
};
Expand Down
9 changes: 9 additions & 0 deletions packages/posecode-parser/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface AstDoc {
rig: string;
startPose?: string;
props: string[];
/** Optional mocap clip name (`clip "<name>"`), resolved to an asset by hosts. */
clip?: string;
repeat: number;
steps: AstStep[];
}
Expand Down Expand Up @@ -124,6 +126,13 @@ export function parseToAst(source: string): ParseAstResult {
else doc.props.push(p);
break;
}
case "clip": {
// `clip "<name>"`: an optional mocap clip the renderer may play
// (retargeted) instead of / blended with the procedural phases.
if (t[1]?.type === "str") doc.clip = t[1].value;
else errors.push({ line: ln.line, message: 'expected `clip "<name>"`' });
break;
}
case "pose": {
// `pose start = <name>`
const name = t.length > 0 ? t[t.length - 1] : undefined;
Expand Down
8 changes: 8 additions & 0 deletions packages/posecode-parser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ export interface PosecodeIR {
startPose?: string;
/** Scene props declared with `prop <type>`, e.g. ["chair", "bar"]. */
props: string[];
/**
* Optional mocap clip name declared with `clip "<name>"`. A renderer MAY
* play a retargeted animation clip of this name (resolved by the host to an
* asset URL) instead of, or blended with, the procedural phase keyframes.
* Renderers without a matching clip ignore it: phases always fully describe
* the movement, so the procedural path remains the source of truth.
*/
clip?: string;
repeat: number;
phases: Phase[];
}
Expand Down
33 changes: 33 additions & 0 deletions packages/posecode-parser/test/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,36 @@ describe("reach/pin effectors", () => {
expect(errors[0]!.message).toContain("tentacle");
});
});

describe("clip directive", () => {
const doc = (clipLine: string): string =>
[
'posecode exercise "Walk"',
" rig humanoid",
" pose start = standing",
clipLine,
' step "Step" 1s linear:',
" hips: flex 20",
" repeat 1",
].join("\n");

it("parses a document-level clip name into the IR", () => {
const { ir, errors } = parse(doc(' clip "walk"'));
expect(errors).toEqual([]);
expect(ir!.clip).toBe("walk");
});

it("omits clip from the IR when the directive is absent", () => {
const { ir, errors } = parse(doc(""));
expect(errors).toEqual([]);
expect(ir!.clip).toBeUndefined();
});

it("rejects a clip directive without a quoted name", () => {
const { ir, errors } = parse(doc(" clip walk"));
expect(ir).toBeNull();
expect(errors).toHaveLength(1);
expect(errors[0]!.line).toBe(4);
expect(errors[0]!.message).toContain("clip");
});
});
20 changes: 20 additions & 0 deletions packages/posecode-render/src/character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ export interface Character {
proportions: Proportions;
/** Copy the driver's current pose onto the character skeleton. */
sync(driver: Mannequin): void;
/**
* The character's first skinned mesh, the retarget target for mocap clips
* (see clips.ts). Null on bare skeletons, which then can't play clips.
*/
skinnedMesh: THREE.SkinnedMesh | null;
/** Bones `sync` writes every frame; the mocap layer blends against these. */
drivenNodes: ReadonlySet<THREE.Object3D>;
/** Free GPU resources. */
dispose(): void;
}
Expand Down Expand Up @@ -372,10 +379,23 @@ export function rigCharacter(charScene: THREE.Object3D): Character {
group.updateMatrixWorld(true);
}

// Surface for the optional mocap-clip layer (clips.ts): the retarget target
// mesh and the set of bones sync() rewrites each frame.
let skinnedMesh: THREE.SkinnedMesh | null = null;
charScene.traverse((o) => {
if (!skinnedMesh && (o as THREE.SkinnedMesh).isSkinnedMesh) {
skinnedMesh = o as THREE.SkinnedMesh;
}
});
const drivenNodes = new Set<THREE.Object3D>(mapped.map((m) => m.node));
for (const ph of phalanges) drivenNodes.add(ph.node);

return {
group,
proportions,
sync,
skinnedMesh,
drivenNodes,
dispose() {
group.traverse((o) => {
const mesh = o as THREE.Mesh;
Expand Down
Loading
Loading