Build your own easing curve and use it on keys and Lerp (#247)
game-maker / gate (push) Successful in 3m44s
game-maker / suite (push) Successful in 10m25s

The built-in eases cover the usual shapes, but a game often wants its
own: a coin that bounces twice, a door that overshoots and settles.
A new asset type holds such a curve, and keys and the Lerp node can
pick it instead of a built-in ease.

- New asset type "curve": points like in Vector art, x is t and y is
  the value. A new curve is the straight line from (0, 0) to (1, 1).
- One pure evaluation in src/core/model/curve.ts serves the timeline
  preview, the running game and the Lerp node.
- The curve always has one value per t. The end points stay at t 0
  and 1, points cannot pass each other, and a handle that reaches past
  its neighbour is shortened along its direction. Loading a hand-edited
  file applies the same rules.
- Curve tab: click on the line to insert a point that keeps the shape,
  click on free space to put one there, drag points and handles,
  double-click or Delete removes, Alt-click toggles round and sharp.
  Every step is one undo step.
- Presets: all 30 built-in eases plus Linear, fitted with adaptive
  subdivision. Each stays within 0.4 % of the built-in ease.
- Preview: a dot runs along the curve once a second, a second dot
  shows only the value. The Preview button stops it.
- Animation keys get "Curve" under Easing with a curve picker. Lerp
  gets "Curve" under Ease, and its result may overshoot b.
- A missing or unpicked curve runs linear. The inspector says so, the
  node shows it as a problem, and deleting a used curve warns with the
  number of keys and Lerp nodes.

Tests:
- ./check.sh: 180 files, 2612 tests, CHECK EXIT 0
- GM_STEPS=1-6,25,53: ALL PASSED, 309 checks
- New e2e module 53 with 13 checks, all with the real mouse. Expected
  values come from its own Bezier evaluation.
- Counter-checks bite: without Bezier evaluation 4 unit tests fail,
  without handle clamping 2. In the browser, a key that cannot find its
  curve, a click that sets no point and a handle drag that moves the
  point all turn checks red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 15:58:05 +02:00
1 parent 320d3e27e1
commit 393b8ec7f9
41 files changed
+1981 -67

No files matched your search

+19 -1
View File
@@ -160,9 +160,14 @@ export type EasingDirection = 'in' | 'out' | 'in-out';
/**
* How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. A
* curve is stored as `<curve>-<direction>`, except quad, which keeps its first name `ease-<direction>`.
* `curve:<id>` blends along a Curve asset (#247); an empty id is "Curve" with none picked yet.
*/
export type KeyInterpolation =
'linear' | 'step' | `ease-${EasingDirection}` | `${Exclude<EasingCurve, 'quad'>}-${EasingDirection}`;
| 'linear'
| 'step'
| `ease-${EasingDirection}`
| `${Exclude<EasingCurve, 'quad'>}-${EasingDirection}`
| `curve:${string}`;
/**
* A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot
@@ -424,6 +429,18 @@ export interface PaletteAsset extends AssetBase {
colors: number[];
}
/**
* An easing drawn by hand (#247). The points are those of an open vector art line, read as a graph:
* `x` is the share of the time (`t`, 0…1) and `y` the share of the way (`v`), which may leave 0…1 to
* overshoot. The first point sits at `t` 0 and the last at 1, the points never overtake each other
* in `t` and a handle never reaches past a neighbour – so the curve has one value for every `t`.
* `sanitizedCurve` holds every stored curve to that.
*/
export interface CurveAsset extends AssetBase {
type: 'curve';
points: VectorPoint[];
}
/**
* The graph is the motion of one particle: every output of its `Output` node is a function of the
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
@@ -449,6 +466,7 @@ export type Asset =
| PixelArtAsset
| PaletteAsset
| AnimationAsset
| CurveAsset
| TileAsset
| TilemapAsset
| StateMachineAsset
+5 -1
View File
@@ -13,6 +13,7 @@ export const ASSET_TYPES = [
'pixelart',
'palette',
'animation',
'curve',
'tile',
'tilemap',
'statemachine',
@@ -39,6 +40,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
pixelart: 'Pixel art',
palette: 'Palette',
animation: 'Animation',
curve: 'Curve',
tile: 'Tile',
tilemap: 'Tilemap',
statemachine: 'State machine',
@@ -54,7 +56,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. A
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
* is the tilemap.
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -64,6 +66,7 @@ export type PlaceableAssetType = Exclude<
| 'pixelart'
| 'palette'
| 'animation'
| 'curve'
| 'tile'
| 'statemachine'
| 'shader'
@@ -79,6 +82,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'pixelart',
'palette',
'animation',
'curve',
'tile',
'statemachine',
'shader',
+38 -11
View File
@@ -11,6 +11,7 @@ import type {
KeyInterpolation,
} from './Asset';
import type { AssetType } from './AssetTypes';
import { type CurveLookup, curveValue, NO_CURVES } from './curve';
import { treeOrder } from './hierarchy';
import { instanceChildId, resolveObjects } from './instance';
import { findAsset, type Project } from './Project';
@@ -115,7 +116,7 @@ export function blends(kind: string): boolean {
return kind === 'number' || kind === 'color';
}
export type EasingShape = 'linear' | 'step' | EasingCurve;
export type EasingShape = 'linear' | 'step' | EasingCurve | 'curve';
/** The inspector's first easing field, in the order it offers them; `linear` is what an absent field means. */
export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [
@@ -131,6 +132,7 @@ export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [
{ value: 'back', label: 'Back' },
{ value: 'elastic', label: 'Elastic' },
{ value: 'bounce', label: 'Bounce' },
{ value: 'curve', label: 'Curve' },
];
/** The second field, for a curve only. */
@@ -170,12 +172,18 @@ function isCurve(value: string): value is EasingCurve {
return Object.hasOwn(EASE_IN, value);
}
/** A stored interpolation split into the inspector's two fields; unknown text reads as `linear`. */
/**
* A stored interpolation split into the inspector's fields; unknown text reads as `linear`. `curve`
* is the id of the Curve asset (#247), `''` while none is picked.
*/
export function easingOf(interpolation: KeyInterpolation): {
shape: EasingShape;
direction?: EasingDirection;
curve?: string;
} {
if (interpolation === 'linear' || interpolation === 'step') return { shape: interpolation };
if (interpolation.startsWith(CURVE_PREFIX))
return { shape: 'curve', curve: interpolation.slice(CURVE_PREFIX.length) };
const match = /^([a-z]+)-(in|out|in-out)$/.exec(interpolation);
if (!match) return { shape: 'linear' };
const curve = match[1]!;
@@ -184,16 +192,23 @@ export function easingOf(interpolation: KeyInterpolation): {
return isCurve(curve) && curve !== 'quad' ? { shape: curve, direction } : { shape: 'linear' };
}
/** The stored form of the two fields; a curve without a direction eases both ways. */
export function easingValue(shape: EasingShape, direction: EasingDirection = 'in-out'): KeyInterpolation {
const CURVE_PREFIX = 'curve:';
/** The stored form of the fields; a curve without a direction eases both ways. */
export function easingValue(
shape: EasingShape,
direction: EasingDirection = 'in-out',
curve = '',
): KeyInterpolation {
if (shape === 'linear' || shape === 'step') return shape;
if (shape === 'curve') return `${CURVE_PREFIX}${curve}`;
return shape === 'quad' ? `ease-${direction}` : `${shape}-${direction}`;
}
export function isKeyInterpolation(value: unknown): value is KeyInterpolation {
if (typeof value !== 'string') return false;
const { shape, direction } = easingOf(value as KeyInterpolation);
return easingValue(shape, direction) === value;
const { shape, direction, curve } = easingOf(value as KeyInterpolation);
return easingValue(shape, direction, curve) === value;
}
export function interpolationOf(key: AnimationKey): KeyInterpolation {
@@ -203,11 +218,20 @@ export function interpolationOf(key: AnimationKey): KeyInterpolation {
/**
* How far along the way to the next key a value is, `t` the share of the time passed. Quad, the eases
* of old, reaches 0.25 at half the time going in, 0.75 going out, and 0.125 at a quarter going both.
* A Curve asset that is gone, or none picked, blends linearly.
*/
export function easedShare(interpolation: KeyInterpolation, t: number): number {
const { shape, direction } = easingOf(interpolation);
export function easedShare(
interpolation: KeyInterpolation,
t: number,
curves: CurveLookup = NO_CURVES,
): number {
const { shape, direction, curve } = easingOf(interpolation);
if (shape === 'linear') return t;
if (shape === 'step') return 0;
if (shape === 'curve') {
const points = curves(curve ?? '');
return points ? curveValue(points, t) : t;
}
const easeIn = EASE_IN[shape];
switch (direction ?? 'in-out') {
case 'in':
@@ -234,13 +258,15 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t:
* A row's value at a (fractional) frame: the first value before the first key, the last after the
* last one. Between two keys the earlier one's interpolation decides the way. A value that jumps –
* one that cannot blend, or a `step` key – holds until the next key in the direction of play: played
* in `reverse`, that is the key above. `undefined` only for a row without keys.
* in `reverse`, that is the key above. `undefined` only for a row without keys. `curves` finds the
* Curve assets a key may blend along.
*/
export function trackValueAt(
keys: readonly AnimationKey[],
kind: string,
frame: number,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
const first = keys[0];
if (!first) return undefined;
@@ -253,7 +279,7 @@ export function trackValueAt(
if (!blends(kind) || interpolation === 'step')
return reverse && frame > prev.frame ? next.value : prev.value;
const t = (frame - prev.frame) / (next.frame - prev.frame);
return blendValue(kind, prev.value, next.value, easedShare(interpolation, t));
return blendValue(kind, prev.value, next.value, easedShare(interpolation, t, curves));
}
return keys[keys.length - 1]!.value;
}
@@ -270,8 +296,9 @@ export function trackWriteAt(
frame: number,
base: PropertyValue | undefined,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
const value = trackValueAt(track.keys, def.kind, frame, reverse);
const value = trackValueAt(track.keys, def.kind, frame, reverse, curves);
if (value === undefined) return undefined;
if (def.kind !== 'number') return value;
const offset = track.relative && typeof base === 'number';
+17 -8
View File
@@ -14,6 +14,7 @@ import {
trackWriteAt,
} from './animation';
import type { AnimationAsset, AnimationTrack, Asset } from './Asset';
import { type CurveLookup, curveLookup } from './curve';
import { instanceChildId, objectRef } from './instance';
import { findAsset, type ObjectHolder, type Project } from './Project';
import { resolveSceneObjectProps } from '../properties/resolve';
@@ -37,9 +38,10 @@ export function previewHolder(
if (!root) return undefined;
const reset = playbackOf(animation) === 'once-reset' && frame >= frameCount(animation);
const holder = root.type === 'scene' ? sceneCopy(root) : placedHolder(root);
const at: ReadAt = { frame, reverse, curves: curveLookup(project) };
for (const track of reset ? [] : animation.tracks) {
if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame, reverse);
else writeObject(project, holder, previewObjectId(root, track.target), track, frame, reverse);
if (root.type === 'scene' && track.target === '') writeScene(holder, track, at);
else writeObject(project, holder, previewObjectId(root, track.target), track, at);
}
return holder;
}
@@ -68,11 +70,19 @@ function placedHolder(root: Asset): ObjectHolder {
return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
}
/** Where on the timeline a row is read, and the Curve assets its keys may blend along. */
interface ReadAt {
frame: number;
reverse: boolean;
curves: CurveLookup;
}
/** A row on the scene itself: its value goes into the copy's own properties. */
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void {
function writeScene(holder: ObjectHolder, track: AnimationTrack, at: ReadAt): void {
const def = animatableDefs('scene').find((d) => d.key === track.property);
if (!def) return;
const value = trackWriteAt(track, def, frame, holder.props[track.property] ?? def.default, reverse);
const base = holder.props[track.property] ?? def.default;
const value = trackWriteAt(track, def, at.frame, base, at.reverse, at.curves);
if (value !== undefined) holder.props[track.property] = value;
}
@@ -81,8 +91,7 @@ function writeObject(
holder: ObjectHolder,
id: string,
track: AnimationTrack,
frame: number,
reverse: boolean,
at: ReadAt,
): void {
const ref = objectRef(project, holder, id);
const asset = ref && findAsset(project, ref.object.assetId);
@@ -93,10 +102,10 @@ function writeObject(
if (!def) return;
const key = track.property;
if (isPositionRow(asset.type, key)) {
const value = trackWriteAt(track, def, frame, ref.object.position[key], reverse);
const value = trackWriteAt(track, def, at.frame, ref.object.position[key], at.reverse, at.curves);
if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) });
return;
}
const value = trackWriteAt(track, def, frame, props[key], reverse);
const value = trackWriteAt(track, def, at.frame, props[key], at.reverse, at.curves);
if (value !== undefined) ref.setOverride(key, value);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* 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';
/** How many animation keys and `Lerp` nodes ease 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) => n.type === LERP && 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 };
}
+111
View File
@@ -0,0 +1,111 @@
/**
* 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));
}
export 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;
}
+1
View File
@@ -29,5 +29,6 @@ export function graphLookup(project: Project): GraphLookup {
start: startStateOf(asset.graph, asset.start),
};
},
curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null,
};
}