Skip to content

Commit 14793b6

Browse files
committed
feat(parser): grip directive with two-point side-anchor resolution
1 parent cd5804c commit 14793b6

6 files changed

Lines changed: 106 additions & 0 deletions

File tree

packages/posecode-parser/src/clamp.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import type {
1111
EulerDeg,
12+
GripTarget,
1213
JointTarget,
1314
PosecodeIR,
1415
ParseError,
@@ -146,6 +147,21 @@ function resolveStep(
146147
for (const effector of sides) pins.push({ effector, anchor: p.anchor });
147148
}
148149

150+
// Grip contacts: like pins, but each hand gets its OWN two-point anchor. A
151+
// bare anchor (`bar`) is rewritten per side to `bar_left`/`bar_right` so the
152+
// two hands grip shoulder-width apart; an already-sided anchor is kept as-is.
153+
const grips: GripTarget[] = [];
154+
for (const g of step.grips) {
155+
const sides = expandEffector(g.effector);
156+
if (sides.length === 0) {
157+
errors.push({ line: g.line, message: `unknown grip effector: "${g.effector}"` });
158+
continue;
159+
}
160+
for (const effector of sides) {
161+
grips.push({ effector, anchor: sideAnchor(g.anchor, effector) });
162+
}
163+
}
164+
149165
// Travel is clamped to a sane studio footprint (±TRAVEL_MAX m) so a stray
150166
// large value can't fling the figure off the ground plane / out of frame.
151167
const travel = step.travel
@@ -163,6 +179,7 @@ function resolveStep(
163179
groundLock: step.groundLock,
164180
reaches,
165181
pins,
182+
grips,
166183
...(step.turn !== undefined ? { turnDeg: step.turn } : {}),
167184
...(travel ? { travel } : {}),
168185
...(step.cue ? { cue: step.cue } : {}),
@@ -176,6 +193,19 @@ function clampNum(v: number, min: number, max: number): number {
176193
return Math.min(max, Math.max(min, v));
177194
}
178195

196+
/**
197+
* Rewrite a grip anchor to the effector's side: a bare anchor (`bar`) becomes
198+
* `bar_left`/`bar_right` so two hands grip shoulder-width apart. An anchor that
199+
* is already sided, or an effector without a side, is returned unchanged. The
200+
* renderer falls back to the bare anchor if a sided one isn't declared.
201+
*/
202+
function sideAnchor(anchor: string, effector: string): string {
203+
if (/_(left|right)$/.test(anchor)) return anchor;
204+
if (effector.endsWith("_left")) return `${anchor}_left`;
205+
if (effector.endsWith("_right")) return `${anchor}_right`;
206+
return anchor;
207+
}
208+
179209
function ensure(map: Map<string, EulerDeg>, bone: string): EulerDeg {
180210
let euler = map.get(bone);
181211
if (!euler) {

packages/posecode-parser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type {
3939
JointTarget,
4040
ReachTarget,
4141
PinTarget,
42+
GripTarget,
4243
Phase,
4344
PosecodeIR,
4445
Warning,

packages/posecode-parser/src/parser.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface AstStep {
3737
groundLock: string[];
3838
reaches: AstReach[];
3939
pins: AstPin[];
40+
grips: AstPin[];
4041
/** Root facing (yaw about world Y, degrees) at the end of this phase. */
4142
turn?: number;
4243
/** Root ground position (world X/Z metres) at the end of this phase. */
@@ -182,6 +183,7 @@ export function parseToAst(source: string): ParseAstResult {
182183
groundLock: [],
183184
reaches: [],
184185
pins: [],
186+
grips: [],
185187
line: ln.line,
186188
};
187189
doc.steps.push(current);
@@ -247,6 +249,20 @@ function parseStepChild(ln: Line, current: AstStep | null): ParseError | null {
247249
return null;
248250
}
249251

252+
if (head === "grip") {
253+
// `grip: <effector> <anchor>`: hold a bar/rail — arm IK to a two-point
254+
// anchor + finger wrap. Parsed exactly like `pin`; the side-anchor rewrite
255+
// happens in resolution.
256+
if (!current) return { line: ln.line, message: "`grip` outside of a step" };
257+
const effector = t[2]?.type === "word" ? t[2].value : null;
258+
const anchor = t[3]?.type === "word" ? t[3].value : null;
259+
if (t[1]?.type !== "colon" || !effector || !anchor) {
260+
return { line: ln.line, message: "expected `grip: <effector> <anchor>`" };
261+
}
262+
current.grips.push({ effector, anchor, line: ln.line });
263+
return null;
264+
}
265+
250266
if (head === "turn") {
251267
// `turn: <degrees>`: the figure's facing (root yaw about world Y) at the
252268
// end of this phase. Absolute, accumulated forward like a joint target.

packages/posecode-parser/src/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const stepSchema = z.object({
6262
groundLock: z.array(z.string()),
6363
reaches: z.array(reachSchema),
6464
pins: z.array(pinSchema),
65+
grips: z.array(pinSchema),
6566
turn: z.number().optional(),
6667
travel: z.object({ x: z.number(), z: z.number() }).optional(),
6768
cue: z.string().optional(),

packages/posecode-parser/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ export interface PinTarget {
4949
anchor: string;
5050
}
5151

52+
/**
53+
* A grip contact: a hand holds a bar/rail. Unlike a pin (which only translates
54+
* the body to an anchor), a grip also bends the arm via IK so each hand lands on
55+
* its own two-point anchor (`bar_left`/`bar_right`) and wraps the fingers around
56+
* the bar. Powers pull-up, dead-hang, hanging knee raise.
57+
*/
58+
export interface GripTarget {
59+
effector: string;
60+
anchor: string;
61+
}
62+
5263
/** One concurrent phase of a movement (e.g. "Lower" in a push-up). */
5364
export interface Phase {
5465
name: string;
@@ -61,6 +72,8 @@ export interface Phase {
6172
reaches: ReachTarget[];
6273
/** Contact pins active during this phase (translate the body to the anchor). */
6374
pins: PinTarget[];
75+
/** Grip contacts active this phase (arm IK to a two-point bar anchor + finger wrap). */
76+
grips: GripTarget[];
6477
/**
6578
* Root facing (yaw about world Y, degrees) at the end of this phase, an
6679
* absolute target carried forward across phases. Powers turns / pirouettes.

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,48 @@ describe("timing modes", () => {
301301
expect(errors[0]!.message.toLowerCase()).toContain("mode");
302302
});
303303
});
304+
305+
describe("grip directive", () => {
306+
it("resolves `grip: hands bar` to two per-side grips with sided anchors", () => {
307+
const src = [
308+
'posecode exercise "Pull-up"',
309+
" rig humanoid",
310+
" prop bar",
311+
" pose start = standing",
312+
' step "Hang" 1s flow:',
313+
" grip: hands bar",
314+
].join("\n");
315+
const { ir, errors } = parse(src);
316+
expect(errors).toEqual([]);
317+
expect(ir!.phases[0]!.grips).toEqual([
318+
{ effector: "hand_left", anchor: "bar_left" },
319+
{ effector: "hand_right", anchor: "bar_right" },
320+
]);
321+
});
322+
323+
it("keeps a side-specific grip anchor verbatim", () => {
324+
const src = [
325+
'posecode exercise "One-arm"',
326+
" rig humanoid",
327+
" prop bar",
328+
' step "Hang" 1s flow:',
329+
" grip: hand_left bar_left",
330+
].join("\n");
331+
const { ir, errors } = parse(src);
332+
expect(errors).toEqual([]);
333+
expect(ir!.phases[0]!.grips).toEqual([{ effector: "hand_left", anchor: "bar_left" }]);
334+
});
335+
336+
it("errors on an unknown grip effector with its line", () => {
337+
const src = [
338+
'posecode exercise "Bad"',
339+
" rig humanoid",
340+
' step "Hang" 1s flow:',
341+
" grip: tentacle bar",
342+
].join("\n");
343+
const { errors } = parse(src);
344+
expect(errors.length).toBeGreaterThan(0);
345+
expect(errors[0]!.line).toBe(4);
346+
expect(errors[0]!.message).toContain("tentacle");
347+
});
348+
});

0 commit comments

Comments
 (0)