/** * Curve assets (#247): an easing drawn by hand, read wherever a key or a `Lerp` node picks one. * One pure function evaluates it for the Curve tab, the animation player and the graph alike. * * A segment is a cubic Bezier from one point to the next, a line where neither end has a handle – * the same rule vector art draws by. It is solved for `t` like CSS `cubic-bezier`: with both inner * control points between the ends in `t`, `t` rises monotonically along the segment, so bisection * finds the one place a `t` is reached. */ import { isGraphAsset, type VectorPoint } from './Asset'; import { findAssetOf, type Project } from './Project'; import type { Vec2 } from './Properties'; import { cloneVectorPoint } from './vectorart'; /** The node type that eases along a curve, and its setting naming the curve. */ export const LERP = 'Lerp'; export const LERP_CURVE = 'curve'; /** Every node type with the easing settings of a `Lerp`, the curve among them. */ export const EASED_NODES: readonly string[] = [LERP, 'SetSoundFxParameters']; /** How many animation keys and eased nodes run along a curve – what deleting it has to warn about. */ export function curveUsages(project: Project, curveId: string): { keys: number; nodes: number } { let keys = 0; let nodes = 0; for (const asset of project.assets) { if (asset.type === 'animation') for (const track of asset.tracks) keys += track.keys.filter((key) => key.interpolation === `curve:${curveId}`).length; if (isGraphAsset(asset)) nodes += asset.graph.nodes.filter( (n) => EASED_NODES.includes(n.type) && n.params[LERP_CURVE] === curveId, ).length; } return { keys, nodes }; } /** The points of the curve an id names; undefined once it is gone. */ export type CurveLookup = (curveId: string) => readonly VectorPoint[] | undefined; export const NO_CURVES: CurveLookup = () => undefined; export function curveLookup(project: Project): CurveLookup { return (curveId) => (curveId ? findAssetOf(project, curveId, 'curve')?.points : undefined); } /** What a new curve is: a straight line from (0, 0) to (1, 1), the same as `linear`. */ export function linearCurve(): VectorPoint[] { return [ { x: 0, y: 0 }, { x: 1, y: 1 }, ]; } /** Halvings of the segment; 2^-40 of a segment is far below anything a value could show. */ const BISECTIONS = 40; /** * The value at `t`, held to 0…1. Between two points the segment decides; a segment without width – * two points on one `t` – is a jump, and the later point's value wins from there on. */ export function curveValue(points: readonly VectorPoint[], t: number): number { const first = points[0]; if (!first) return t; const at = Math.min(Math.max(Number.isFinite(t) ? t : 0, 0), 1); if (at <= first.x) return first.y; for (let i = 1; i < points.length; i++) { const to = points[i]!; if (at > to.x) continue; return segmentValue(points[i - 1]!, to, at); } return points[points.length - 1]!.y; } function segmentValue(from: VectorPoint, to: VectorPoint, t: number): number { const span = to.x - from.x; if (span <= 0) return to.y; if (!from.out && !to.in) return from.y + ((to.y - from.y) * (t - from.x)) / span; // Held between the ends in `t`, whatever is stored: that is what keeps the solve unique. const c1x = clamp(from.x + (from.out?.x ?? 0), from.x, to.x); const c2x = clamp(to.x + (to.in?.x ?? 0), from.x, to.x); const c1y = from.y + (from.out?.y ?? 0); const c2y = to.y + (to.in?.y ?? 0); let lo = 0; let hi = 1; for (let step = 0; step < BISECTIONS; step++) { const mid = (lo + hi) / 2; if (cubic(from.x, c1x, c2x, to.x, mid) < t) lo = mid; else hi = mid; } return cubic(from.y, c1y, c2y, to.y, (lo + hi) / 2); } function cubic(a: number, b: number, c: number, d: number, s: number): number { const u = 1 - s; return u * u * u * a + 3 * u * u * s * b + 3 * u * s * s * c + s * s * s * d; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } /** * The curve as the rules want it, whatever was handed in. At least two points; sorted by `t`, `t` * held to 0…1, the first at 0 and the last at 1. The first point has no handle in and the last none * out. A handle pointing backwards in `t` is turned upright, and one reaching past its neighbour is * shortened along its own direction, so a smooth point stays smooth. What does not come out a number * is dropped; with fewer than two points left the curve is linear. */ export function sanitizedCurve(points: readonly VectorPoint[]): VectorPoint[] { const kept = points .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) .map((p) => ({ ...cloneVectorPoint(p), x: clamp(p.x, 0, 1) })) .sort((a, b) => a.x - b.x); if (kept.length < 2) return linearCurve(); kept[0]!.x = 0; kept[kept.length - 1]!.x = 1; for (const [i, point] of kept.entries()) { const before = kept[i - 1]; const after = kept[i + 1]; const handleIn = before && point.in ? heldHandle(point.in, before.x - point.x) : undefined; const handleOut = after && point.out ? heldHandle(point.out, after.x - point.x) : undefined; if (handleIn) point.in = handleIn; else delete point.in; if (handleOut) point.out = handleOut; else delete point.out; if (!point.in && !point.out) delete point.mode; } return kept; } /** A handle kept on its own side of the point, reaching `reach` in `t` at most (negative for `in`). */ function heldHandle(handle: Vec2, reach: number): Vec2 | undefined { const finite = Number.isFinite(handle.x) && Number.isFinite(handle.y); if (!finite) return undefined; let { x, y } = handle; if (x * reach < 0) x = 0; if (Math.abs(x) > Math.abs(reach)) { const scale = Math.abs(reach) / Math.abs(x); x *= scale; y *= scale; } return Math.hypot(x, y) < 1e-9 ? undefined : { x, y }; } /** The lowest and highest value the curve can reach – its points and handles – and never less than 0…1. */ export function curveExtent(points: readonly VectorPoint[]): { min: number; max: number } { let min = 0; let max = 1; for (const point of points) for (const y of [point.y, point.y + (point.in?.y ?? 0), point.y + (point.out?.y ?? 0)]) { min = Math.min(min, y); max = Math.max(max, y); } return { min, max }; }