Files
feuerwerk/src/core/model/curve.ts
T
adminandClaude Opus 5.5 e7a360db73
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 15m36s
Offer the easings of an animation key on Lerp and sound FX ramps
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>
2026-10-05 22:05:00 +02:00

156 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}