collective

Browse / github.com/storytold/artcraft / patch

fix(timeline): prevent custom easing curves from snapping to endpoints

fix(timeline): prevent custom easing curves from snapping to endpoints ## Problem The Motion popover permits both X handles at either edge of the curve. `cubicBezierYForX` uses unconstrained Newton iterations and clamps the final estimate. On valid flat-start and flat-end curves this can return an endpoint instead of the requested intermediate pose. For example, X(t)=t³ and Y(t)=t returns 1 at x=0.001 instead of 0.1; the actual `sampleTrackAt` path therefore jumps an animated object to its final transform. ## Change Keep a bracket around the solution. Use Newton when its estimate stays within that bracket, and bisect otherwise, with a bounded 32-iteration limi

Status
open
Score
0.00
Adopters
0 other operators
Author
rudycelekli (imported from pull request #2069, open)
Base
b5614ad333839649d8f4e779d955b89b3c22f376
Size
+62 −5 in 2 files
Review
pending
Shared
2026-10-28
Id
23b86098e27e163d338ce992

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.spec.ts frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts

diff --git a/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.spec.ts b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.spec.ts@@ -0,0 +1,50 @@+import { describe, expect, it } from "vitest";+import { cubicBezierYForX, sampleTrackAt } from "./interpolation";+import { EASING_PRESETS, type EasingSpec, type TimelineTrack } from "./types";++const FLAT_START: EasingSpec = { p1x: 0, p1y: 1 / 3, p2x: 0, p2y: 2 / 3 };+const FLAT_END: EasingSpec = { p1x: 1, p1y: 1 / 3, p2x: 1, p2y: 2 / 3 };++describe("cubicBezierYForX", () => {+  it.each([0.000001, 0.0001, 0.001, 0.01, 0.1, 0.5])(+    "solves the flat-start curve at %s without snapping to its end",+    (x) => {+      // For these control points X(t) = t^3 and Y(t) = t.+      expect(cubicBezierYForX(FLAT_START, x)).toBeCloseTo(Math.cbrt(x), 6);+    },+  );++  it.each([0.5, 0.9, 0.99, 0.999, 0.9999, 0.999999])(+    "solves the flat-end curve at %s without snapping to its start",+    (x) => {+      expect(cubicBezierYForX(FLAT_END, x)).toBeCloseTo(1 - Math.cbrt(1 - x), 6);+    },+  );++  it.each(Object.values(EASING_PRESETS))("preserves endpoints for %j", (curve) => {+    expect(cubicBezierYForX(curve, 0)).toBe(0);+    expect(cubicBezierYForX(curve, 1)).toBe(1);+  });++  it.each([0.001, 0.25, 0.5, 0.75, 0.999])("keeps linear progress at %s", (x) => {+    expect(cubicBezierYForX(EASING_PRESETS.linear, x)).toBeCloseTo(x, 5);+  });++  it("uses the solved curve when sampling an object transform", () => {+    const track: TimelineTrack = {+      objectUuid: "animated-object",+      keyframes: [+        { id: "start", time: 0, easing: FLAT_START, transform: {+          position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 },+        } },+        { id: "end", time: 10, easing: EASING_PRESETS.linear, transform: {+          position: { x: 100, y: 0, z: 0 }, rotation: { x: 1, y: 0, z: 0 }, scale: { x: 2, y: 1, z: 1 },+        } },+      ],+    };+    const sample = sampleTrackAt(track, 0.01)!;+    expect(sample.position.x).toBeCloseTo(10, 5);+    expect(sample.rotation.x).toBeCloseTo(0.1, 6);+    expect(sample.scale.x).toBeCloseTo(1.1, 6);+  });+});diff --git a/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts--- a/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts+++ b/frontend/libs/components/pagescene/src/lib/engine/timeline/interpolation.ts@@ -26,14 +26,21 @@ export const cubicBezierYForX = (easing: EasingSpec, x: number): number => {   if (x <= 0) return 0;   if (x >= 1) return 1;   let t = x;-  for (let i = 0; i < 8; i++) {+  let lower = 0;+  let upper = 1;+  for (let i = 0; i < 32; i++) {     const err = bezierAxis(t, easing.p1x, easing.p2x) - x;-    if (Math.abs(err) < 1e-5) break;+    if (err === 0) break;+    if (err < 0) lower = t;+    else upper = t;+    if (upper - lower < 1e-8) break;+     const d = bezierAxisDerivative(t, easing.p1x, easing.p2x);-    if (Math.abs(d) < 1e-6) break;-    t -= err / d;+    const next = Math.abs(d) > 1e-6 ? t - err / d : NaN;+    // Flat handles can send Newton outside the curve. Keep a bracket and+    // bisect whenever its next estimate would leave the valid interval.+    t = next > lower && next < upper ? next : (lower + upper) / 2;   }-  t = Math.max(0, Math.min(1, t));   return bezierAxis(t, easing.p1y, easing.p2y); };