Skip to content

Commit f9ced28

Browse files
feat(sdk): stage 6 — arc path ops (setArcPath, updateArcSegment, removeArcPath)
Port arc path trio from recast to browser-safe acorn+MagicString writer. Add SDK op types and mutate.ts handlers for setArcPath / updateArcSegment / removeArcPath. Decompose buildMotionPathObjectCode into small sub-functions in gsapSerialize.ts to stay within fallow complexity thresholds. Tests verify acorn output re-parses to correct arcPath shape. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent ccd350d commit f9ced28

6 files changed

Lines changed: 498 additions & 4 deletions

File tree

packages/core/src/parsers/gsapSerialize.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,3 +475,80 @@ export function resolveConversionProps(
475475
: { ...anim.properties };
476476
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
477477
}
478+
479+
// ── Arc path serialization helpers (shared by recast + acorn writers) ─────────
480+
481+
function numericXY(props: Record<string, number | string>): { x: number; y: number } | null {
482+
const vx = props.x;
483+
const vy = props.y;
484+
return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null;
485+
}
486+
487+
export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> {
488+
const keyframeWps = (anim.keyframes?.keyframes ?? [])
489+
.map((kf) => numericXY(kf.properties))
490+
.filter((pt): pt is { x: number; y: number } => pt !== null);
491+
if (keyframeWps.length >= 2) return keyframeWps;
492+
const propX = anim.properties.x;
493+
const propY = anim.properties.y;
494+
if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps;
495+
const destX = typeof propX === "number" ? propX : 0;
496+
const destY = typeof propY === "number" ? propY : 0;
497+
return [
498+
{ x: 0, y: 0 },
499+
{ x: destX, y: destY },
500+
];
501+
}
502+
503+
function autoRotateSuffix(autoRotate: boolean | number): string {
504+
if (autoRotate === true) return ", autoRotate: true";
505+
if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`;
506+
return "";
507+
}
508+
509+
function cubicControlPoints(
510+
seg: ArcPathSegment,
511+
wp: { x: number; y: number },
512+
nextWp: { x: number; y: number },
513+
): string[] {
514+
if (seg.cp1 && seg.cp2) {
515+
return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`];
516+
}
517+
const dx = nextWp.x - wp.x;
518+
const dy = nextWp.y - wp.y;
519+
const c = seg.curviness ?? 1;
520+
return [
521+
`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`,
522+
`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`,
523+
];
524+
}
525+
526+
function buildCubicPathEntries(
527+
waypoints: Array<{ x: number; y: number }>,
528+
segments: ArcPathSegment[],
529+
): string[] {
530+
const entries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`];
531+
for (let i = 0; i < segments.length; i++) {
532+
const nextWp = waypoints[i + 1]!;
533+
entries.push(...cubicControlPoints(segments[i]!, waypoints[i]!, nextWp));
534+
entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
535+
}
536+
return entries;
537+
}
538+
539+
export function buildMotionPathObjectCode(config: {
540+
waypoints: Array<{ x: number; y: number }>;
541+
segments: ArcPathSegment[];
542+
autoRotate: boolean | number;
543+
}): string {
544+
const { waypoints, segments, autoRotate } = config;
545+
const arSuffix = autoRotateSuffix(autoRotate);
546+
if (segments.some((s) => s.cp1 && s.cp2) && waypoints.length >= 2) {
547+
const pathStr = buildCubicPathEntries(waypoints, segments).join(", ");
548+
return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`;
549+
}
550+
const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
551+
const curviness = segments[0]?.curviness ?? 1;
552+
const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : "";
553+
return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`;
554+
}

packages/core/src/parsers/gsapWriter.parity.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,20 @@ import {
2424
materializeKeyframesFromScript as materializeAcorn,
2525
splitIntoPropertyGroupsFromScript as splitGroupsAcorn,
2626
splitAnimationsInScript as splitAnimsAcorn,
27+
setArcPathInScript as setArcAcorn,
28+
updateArcSegmentInScript as updateArcSegmentAcorn,
29+
removeArcPathFromScript as removeArcAcorn,
2730
} from "./gsapWriterAcorn.js";
28-
2931
function acornId(script: string): string {
3032
const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite;
3133
return parsed.located[0]!.id;
3234
}
3335

36+
function arcShapeOf(script: string) {
37+
const anim = parseGsapScript(script).animations[0]!;
38+
return { arcPath: anim.arcPath, properties: anim.properties };
39+
}
40+
3441
/** Reparse a written script and return the first animation's editable shape. */
3542
function shapeOf(script: string) {
3643
const anim = parseGsapScript(script).animations[0]!;
@@ -390,3 +397,66 @@ describe("parity: splitAnimationsInScript (recast vs acorn)", () => {
390397
expect(splitAnimsAcorn(script, opts).script).toBe(script);
391398
});
392399
});
400+
401+
// ─── arc path parity ──────────────────────────────────────────────────────────
402+
403+
const ARC_FLAT_SCRIPT = `
404+
const tl = gsap.timeline({ paused: true });
405+
tl.to("#hero", { x: 100, y: 50, duration: 2 }, 0);
406+
`;
407+
const ARC_CFG = {
408+
enabled: true as const,
409+
autoRotate: false as const,
410+
segments: [{ curviness: 1 }],
411+
};
412+
const DISABLE_CFG = {
413+
enabled: false as const,
414+
autoRotate: false as const,
415+
segments: [] as never[],
416+
};
417+
418+
function arcFixture() {
419+
const id = acornId(ARC_FLAT_SCRIPT);
420+
const enabled = setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG);
421+
return { id, enabled };
422+
}
423+
424+
describe("setArcPathInScript: acorn output correctness", () => {
425+
it("enable: arcPath.enabled=true, segments preserved", () => {
426+
const id = acornId(ARC_FLAT_SCRIPT);
427+
const shape = arcShapeOf(setArcAcorn(ARC_FLAT_SCRIPT, id, ARC_CFG));
428+
expect(shape.arcPath?.enabled).toBe(true);
429+
expect(shape.arcPath?.segments).toHaveLength(1);
430+
});
431+
432+
it("disable: arcPath=undefined, x/y restored", () => {
433+
const { id, enabled } = arcFixture();
434+
const shape = arcShapeOf(setArcAcorn(enabled, id, DISABLE_CFG));
435+
expect(shape.arcPath).toBeUndefined();
436+
expect(typeof shape.properties.x).toBe("number");
437+
});
438+
439+
it("no-op when animation not found", () => {
440+
expect(setArcAcorn(ARC_FLAT_SCRIPT, "nope", ARC_CFG)).toBe(ARC_FLAT_SCRIPT);
441+
});
442+
});
443+
444+
describe("updateArcSegmentInScript: acorn output correctness", () => {
445+
it("curviness update reflected in parsed shape", () => {
446+
const { id, enabled } = arcFixture();
447+
const shape = arcShapeOf(updateArcSegmentAcorn(enabled, id, 0, { curviness: 2 }));
448+
expect(shape.arcPath?.segments[0]?.curviness).toBe(2);
449+
});
450+
451+
it("no-op when index out of range", () => {
452+
const { id, enabled } = arcFixture();
453+
expect(updateArcSegmentAcorn(enabled, id, 99, { curviness: 2 })).toBe(enabled);
454+
});
455+
});
456+
457+
describe("removeArcPathFromScript: acorn output correctness", () => {
458+
it("arcPath=undefined after removal", () => {
459+
const { id, enabled } = arcFixture();
460+
expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined();
461+
});
462+
});

packages/core/src/parsers/gsapWriterAcorn.ts

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@
77
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
88
*/
99
import MagicString from "magic-string";
10-
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js";
11-
import { resolveConversionProps } from "./gsapSerialize.js";
10+
import type {
11+
GsapAnimation,
12+
GsapPercentageKeyframe,
13+
ArcPathConfig,
14+
ArcPathSegment,
15+
} from "./gsapSerialize.js";
16+
import {
17+
resolveConversionProps,
18+
extractArcWaypoints,
19+
buildMotionPathObjectCode,
20+
} from "./gsapSerialize.js";
1221
import {
1322
parseGsapScriptAcornForWrite,
1423
type ParsedGsapAcornForWrite,
@@ -1174,6 +1183,160 @@ export function removeLabelFromScript(script: string, name: string): string {
11741183
return ms.toString();
11751184
}
11761185

1186+
// ── Arc path helpers ─────────────────────────────────────────────────────────
1187+
1188+
/**
1189+
* Remove a set of properties from an ObjectExpression in a single pass.
1190+
* Groups consecutive marked props into blocks to avoid overlapping remove ranges.
1191+
*/
1192+
function removePropsByKey(ms: MagicString, objNode: any, keys: Set<string>): void {
1193+
if (objNode?.type !== "ObjectExpression") return;
1194+
const allProps = (objNode.properties ?? []).filter(isObjectProperty);
1195+
const marked = allProps.map((p: any) => keys.has(propKeyName(p) ?? ""));
1196+
let i = 0;
1197+
while (i < allProps.length) {
1198+
if (!marked[i]) {
1199+
i++;
1200+
continue;
1201+
}
1202+
const blockStart = i;
1203+
while (i < allProps.length && marked[i]) i++;
1204+
ms.remove(...blockRemoveRange(allProps, blockStart, i));
1205+
}
1206+
}
1207+
1208+
function blockRemoveRange(allProps: any[], blockStart: number, blockEnd: number): [number, number] {
1209+
if (blockStart === 0 && blockEnd === allProps.length)
1210+
return [allProps[0].start, allProps[allProps.length - 1].end];
1211+
if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start];
1212+
return [allProps[blockStart - 1].end, allProps[blockEnd - 1].end];
1213+
}
1214+
1215+
// fallow-ignore-next-line complexity
1216+
function readLastWaypointXY(mpVal: any): { x: number | null; y: number | null } {
1217+
if (mpVal?.type !== "ObjectExpression") return { x: null, y: null };
1218+
const pathProp = findPropertyNode(mpVal, "path");
1219+
if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null };
1220+
const elems: any[] = pathProp.value.elements ?? [];
1221+
const last = elems[elems.length - 1];
1222+
if (last?.type !== "ObjectExpression") return { x: null, y: null };
1223+
const xRaw = findPropertyNode(last, "x")?.value?.value;
1224+
const yRaw = findPropertyNode(last, "y")?.value?.value;
1225+
return { x: typeof xRaw === "number" ? xRaw : null, y: typeof yRaw === "number" ? yRaw : null };
1226+
}
1227+
1228+
function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean {
1229+
const mpProp = findPropertyNode(call.varsArg, "motionPath");
1230+
if (!mpProp) return false;
1231+
const { x, y } = readLastWaypointXY(mpProp.value);
1232+
if (x === null && y === null) {
1233+
const allProps = (call.varsArg.properties ?? []).filter(isObjectProperty);
1234+
removeProp(ms, mpProp, allProps);
1235+
return true;
1236+
}
1237+
// Overwrite the entire motionPath property with the recovered x/y pair — avoids
1238+
// the appendLeft+remove range-boundary issue in MagicString.
1239+
const parts: string[] = [];
1240+
if (x !== null) parts.push(`x: ${x}`);
1241+
if (y !== null) parts.push(`y: ${y}`);
1242+
ms.overwrite(mpProp.start, mpProp.end, parts.join(", "));
1243+
return true;
1244+
}
1245+
1246+
function stripXYFromKeyframes(ms: MagicString, kfPropNode: any): void {
1247+
if (kfPropNode?.value?.type !== "ObjectExpression") return;
1248+
const xyKeys = new Set(["x", "y"]);
1249+
for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) {
1250+
const k = propKeyName(pctProp);
1251+
if (typeof k === "string" && k.endsWith("%") && pctProp.value?.type === "ObjectExpression") {
1252+
removePropsByKey(ms, pctProp.value, xyKeys);
1253+
}
1254+
}
1255+
}
1256+
1257+
function enableArcPath(
1258+
ms: MagicString,
1259+
call: TweenCallInfo,
1260+
animation: GsapAnimation,
1261+
config: ArcPathConfig,
1262+
): boolean {
1263+
const waypoints = extractArcWaypoints(animation);
1264+
if (waypoints.length < 2) return false;
1265+
const segments: ArcPathSegment[] =
1266+
config.segments.length === waypoints.length - 1
1267+
? config.segments
1268+
: Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 }));
1269+
const motionPathCode = buildMotionPathObjectCode({
1270+
waypoints,
1271+
segments,
1272+
autoRotate: config.autoRotate,
1273+
});
1274+
upsertProp(ms, call.varsArg, "motionPath", `__raw:${motionPathCode}`);
1275+
stripXYFromKeyframes(ms, findPropertyNode(call.varsArg, "keyframes"));
1276+
removePropsByKey(ms, call.varsArg, new Set(["x", "y"]));
1277+
return true;
1278+
}
1279+
1280+
export function setArcPathInScript(
1281+
script: string,
1282+
animationId: string,
1283+
config: ArcPathConfig,
1284+
): string {
1285+
const parsed = parseGsapScriptAcornForWrite(script);
1286+
if (!parsed) return script;
1287+
const target = parsed.located.find((l) => l.id === animationId);
1288+
if (!target) return script;
1289+
const ms = new MagicString(script);
1290+
const handled = config.enabled
1291+
? enableArcPath(ms, target.call, target.animation, config)
1292+
: disableArcPath(ms, target.call);
1293+
return handled ? ms.toString() : script;
1294+
}
1295+
1296+
export function updateArcSegmentInScript(
1297+
script: string,
1298+
animationId: string,
1299+
segmentIndex: number,
1300+
update: Partial<ArcPathSegment>,
1301+
): string {
1302+
const parsed = parseGsapScriptAcornForWrite(script);
1303+
if (!parsed) return script;
1304+
const target = parsed.located.find((l) => l.id === animationId);
1305+
if (!target) return script;
1306+
1307+
const { call, animation } = target;
1308+
if (!animation.arcPath?.enabled) return script;
1309+
1310+
const segments = [...animation.arcPath.segments];
1311+
if (segmentIndex < 0 || segmentIndex >= segments.length) return script;
1312+
1313+
segments[segmentIndex] = { ...segments[segmentIndex]!, ...update };
1314+
1315+
const waypoints = extractArcWaypoints(animation);
1316+
if (waypoints.length < 2) return script;
1317+
1318+
const motionPathCode = buildMotionPathObjectCode({
1319+
waypoints,
1320+
segments,
1321+
autoRotate: animation.arcPath.autoRotate,
1322+
});
1323+
1324+
const mpProp = findPropertyNode(call.varsArg, "motionPath");
1325+
if (!mpProp) return script;
1326+
1327+
const ms = new MagicString(script);
1328+
ms.overwrite(mpProp.value.start, mpProp.value.end, motionPathCode);
1329+
return ms.toString();
1330+
}
1331+
1332+
export function removeArcPathFromScript(script: string, animationId: string): string {
1333+
return setArcPathInScript(script, animationId, {
1334+
enabled: false,
1335+
autoRotate: false,
1336+
segments: [],
1337+
});
1338+
}
1339+
11771340
// ── splitAnimationsInScript helpers ──────────────────────────────────────────
11781341

11791342
/** Overwrite the selector (first arg) of a tween call. */

0 commit comments

Comments
 (0)