A timed Set sound FX parameters ran only linearly, and a Lerp had four quadratic eases of its own. An animation key already had the full set. Now all three offer the same choice: Linear, Step, the ten curves from Sine to Bounce with In, Out or In-out, and a drawn Curve asset. - easingParams in src/core/graph/nodes/easing.ts gives a node the fields Easing, Direction and Curve from the key's own lists, and easingOfParams reads them back as a key interpolation. - There is no migration. A Lerp saved with the old in, out or inOut runs linear until its easing is picked again. - Set sound FX parameters hands the easing to the player. Linear and Step stay ramps of the audio clock, every other easing goes to setValueCurveAtTime as 200 samples a second, in semitones and dB. - Step now reaches its end at t 1. A key never asked for that, it reads the next key itself, so animations play as before. - The curve-in-use count and the warning for a gone curve cover the sound node too. Tests: - node-catalog: Lerp with cubic in, quad out and in-out, a curve without a direction, step, an unknown easing as linear. The sound node hands on a curve, step and a gone curve as linear. - sound-library: an eased change becomes a curve in semitones and decibels, a step a jump at the end. - node-catalog and curve after dropping the old values: 399, green. - e2e 6, 34, 53 on melektaus-atx: ALL PASSED, 198 checks. In Chrome a 2 s Quad in from depth 7 to -5 read speed 0.864 early and 0.5 at the end, a 1 s Step held 1 and then jumped to 0.5. - Counter-check: with curves sent as linear ramps, the curve test of the sound library fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|