From 4dc53bb794aff207d67d3f1eade4d9be3772ee40 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Wed, 23 Sep 2020 11:58:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(=F0=9F=A7=AE):=20cubicBezierYForX()=20(#35?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Math.ts | 91 ++++++++++++++++++++++++++++++++++++++ src/Vectors.ts | 17 +++++++ src/__tests__/Math.test.ts | 22 +++++++++ 3 files changed, 130 insertions(+) diff --git a/src/Math.ts b/src/Math.ts index b851c9e0..cc810111 100644 --- a/src/Math.ts +++ b/src/Math.ts @@ -1,3 +1,5 @@ +import { Vector } from "./Vectors"; + export const bin = (value: boolean): 0 | 1 => { "worklet"; return value ? 1 : 0; @@ -66,3 +68,92 @@ export const round = (value: number, precision = 0) => { const p = Math.pow(10, precision); return Math.round(value * p) / p; }; + +// https://stackoverflow.com/questions/27176423/function-to-solve-cubic-equation-analytically +const cuberoot = (x: number) => { + "worklet"; + const y = Math.pow(Math.abs(x), 1 / 3); + return x < 0 ? -y : y; +}; + +const solveCubic = (a: number, b: number, c: number, d: number) => { + "worklet"; + if (Math.abs(a) < 1e-8) { + // Quadratic case, ax^2+bx+c=0 + a = b; + b = c; + c = d; + if (Math.abs(a) < 1e-8) { + // Linear case, ax+b=0 + a = b; + b = c; + if (Math.abs(a) < 1e-8) { + // Degenerate case + return []; + } + return [-b / a]; + } + + const D = b * b - 4 * a * c; + if (Math.abs(D) < 1e-8) { + return [-b / (2 * a)]; + } else if (D > 0) { + return [(-b + Math.sqrt(D)) / (2 * a), (-b - Math.sqrt(D)) / (2 * a)]; + } + return []; + } + + // Convert to depressed cubic t^3+pt+q = 0 (subst x = t - b/3a) + const p = (3 * a * c - b * b) / (3 * a * a); + const q = (2 * b * b * b - 9 * a * b * c + 27 * a * a * d) / (27 * a * a * a); + let roots; + + if (Math.abs(p) < 1e-8) { + // p = 0 -> t^3 = -q -> t = -q^1/3 + roots = [cuberoot(-q)]; + } else if (Math.abs(q) < 1e-8) { + // q = 0 -> t^3 + pt = 0 -> t(t^2+p)=0 + roots = [0].concat(p < 0 ? [Math.sqrt(-p), -Math.sqrt(-p)] : []); + } else { + const D = (q * q) / 4 + (p * p * p) / 27; + if (Math.abs(D) < 1e-8) { + // D = 0 -> two roots + roots = [(-1.5 * q) / p, (3 * q) / p]; + } else if (D > 0) { + // Only one real root + const u = cuberoot(-q / 2 - Math.sqrt(D)); + roots = [u - p / (3 * u)]; + } else { + // D < 0, three roots, but needs to use complex numbers/trigonometric solution + const u = 2 * Math.sqrt(-p / 3); + const t = Math.acos((3 * q) / p / u) / 3; // D < 0 implies p < 0 and acos argument in [-1..1] + const k = (2 * Math.PI) / 3; + roots = [u * Math.cos(t), u * Math.cos(t - k), u * Math.cos(t - 2 * k)]; + } + } + + // Convert back from depressed cubic + for (let i = 0; i < roots.length; i++) { + roots[i] -= b / (3 * a); + } + + return roots; +}; + +export const cubicBezierYForX = ( + x: number, + a: Vector, + b: Vector, + c: Vector, + d: Vector +) => { + "worklet"; + const pa = -a.x + 3 * b.x - 3 * c.x + d.x; + const pb = 3 * a.x - 6 * b.x + 3 * c.x; + const pc = -3 * a.x + 3 * b.x; + const pd = a.x - x; + const [t] = solveCubic(pa, pb, pc, pd).filter( + (root) => root >= 0 && root <= 1 + ); + return cubicBezier(t, a.y, b.y, c.y, d.y); +}; diff --git a/src/Vectors.ts b/src/Vectors.ts index 98f0d156..329ee09a 100644 --- a/src/Vectors.ts +++ b/src/Vectors.ts @@ -13,3 +13,20 @@ export const useVector = ( const y = useSharedValue(y1 ?? x1); return { x, y }; }; + +type Create = { + (): Vector<0>; + >(x: T, y?: T): Vector; +}; + +const create: Create = >(x?: T, y?: T) => { + "worklet"; + return { + x: x ?? 0, + y: y ?? x ?? 0, + }; +}; + +export const vec = { + create, +}; diff --git a/src/__tests__/Math.test.ts b/src/__tests__/Math.test.ts index b05ee106..40652fac 100644 --- a/src/__tests__/Math.test.ts +++ b/src/__tests__/Math.test.ts @@ -8,7 +8,9 @@ import { between, toRad, toDeg, + cubicBezierYForX, } from "../Math"; +import { vec } from "../Vectors"; test("bin()", () => { expect(bin(true)).toBe(1); @@ -58,3 +60,23 @@ test("toRad()", () => { expect(toDeg(Math.PI)).toBe(180); expect(toDeg(Math.PI / 4)).toBe(45); }); + +test("cubicBezierYForX()", () => { + const x = 198; + const y = 94; + const a = vec.create(20, 250); + const b = vec.create(30, 20); + const c = vec.create(203, 221); + const d = vec.create(220, 20); + expect(Math.round(cubicBezierYForX(x, a, b, c, d))).toBe(y); +}); + +test("cubicBezierYForX2()", () => { + const x = 116; + const y = 139; + const a = vec.create(59, 218); + const b = vec.create(131, 39); + const c = vec.create(204, 223); + const d = vec.create(227, 89); + expect(Math.round(cubicBezierYForX(x, a, b, c, d))).toBe(y); +});