The scene view and the animation preview share one camera, the three form dialogs one base, and keys are found through one set of helpers (#85)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf02c9e8b3
commit
2a390e52ff
22 files changed
+418
-452
No files matched your search
@@ -2,7 +2,10 @@ import { newId } from '../ids';
|
||||
import {
|
||||
ANIMATION_ROOT_TYPES,
|
||||
blends,
|
||||
findTrack,
|
||||
hasKey,
|
||||
isKeyInterpolation,
|
||||
type KeyRef,
|
||||
normalizeKeys,
|
||||
targetAssetOf,
|
||||
targetDefs,
|
||||
@@ -16,12 +19,6 @@ import { coerceValue } from '../properties/PropertySchema';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './require';
|
||||
|
||||
/** A key on one row, as the timeline selects it. */
|
||||
export interface KeyRef {
|
||||
trackId: string;
|
||||
frame: number;
|
||||
}
|
||||
|
||||
function requireAnimation(project: Project, assetId: string): AnimationAsset {
|
||||
const asset = requireAsset(project, assetId);
|
||||
if (asset.type !== 'animation') throw new CommandError(`${asset.name} is not an animation`);
|
||||
@@ -29,11 +26,27 @@ function requireAnimation(project: Project, assetId: string): AnimationAsset {
|
||||
}
|
||||
|
||||
function requireTrack(asset: AnimationAsset, trackId: string): AnimationTrack {
|
||||
const track = asset.tracks.find((t) => t.id === trackId);
|
||||
const track = findTrack(asset, trackId);
|
||||
if (!track) throw new CommandError(`${asset.name} has no such row`);
|
||||
return track;
|
||||
}
|
||||
|
||||
function requireKey(asset: AnimationAsset, ref: KeyRef): { track: AnimationTrack; key: AnimationKey } {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||||
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
return { track, key };
|
||||
}
|
||||
|
||||
function requireFrame(frame: number, message: string): void {
|
||||
if (!Number.isInteger(frame) || frame < 0) throw new CommandError(message);
|
||||
}
|
||||
|
||||
/** `Remove key`, `Remove 3 keys` – how a key command names itself. */
|
||||
function keysLabel(verb: string, count: number): string {
|
||||
return count === 1 ? `${verb} key` : `${verb} ${count} keys`;
|
||||
}
|
||||
|
||||
/** The kind of `property` on the object `target` names, refusing what cannot be animated there. */
|
||||
function requireAnimatable(
|
||||
project: Project,
|
||||
@@ -217,8 +230,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
|
||||
protected apply(project: Project, asset: AnimationAsset): void {
|
||||
const track = requireTrack(asset, this.trackId);
|
||||
if (!Number.isInteger(this.frame) || this.frame < 0)
|
||||
throw new CommandError('A key sits on a whole frame from 0');
|
||||
requireFrame(this.frame, 'A key sits on a whole frame from 0');
|
||||
const def = trackDef(project, asset, track);
|
||||
if (!def) throw new CommandError('The row aims at something that is gone');
|
||||
const current = trackValueAt(track.keys, def.kind, this.frame);
|
||||
@@ -250,9 +262,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
|
||||
if (!isKeyInterpolation(this.interpolation)) throw new CommandError('No such easing');
|
||||
let changed = 0;
|
||||
for (const ref of this.keys) {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||||
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
const { track, key } = requireKey(asset, ref);
|
||||
const kind = trackDef(project, asset, track)?.kind;
|
||||
if (kind !== undefined && !blends(kind)) continue;
|
||||
if (this.interpolation === 'linear') delete key.interpolation;
|
||||
@@ -271,16 +281,14 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
|
||||
private readonly keys: readonly KeyRef[],
|
||||
) {
|
||||
super(assetId);
|
||||
this.label = keys.length === 1 ? 'Remove key' : `Remove ${keys.length} keys`;
|
||||
this.label = keysLabel('Remove', keys.length);
|
||||
}
|
||||
|
||||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||||
if (this.keys.length === 0) throw new CommandError('No keys to remove');
|
||||
for (const ref of this.keys) {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
const at = track.keys.findIndex((k) => k.frame === ref.frame);
|
||||
if (at < 0) throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
track.keys.splice(at, 1);
|
||||
const { track, key } = requireKey(asset, ref);
|
||||
track.keys.splice(track.keys.indexOf(key), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,8 +307,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
|
||||
private readonly copy = false,
|
||||
) {
|
||||
super(assetId);
|
||||
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
|
||||
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
|
||||
this.label = keysLabel(copy ? 'Duplicate' : 'Move', keys.length);
|
||||
}
|
||||
|
||||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||||
@@ -308,9 +315,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
|
||||
if (this.copy && this.delta === 0) throw new CommandError('A duplicate has to land somewhere else');
|
||||
const byTrack = new Map<string, Set<number>>();
|
||||
for (const ref of this.keys) {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
if (!track.keys.some((k) => k.frame === ref.frame))
|
||||
throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
requireKey(asset, ref);
|
||||
if (ref.frame + this.delta < 0) throw new CommandError('A key cannot move before frame 0');
|
||||
byTrack.set(ref.trackId, (byTrack.get(ref.trackId) ?? new Set()).add(ref.frame));
|
||||
}
|
||||
@@ -342,6 +347,10 @@ export interface KeyClipboard {
|
||||
rows: ClipboardRow[];
|
||||
}
|
||||
|
||||
export function clipboardKeyCount(clipboard: KeyClipboard): number {
|
||||
return clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
|
||||
}
|
||||
|
||||
/** The picked keys as a clipboard, or null when none of them exists. */
|
||||
export function copyAnimationKeys(
|
||||
project: Project,
|
||||
@@ -351,7 +360,7 @@ export function copyAnimationKeys(
|
||||
const picked = asset.tracks
|
||||
.map((track) => ({
|
||||
track,
|
||||
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
|
||||
keys: track.keys.filter((k) => hasKey(refs, { trackId: track.id, frame: k.frame })),
|
||||
}))
|
||||
.filter((p) => p.keys.length > 0);
|
||||
if (picked.length === 0) return null;
|
||||
@@ -391,13 +400,11 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
|
||||
private readonly intoTrackId = '',
|
||||
) {
|
||||
super(assetId);
|
||||
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
|
||||
this.label = count === 1 ? 'Paste key' : `Paste ${count} keys`;
|
||||
this.label = keysLabel('Paste', clipboardKeyCount(clipboard));
|
||||
}
|
||||
|
||||
protected apply(project: Project, asset: AnimationAsset): void {
|
||||
if (!Number.isInteger(this.frame) || this.frame < 0)
|
||||
throw new CommandError('Keys paste onto a whole frame from 0');
|
||||
requireFrame(this.frame, 'Keys paste onto a whole frame from 0');
|
||||
this.pasted = [];
|
||||
this.skipped = 0;
|
||||
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
|
||||
@@ -434,7 +441,7 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
|
||||
|
||||
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
|
||||
return asset.id === this.clipboard.assetId
|
||||
? asset.tracks.find((t) => t.id === row.trackId)
|
||||
? findTrack(asset, row.trackId)
|
||||
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
|
||||
}
|
||||
}
|
||||
@@ -467,8 +474,7 @@ export class ShiftAnimationFramesCommand extends TimelineCommand {
|
||||
}
|
||||
|
||||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||||
if (!Number.isInteger(this.at) || this.at < 0)
|
||||
throw new CommandError('Frames are inserted at a whole frame from 0');
|
||||
requireFrame(this.at, 'Frames are inserted at a whole frame from 0');
|
||||
if (!Number.isInteger(this.count) || this.count === 0)
|
||||
throw new CommandError('Give a whole number of frames');
|
||||
let touched = 0;
|
||||
|
||||
Reference in new issue
Block a user