Skip to content

Commit f69ccf0

Browse files
committed
feat(parser): timing modes with legacy easing aliases
1 parent c323b01 commit f69ccf0

5 files changed

Lines changed: 81 additions & 14 deletions

File tree

packages/posecode-parser/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function parse(source: string): ParseResult {
3434
export type {
3535
Axis,
3636
Easing,
37+
TimingMode,
3738
EulerDeg,
3839
JointTarget,
3940
ReachTarget,
@@ -57,4 +58,4 @@ export {
5758
boneType,
5859
} from "./joints.js";
5960
export { romFor, clampAngle, eulerRomFor, type RomLimit, type EulerRom } from "./rom.js";
60-
export { EASINGS } from "./schema.js";
61+
export { EASINGS, MODES, LEGACY_MODE_ALIASES, normalizeMode } from "./schema.js";

packages/posecode-parser/src/parser.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { tokenize, TokenizeError, type Line, type Token } from "./tokenizer.js";
1010
import type { ParseError } from "./types.js";
11+
import { normalizeMode, MODES } from "./schema.js";
1112

1213
export interface AstJointTarget {
1314
joint: string;
@@ -151,25 +152,32 @@ export function parseToAst(source: string): ParseAstResult {
151152
case "step": {
152153
const name = t[1];
153154
const dur = t[2];
154-
const easing = word(t[3]);
155+
const easingTok = word(t[3]);
156+
const resolved = easingTok
157+
? normalizeMode(easingTok)
158+
: { mode: null, legacy: false };
155159
const colon = t[4];
156160
if (
157161
name?.type !== "str" ||
158162
dur?.type !== "dur" ||
159-
!easing ||
163+
!easingTok ||
164+
resolved.mode === null ||
160165
colon?.type !== "colon"
161166
) {
162167
errors.push({
163168
line: ln.line,
164-
message: 'expected `step "<name>" <duration> <easing>:`',
169+
message:
170+
resolved.mode === null && easingTok
171+
? `unknown timing mode "${easingTok}"; expected one of ${MODES.join(", ")}`
172+
: 'expected `step "<name>" <duration> <mode>:`',
165173
});
166174
current = null;
167175
break;
168176
}
169177
current = {
170178
name: name.value,
171179
durationSec: parseDuration(dur.value),
172-
easing,
180+
easing: resolved.mode,
173181
targets: [],
174182
groundLock: [],
175183
reaches: [],

packages/posecode-parser/src/schema.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,32 @@
88
*/
99

1010
import { z } from "zod";
11-
import type { ParseError } from "./types.js";
11+
import type { ParseError, TimingMode } from "./types.js";
1212
import type { AstDoc } from "./parser.js";
1313

14-
export const EASINGS = ["linear", "ease-in", "ease-out", "ease-in-out"] as const;
14+
export const MODES = ["flow", "settle", "drive", "snap", "linear"] as const;
15+
16+
/** Deprecated easing names → canonical mode. Kept so existing docs never break. */
17+
export const LEGACY_MODE_ALIASES: Record<string, TimingMode> = {
18+
"ease-in": "drive",
19+
"ease-out": "settle",
20+
"ease-in-out": "settle",
21+
linear: "linear",
22+
};
23+
24+
/** Back-compat: the old exported name, now the union of accepted written tokens. */
25+
export const EASINGS = [...MODES, "ease-in", "ease-out", "ease-in-out"] as const;
26+
27+
/** Map a written token to a canonical mode + whether it was a legacy alias. */
28+
export function normalizeMode(raw: string): { mode: TimingMode | null; legacy: boolean } {
29+
if ((MODES as readonly string[]).includes(raw)) {
30+
return { mode: raw as TimingMode, legacy: false };
31+
}
32+
const alias = LEGACY_MODE_ALIASES[raw];
33+
// "linear" is canonical, not a deprecation — only non-canonical aliases are legacy.
34+
if (alias) return { mode: alias, legacy: raw !== "linear" };
35+
return { mode: null, legacy: false };
36+
}
1537

1638
const jointTargetSchema = z.object({
1739
joint: z.string().min(1),
@@ -35,7 +57,7 @@ const pinSchema = z.object({
3557
const stepSchema = z.object({
3658
name: z.string(),
3759
durationSec: z.number().positive(),
38-
easing: z.enum(EASINGS),
60+
easing: z.enum(MODES),
3961
targets: z.array(jointTargetSchema),
4062
groundLock: z.array(z.string()),
4163
reaches: z.array(reachSchema),

packages/posecode-parser/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export const POSECODE_VERSION = "0.1";
1111

1212
export type Axis = "x" | "y" | "z";
1313

14-
export type Easing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
14+
export type TimingMode = "flow" | "settle" | "drive" | "snap" | "linear";
15+
/** @deprecated use TimingMode. Kept as an alias for one release. */
16+
export type Easing = TimingMode;
1517

1618
/** Euler rotation in degrees, local to a bone's rest orientation. */
1719
export interface EulerDeg {

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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { parse } from "../src/index.js";
2+
import { parse, normalizeMode, MODES } from "../src/index.js";
33

44
const PUSHUP = [
55
'posecode exercise "Push-up"',
@@ -38,7 +38,7 @@ describe("parse", () => {
3838
const lower = ir!.phases[0]!;
3939
expect(lower.name).toBe("Lower");
4040
expect(lower.durationSec).toBe(2);
41-
expect(lower.easing).toBe("ease-in");
41+
expect(lower.easing).toBe("drive"); // legacy `ease-in` normalizes to canonical mode
4242
expect(lower.cue).toBe("Elbows ~45 from torso");
4343
expect(lower.groundLock.sort()).toEqual(["feet", "hands"]);
4444

@@ -114,15 +114,15 @@ describe("parse", () => {
114114
expect(errors[0]!.message).toMatch(/header|must start/i);
115115
});
116116

117-
it("rejects an unknown easing", () => {
117+
it("rejects an unknown timing mode", () => {
118118
const src = [
119-
'posecode exercise "Bad easing"',
119+
'posecode exercise "Bad mode"',
120120
" rig humanoid",
121121
' step "Move" 1s wobble:',
122122
" elbows: flex 90",
123123
].join("\n");
124124
const { errors } = parse(src);
125-
expect(errors.some((e) => /easing/i.test(e.message))).toBe(true);
125+
expect(errors.some((e) => /mode/i.test(e.message))).toBe(true);
126126
});
127127

128128
it("parses turn and travel into the phase IR", () => {
@@ -267,3 +267,37 @@ describe("clip directive", () => {
267267
expect(errors[0]!.message).toContain("clip");
268268
});
269269
});
270+
271+
describe("timing modes", () => {
272+
it("accepts the canonical modes", () => {
273+
for (const m of MODES) {
274+
const src = `posecode exercise "x"\n rig humanoid\n step "s" 1s ${m}:\n knees: flex 10\n`;
275+
const { errors } = parse(src);
276+
expect(errors).toEqual([]);
277+
}
278+
});
279+
280+
it("normalizes legacy easing names to canonical modes", () => {
281+
expect(normalizeMode("ease-in")).toEqual({ mode: "drive", legacy: true });
282+
expect(normalizeMode("ease-out")).toEqual({ mode: "settle", legacy: true });
283+
expect(normalizeMode("ease-in-out")).toEqual({ mode: "settle", legacy: true });
284+
expect(normalizeMode("linear")).toEqual({ mode: "linear", legacy: false });
285+
expect(normalizeMode("flow")).toEqual({ mode: "flow", legacy: false });
286+
expect(normalizeMode("bogus")).toEqual({ mode: null, legacy: false });
287+
});
288+
289+
it("legacy documents still parse and carry a canonical mode", () => {
290+
const src =
291+
`posecode exercise "sq"\n rig humanoid\n step "Descend" 1s ease-in-out:\n knees: flex 90\n`;
292+
const { ir, errors } = parse(src);
293+
expect(errors).toEqual([]);
294+
expect(ir?.phases[0]?.easing).toBe("settle");
295+
});
296+
297+
it("rejects an unknown mode with a clear error", () => {
298+
const src = `posecode exercise "x"\n rig humanoid\n step "s" 1s wobble:\n knees: flex 10\n`;
299+
const { errors } = parse(src);
300+
expect(errors.length).toBeGreaterThan(0);
301+
expect(errors[0]!.message.toLowerCase()).toContain("mode");
302+
});
303+
});

0 commit comments

Comments
 (0)