Keys carry an easing for the span to the next key: linear, step, ease in, ease out, ease in-out (#101)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c92a9478f
commit
b278605c53
7 files changed
+331
-27
No files matched your search
@@ -8,10 +8,11 @@ import {
|
||||
animatedKind,
|
||||
animationPlayheadAt,
|
||||
frameCount,
|
||||
interpolationOf,
|
||||
targetAssetOf,
|
||||
trackLabel,
|
||||
} from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||||
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { drawBand, pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
||||
@@ -214,16 +215,17 @@ export class TimelineCanvas extends Widget {
|
||||
return { x: origin.x + HEADER_WIDTH + frameCentreX(this.view, frame), y: origin.y + RULER_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
/** The keys as drawn, with whether each is picked. */
|
||||
keysDrawn(): { trackId: string; frame: number; selected: boolean }[] {
|
||||
/** The keys as drawn, with whether each is picked and the easing its shape shows. */
|
||||
keysDrawn(): { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] {
|
||||
const picked = this.state.selectedKeys;
|
||||
const out: { trackId: string; frame: number; selected: boolean }[] = [];
|
||||
const out: { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] = [];
|
||||
for (const track of this.asset()?.tracks ?? [])
|
||||
for (const key of track.keys)
|
||||
out.push({
|
||||
trackId: track.id,
|
||||
frame: key.frame,
|
||||
selected: hasKey(picked, { trackId: track.id, frame: key.frame }),
|
||||
interpolation: interpolationOf(key),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -491,12 +493,7 @@ export class TimelineCanvas extends Widget {
|
||||
const shifted = drag && drag.keys.some((k) => sameKey(k, ref)) ? drag.delta : 0;
|
||||
const x = HEADER_WIDTH + frameCentreX(this.view, key.frame + shifted);
|
||||
if (x < HEADER_WIDTH - half || x > this.w + half) continue;
|
||||
this.overlay
|
||||
.moveTo(x, y - half)
|
||||
.lineTo(x + half, y)
|
||||
.lineTo(x, y + half)
|
||||
.lineTo(x - half, y)
|
||||
.closePath()
|
||||
drawKeyShape(this.overlay, interpolationOf(key), x, y, half)
|
||||
.fill(selected ? c.accent : c.text)
|
||||
.stroke({ width: 1, color: selected ? c.accentText : c.viewport });
|
||||
}
|
||||
@@ -556,3 +553,26 @@ export class TimelineCanvas extends Widget {
|
||||
this.hint.position.set(HEADER_WIDTH + Theme.padding * 2, RULER_HEIGHT + Theme.padding * 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key's outline tells its easing: the right half is the start of the way to the next key, the left
|
||||
* half its end, and a round half is a slow one. Linear is the diamond, ease-in-out the circle, a step
|
||||
* – which holds – the square.
|
||||
*/
|
||||
function drawKeyShape(
|
||||
g: Graphics,
|
||||
interpolation: KeyInterpolation,
|
||||
x: number,
|
||||
y: number,
|
||||
half: number,
|
||||
): Graphics {
|
||||
if (interpolation === 'step') return g.rect(x - half * 0.75, y - half * 0.75, half * 1.5, half * 1.5);
|
||||
const slowStart = interpolation === 'ease-in' || interpolation === 'ease-in-out';
|
||||
const slowEnd = interpolation === 'ease-out' || interpolation === 'ease-in-out';
|
||||
g.moveTo(x, y - half);
|
||||
if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2);
|
||||
else g.lineTo(x + half, y).lineTo(x, y + half);
|
||||
if (slowEnd) g.arc(x, y, half, Math.PI / 2, (3 * Math.PI) / 2);
|
||||
else g.lineTo(x - half, y).lineTo(x, y - half);
|
||||
return g.closePath();
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import {
|
||||
type KeyRef,
|
||||
RemoveAnimationKeysCommand,
|
||||
RemoveAnimationTracksCommand,
|
||||
SetAnimationKeyCommand,
|
||||
SetKeyInterpolationCommand,
|
||||
SetTrackRelativeCommand,
|
||||
} from '../../core/commands/animationCommands';
|
||||
import { animatableDefs, targetAssetOf, trackLabel } from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||||
import {
|
||||
animatableDefs,
|
||||
blends,
|
||||
interpolationOf,
|
||||
KEY_INTERPOLATIONS,
|
||||
targetAssetOf,
|
||||
trackLabel,
|
||||
} from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { Button } from '../../ui';
|
||||
import { openRowDialog, RowDialog } from '../animation/RowDialog';
|
||||
@@ -87,6 +96,7 @@ export function buildTrackSection(
|
||||
else if (keys.length > 1) {
|
||||
addGap(host);
|
||||
addTitle(host, `${keys.length} KEYS`);
|
||||
buildEasing(host, asset, keys);
|
||||
host.body.add(
|
||||
new Button('Delete keys', {
|
||||
variant: 'danger',
|
||||
@@ -120,6 +130,51 @@ function buildKey(host: InspectorHost, asset: AnimationAsset, ref: { trackId: st
|
||||
host.fields.set('value', row);
|
||||
host.body.add(row.row);
|
||||
if (track.relative) addNote(host, 'An offset, since the row is relative.', 'small');
|
||||
buildEasing(host, asset, [ref]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the picked keys blend into their next key – for all of them at once. It shows the easing of the
|
||||
* key picked last among those that can blend; a row that cannot blend offers none, it always jumps.
|
||||
*/
|
||||
function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly KeyRef[]): void {
|
||||
const shown = easingKey(host.state.project, asset, keys);
|
||||
if (!shown) return;
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{
|
||||
key: 'easing',
|
||||
label: 'Easing',
|
||||
kind: 'enum',
|
||||
default: 'linear',
|
||||
options: KEY_INTERPOLATIONS.map((i) => ({ ...i })),
|
||||
},
|
||||
interpolationOf(shown),
|
||||
{
|
||||
onInput: () => {},
|
||||
onChange: (value) =>
|
||||
host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value as KeyInterpolation)),
|
||||
},
|
||||
);
|
||||
host.fields.set('easing', row);
|
||||
host.body.add(row.row);
|
||||
addNote(host, 'Easing: the way from this key to the next one.', 'small');
|
||||
}
|
||||
|
||||
/** The last picked key on a row that blends; undefined when none of them can ease. */
|
||||
function easingKey(
|
||||
project: Project,
|
||||
asset: AnimationAsset,
|
||||
keys: readonly KeyRef[],
|
||||
): AnimationKey | undefined {
|
||||
for (const ref of [...keys].reverse()) {
|
||||
const track = asset.tracks.find((t) => t.id === ref.trackId);
|
||||
const kind = track && kindOf(project, asset, track);
|
||||
if (!kind || !blends(kind)) continue;
|
||||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||||
if (key) return key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Pushes the current values into the rows. False when the row is gone (rebuild). */
|
||||
@@ -139,6 +194,9 @@ export function refreshTrackSection(
|
||||
if (!key) return false;
|
||||
host.fields.get('value')?.setValue(key.value);
|
||||
}
|
||||
const eased = easingKey(host.state.project, asset, keys);
|
||||
if (Boolean(eased) !== host.fields.has('easing')) return false;
|
||||
if (eased) host.fields.get('easing')?.setValue(interpolationOf(eased));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user