/** * The built-in eases as starting points for a Curve asset (#247). Each is traced by a few Bezier * segments: the points sit on the ease, and each handle points along the ease's slope on its side, a * third of the way to the neighbour (a cubic Hermite piece). A segment that strays more than * `TOLERANCE` from the ease is halved, and only that one – so a quad keeps its single segment, and * circ gets ever shorter ones only towards its upright end. */ import type { EasingDirection, VectorPoint } from './Asset'; import type { Vec2 } from './Properties'; import { EASING_DIRECTIONS, EASING_SHAPES, easedShare, type EasingShape, easingValue } from './animation'; import { curveValue, linearCurve } from './curve'; export interface CurvePreset { label: string; /** The ease it traces; presets of one shape share it, so a menu can group them. */ shape: EasingShape; build(): VectorPoint[]; } const TOLERANCE = 0.004; /** Enough for a bounce; a curve with more points than this is no longer one to edit by hand. */ const MAX_POINTS = 64; const MAX_ROUNDS = 16; /** Where each segment is compared with the ease. */ const SAMPLES = 24; /** The step of the one-sided slopes: small, and never onto the point itself, where expo jumps to 0. */ const H = 1e-6; export function curvePresets(): CurvePreset[] { const presets: CurvePreset[] = [{ label: 'Linear', shape: 'linear', build: linearCurve }]; for (const { value: shape, label } of EASING_SHAPES) { if (shape === 'linear' || shape === 'step' || shape === 'curve') continue; for (const direction of EASING_DIRECTIONS) presets.push({ label: `${label} ${direction.label.toLowerCase()}`, shape, build: () => easeCurve(shape, direction.value), }); } return presets; } export function easeCurve(shape: EasingShape, direction: EasingDirection): VectorPoint[] { const interpolation = easingValue(shape, direction); return curveFromEase((t) => easedShare(interpolation, t)); } function curveFromEase(ease: (t: number) => number): VectorPoint[] { let at = [0, 1]; let points = traced(ease, at); for (let round = 0; round < MAX_ROUNDS; round++) { const next = [0]; for (let i = 1; i < at.length; i++) { const strays = segmentGap(points[i - 1]!, points[i]!, ease) > TOLERANCE; if (strays && next.length + at.length - i < MAX_POINTS) next.push((at[i - 1]! + at[i]!) / 2); next.push(at[i]!); } if (next.length === at.length) break; at = next; points = traced(ease, at); } return points; } function traced(ease: (t: number) => number, at: readonly number[]): VectorPoint[] { const value = (x: number) => clean(ease(x)); return at.map((x, i) => { const point: VectorPoint = { x, y: value(x) }; const before = at[i - 1]; const after = at[i + 1]; const slopeIn = before === undefined ? null : (ease(x - H) - ease(x - 2 * H)) / H; const slopeOut = after === undefined ? null : (ease(x + 2 * H) - ease(x + H)) / H; if (before !== undefined && slopeIn !== null) point.in = handle((before - x) / 3, slopeIn, point.y - value(before)); if (after !== undefined && slopeOut !== null) point.out = handle((after - x) / 3, slopeOut, value(after) - point.y); if ( slopeIn !== null && slopeOut !== null && Math.abs(slopeIn - slopeOut) < 1e-3 * Math.max(1, Math.abs(slopeOut)) ) point.mode = 'smooth'; return point; }); } /** * A handle along `slope`, reaching `reach` in `t`. Where the ease stands upright – circ at its steep * end – the slope is huge, so the handle is held to the rise of its segment plus one step, keeping * its direction: nearly vertical, as the ease is there, instead of shooting off the graph. */ function handle(reach: number, slope: number, rise: number): Vec2 { const y = slope * reach; const most = Math.abs(rise) + Math.abs(reach) * 3; const scale = Math.abs(y) > most ? most / Math.abs(y) : 1; return { x: reach * scale, y: y * scale }; } /** Rounding noise off, so an ease that starts at 0 starts at 0 and not at 2e-16. */ function clean(value: number): number { return Math.round(value * 1e12) / 1e12; } function segmentGap(from: VectorPoint, to: VectorPoint, ease: (t: number) => number): number { let gap = 0; for (let i = 1; i < SAMPLES; i++) { const t = from.x + ((to.x - from.x) * i) / SAMPLES; gap = Math.max(gap, Math.abs(curveValue([from, to], t) - ease(t))); } return gap; }