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;
|
||||
|
||||
@@ -17,9 +17,9 @@ export type Playback = (typeof PLAYBACKS)[number];
|
||||
export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene'];
|
||||
|
||||
/** Position lives on the object, not in its property bag, so the timeline offers it by name. */
|
||||
export const POSITION_KEYS = ['x', 'y'] as const;
|
||||
const POSITION_KEYS = ['x', 'y'] as const;
|
||||
|
||||
export function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
|
||||
function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
|
||||
return (POSITION_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function fpsOf(asset: AnimationAsset): number {
|
||||
}
|
||||
|
||||
/** The frame of the last key over all rows; -1 for an animation without keys. */
|
||||
export function lastKeyFrame(asset: AnimationAsset): number {
|
||||
function lastKeyFrame(asset: AnimationAsset): number {
|
||||
let last = -1;
|
||||
for (const track of asset.tracks) {
|
||||
const key = track.keys[track.keys.length - 1];
|
||||
@@ -133,7 +133,7 @@ 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. The eases are
|
||||
* quadratic: ease-in reaches 0.25 at half the time, ease-out 0.75, ease-in-out 0.5 but 0.125 at a quarter.
|
||||
*/
|
||||
export function easedShare(interpolation: KeyInterpolation, t: number): number {
|
||||
function easedShare(interpolation: KeyInterpolation, t: number): number {
|
||||
switch (interpolation) {
|
||||
case 'linear':
|
||||
return t;
|
||||
@@ -210,6 +210,16 @@ export function animatedKind(type: AssetType, key: string): string | undefined {
|
||||
return animatableDefs(type).find((d) => d.key === key)?.kind;
|
||||
}
|
||||
|
||||
/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */
|
||||
export function animatedKindOf(
|
||||
project: Project,
|
||||
asset: Asset,
|
||||
bag: PropertyBag,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind;
|
||||
}
|
||||
|
||||
function hasPosition(type: AssetType): boolean {
|
||||
return type !== 'scene' && type !== 'behavior';
|
||||
}
|
||||
@@ -501,6 +511,28 @@ export function pauseAnimation(holder: PlayerHolder, animationId: string): void
|
||||
if (animationId === '' || player.animationId === animationId) player.playing = false;
|
||||
}
|
||||
|
||||
/** A key on one row, as the timeline selects it. */
|
||||
export interface KeyRef {
|
||||
trackId: string;
|
||||
frame: number;
|
||||
}
|
||||
|
||||
export function sameKey(a: KeyRef, b: KeyRef): boolean {
|
||||
return a.trackId === b.trackId && a.frame === b.frame;
|
||||
}
|
||||
|
||||
export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean {
|
||||
return keys.some((k) => sameKey(k, key));
|
||||
}
|
||||
|
||||
export function findTrack(asset: AnimationAsset, trackId: string): AnimationTrack | undefined {
|
||||
return asset.tracks.find((t) => t.id === trackId);
|
||||
}
|
||||
|
||||
export function findKey(asset: AnimationAsset, ref: KeyRef): AnimationKey | undefined {
|
||||
return findTrack(asset, ref.trackId)?.keys.find((k) => k.frame === ref.frame);
|
||||
}
|
||||
|
||||
/** Sorted by frame, at most one per frame (the later wins), frames whole and not negative. */
|
||||
export function normalizeKeys(keys: readonly AnimationKey[]): AnimationKey[] {
|
||||
const byFrame = new Map<number, AnimationKey>();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import {
|
||||
animatableDefs,
|
||||
animatableDefsOf,
|
||||
animatedKindOf,
|
||||
frameCount,
|
||||
isPositionRow,
|
||||
PLACED_ROOT_ID,
|
||||
@@ -18,9 +18,6 @@ import { instanceChildId, objectRef } from './instance';
|
||||
import { findAsset, type ObjectHolder, type Project } from './Project';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
|
||||
/** The one object the preview places the root on – what a row aimed at the root itself writes to. */
|
||||
export const PREVIEW_ROOT_ID = PLACED_ROOT_ID;
|
||||
|
||||
/**
|
||||
* The holder the preview draws at `frame`: a copy of the scene for a scene root, otherwise a holder
|
||||
* of one object placing the root at the origin, its content resolved from the template like any
|
||||
@@ -50,7 +47,7 @@ export function previewHolder(
|
||||
/** The id the preview draws a row's target under – for a check that wants to look at it. */
|
||||
export function previewObjectId(root: Asset | undefined, target: string): string {
|
||||
if (root?.type === 'scene') return target;
|
||||
return target === '' ? PREVIEW_ROOT_ID : instanceChildId(PREVIEW_ROOT_ID, target);
|
||||
return target === '' ? PLACED_ROOT_ID : instanceChildId(PLACED_ROOT_ID, target);
|
||||
}
|
||||
|
||||
/** Deep enough that nothing a row writes – a position, an override, an instance edit – is the document's. */
|
||||
@@ -68,7 +65,7 @@ function sceneCopy(scene: ObjectHolder): ObjectHolder {
|
||||
}
|
||||
|
||||
function placedHolder(root: Asset): ObjectHolder {
|
||||
return { id: PREVIEW_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
|
||||
return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
|
||||
}
|
||||
|
||||
/** A row on the scene itself: its value goes into the copy's own properties. */
|
||||
@@ -92,7 +89,7 @@ function writeObject(
|
||||
if (!ref || !asset) return;
|
||||
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
|
||||
const props = resolveSceneObjectProps(project, asset, ref.object);
|
||||
const kind = animatableDefsOf(project, asset, props).find((d) => d.key === track.property)?.kind;
|
||||
const kind = animatedKindOf(project, asset, props, track.property);
|
||||
if (!kind) return;
|
||||
const key = track.property;
|
||||
if (isPositionRow(asset.type, key)) {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { propertyNodeParams } from '../properties/declaredRows';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
PARTICLE_PROPERTY,
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
propertyNameOf,
|
||||
} from '../properties/particles';
|
||||
import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles';
|
||||
|
||||
/** What one particle is, at one moment: everything a graph may read about it. */
|
||||
export interface ParticleEnv {
|
||||
@@ -249,12 +243,7 @@ export const ParticleProperty: ParticleNodeDef = {
|
||||
description: 'A number the source sets: a property row on the placed source, and a value here.',
|
||||
inputs: [],
|
||||
outputs: [numberOut('value')],
|
||||
params: [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
|
||||
],
|
||||
params: propertyNodeParams(NUMBER_STEP),
|
||||
derivePorts: ({ node }) => ({ label: propertyNameOf(node) || ParticleProperty.label }),
|
||||
emit: (_inputs, params, ctx) => {
|
||||
// The slot is bound once, here: reading it per particle would cost a lookup by name each time.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Graph, GraphNode } from '../graph/Graph';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import type { FlatDef } from './PropertySchema';
|
||||
import { flatDef, type FlatDef, type PropertyDef } from './PropertySchema';
|
||||
|
||||
/** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */
|
||||
export const PROPERTY_NAME_PARAM = 'name';
|
||||
@@ -8,6 +8,16 @@ export const PROPERTY_DEFAULT_PARAM = 'default';
|
||||
export const PROPERTY_MIN_PARAM = 'min';
|
||||
export const PROPERTY_MAX_PARAM = 'max';
|
||||
|
||||
/** The params of a `Property` node, the same in both graphs but for the step of their number fields. */
|
||||
export function propertyNodeParams(step: number): PropertyDef[] {
|
||||
return [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step },
|
||||
];
|
||||
}
|
||||
|
||||
export interface Declaration {
|
||||
isDeclaration(node: GraphNode): boolean;
|
||||
nameOf(node: GraphNode): string;
|
||||
|
||||
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
import { type FlatDef, isValidFieldName } from './PropertySchema';
|
||||
|
||||
export {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
} from './declaredRows';
|
||||
export { PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
|
||||
/** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */
|
||||
export const PARTICLE_PROPERTY = 'ParticleProperty';
|
||||
|
||||
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
import { type FlatDef, isSpriteProp, isValidFieldName } from './PropertySchema';
|
||||
|
||||
export {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
} from './declaredRows';
|
||||
export { PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
|
||||
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
|
||||
export const SHADER_KEY = 'shader';
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { propertyNodeParams } from '../properties/declaredRows';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
propertyName,
|
||||
SHADER_PROPERTY,
|
||||
} from '../properties/shaders';
|
||||
import { PROPERTY_NAME_PARAM, propertyName, SHADER_PROPERTY } from '../properties/shaders';
|
||||
|
||||
export type GlslType = 'float' | 'vec3';
|
||||
|
||||
@@ -232,12 +226,7 @@ export const ShaderProperty: ShaderNodeDef = {
|
||||
description: 'A number the sprite sets: a property row below the Shader dropdown, and a value here.',
|
||||
inputs: [],
|
||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||
params: [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
|
||||
],
|
||||
params: propertyNodeParams(NUMBER_STEP),
|
||||
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }),
|
||||
emit: (_inputs, params) => ({
|
||||
value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' },
|
||||
|
||||
Reference in new issue
Block a user