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
new file mode 100644@@ -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);+ });+});@@ -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); };