Files
feuerwerk/src/core/commands/assetCommands.ts
T
adminandClaude Opus 5 bdb3f066cf The asset type "vector art" exists as a model, which is stage 1 of #129 and shows nothing in the browser yet on purpose: a vectorart asset is not placeable, sits in the + menu and in the asset panel with its own icon (a curve between two points) and has no tab, so it behaves like a font - selectable, its rows in the inspector, a double click leading nowhere - because the tab is stage 3 and half an asset would only be in the way; the data model in Asset.ts is a shape built from rings (even-odd, so a shape may have holes, answer 6 in the issue), a ring being points with optional bezier handles in/out stored as an offset from the point and a closed flag (an open path is stroke only, answer 4), plus fill colour, opacity and an optional stroke (answer 5) - the two handles are kept apart instead of mirrored, because what "smooth" means is the editor's business and not the model's; canvas width, height and the filter are properties, so they get an inspector row and undo like any other, while the shapes are intrinsic data like the frames of a texture; the revision that #185 hands to the picture path is a fingerprint of what is drawn, not a counter, which is exactly why that key took a string token - no command can forget to bump it - and the name of a shape is deliberately left out of it, renaming changing no pixel; six undo commands in the new vectorArtCommands.ts (add a shape numbered in order, remove one which comes back to its old place - unlike a tilemap layer the last one may go too -, rename, move, write the rings as one step per gesture with the list copied, and set the style where stroke: null takes it away) and the pure function shapeOrderTarget for the four buttons of the planned inspector list (one forward, to front, one back, to back), which also serves dragging in that list; loading from a project file is strict - a point without numbers falls away, a broken handle leaves a corner behind, duplicate shape ids fall away like duplicate layer ids, a stroke of width 0 is no stroke - while a ring with a single point, which is what someone is drawing right now, stays; three findings on the way: JSON.stringify as the fingerprint was wrong because the same drawing came back from a project file as {points, closed} and was built as {closed, points}, so a reload would have rasterized the same picture under a new key (the fingerprint writes its fields in a fixed order now and a test measures that case), toNumber in serialize.ts turns any nonsense into 0 so the Number.isFinite checks ran into nothing and {x: 'far'} became the left edge instead of vanishing (shapes read numbers strictly via asFinite, 0 being a place on the canvas here), and "Vector art" would have been both a menu entry and the panel heading, which an existing test catches because a click by text would hit the heading - the group is called "Vector drawings" now while the menu entry stays "Vector art"; 20 new unit tests in tests/core/vectorart.test.ts and three counter-checks hold: the fingerprint back on JSON.stringify fails 2, reading the numbers loosely again fails 4, and not copying the ring list fails 1; no e2e check and the check sum stays 1157, only module 04 counting 19 asset types instead of 18 (#129)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:29:35 +02:00

403 lines
13 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.
import { createEmptyGraph } from '../graph/Graph';
import { newId } from '../ids';
import type { Asset, FontAsset, SoundAsset, StateMachineAsset, TextureAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { isSoundFile } from '../model/sound';
import { removeSubtrees } from '../model/hierarchy';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { objectHolders, type Project } from '../model/Project';
import { type Scope, scopeOf, setScope } from '../model/Scope';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile } from '../model/texture';
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset, requirePropDef, requireStateMachine } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
const base = { id, name, props: defaultsFor(type) };
switch (type) {
case 'texture':
return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id);
case 'scene':
return { ...base, type: 'scene', objects: [] };
case 'camera':
return { ...base, type: 'camera' };
case 'sprite':
return { ...base, type: 'sprite' };
case 'text':
return { ...base, type: 'text' };
case 'behavior':
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'variables':
return { ...base, type: 'variables', variables: [] };
case 'collider':
return { ...base, type: 'collider' };
case 'rigidbody':
return { ...base, type: 'rigidbody' };
case 'container':
return { ...base, type: 'container', objects: [] };
// A drawing starts empty: the pen tool of #129 sets the first shape down.
case 'vectorart':
return { ...base, type: 'vectorart', shapes: [] };
case 'animation':
return { ...base, type: 'animation', root: '', tracks: [] };
case 'tile':
return { ...base, type: 'tile', pictures: [] };
// A tilemap without a layer could not be painted on, so it starts with one.
case 'tilemap':
return { ...base, type: 'tilemap', layers: [{ id: newId(), name: 'Layer 1', cells: {} }] };
case 'statemachine':
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
case 'shader':
return { ...base, type: 'shader', graph: createEmptyGraph() };
case 'font':
return { ...base, type: 'font', file: '' };
case 'sound':
return { ...base, type: 'sound', file: '' };
case 'particles':
return { ...base, type: 'particles', graph: createEmptyGraph() };
}
}
/** Adds a new asset; the first scene of a project becomes the active scene. */
export class CreateAssetCommand implements Command {
readonly label: string;
readonly asset: Asset;
private previousActiveScene = '';
/** `props` replace schema defaults and pass through the schema like any other edit. */
constructor(
type: AssetType,
name: string,
private readonly props: PropertyBag = {},
) {
this.asset = buildAsset(type, name);
this.label = `Create ${type} "${name}"`;
}
execute(project: Project): void {
for (const [key, value] of Object.entries(this.props))
this.asset.props[key] = coerceValue(requirePropDef(project, this.asset, key), value);
project.assets.push(this.asset);
this.previousActiveScene = project.activeSceneId;
if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id;
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
project.activeSceneId = this.previousActiveScene;
}
}
/** The upload result a texture asset is built from (see `TextureStore.upload`). */
export interface TextureImage {
file: string;
width: number;
height: number;
}
/**
* Apart from `buildAsset` so callers get a `TextureAsset` rather than the `Asset` union.
*
* The three fields are copied out one by one rather than spread: callers pass the whole upload
* result, which carries `bytes` and – since original names are kept – a `name` of its own. A
* spread would put the *file's* name where the asset's belongs and silently overrule the caller.
*/
function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
return {
id,
name,
props: defaultsFor('texture'),
type: 'texture',
file: image.file,
width: image.width,
height: image.height,
frames: [],
};
}
/**
* The file is uploaded before the command runs; this only records the result, so undo and redo
* never touch the network.
*/
abstract class ImportFileCommand<A extends Asset & { file: string }> implements Command {
readonly label: string;
constructor(
readonly asset: A,
private readonly fileNoun: string,
private readonly isStored: (file: string) => boolean,
) {
this.label = `Import ${asset.type} "${asset.name}"`;
}
execute(project: Project): void {
// The name is the hash the server derived from the bytes; anything else was never uploaded.
if (!this.isStored(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored ${this.fileNoun} file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
}
}
export class CreateTextureCommand extends ImportFileCommand<TextureAsset> {
constructor(name: string, image: TextureImage) {
super(buildTextureAsset(name, image), 'image', isTextureFile);
}
}
function buildFontAsset(name: string, file: string): FontAsset {
return { id: newId(), name, props: defaultsFor('font'), type: 'font', file };
}
export class CreateFontCommand extends ImportFileCommand<FontAsset> {
constructor(name: string, file: string) {
super(buildFontAsset(name, file), 'font', isFontFile);
}
}
function buildSoundAsset(name: string, file: string): SoundAsset {
return { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file };
}
export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
constructor(name: string, file: string) {
super(buildSoundAsset(name, file), 'sound', isSoundFile);
}
}
/**
* Deletes an asset and, as a cascade, every scene object referencing it, together with the objects
* parented below those – in container templates as well as in scenes.
*/
export class DeleteAssetCommand implements Command {
readonly label = 'Delete asset';
private removed?: Removed<Asset>;
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
private previousActiveScene = '';
constructor(private readonly assetId: string) {}
execute(project: Project): void {
requireAsset(project, this.assetId);
this.removedObjects = [];
// The cascade runs while the asset is still in the list: a container asset is an object
// holder itself, so removing it first would change what `objectHolders` reports.
for (const holder of objectHolders(project)) {
const roots = holder.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
if (roots.length === 0) continue;
this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
}
this.removed = removeFirst(project.assets, (a) => a.id === this.assetId);
this.previousActiveScene = project.activeSceneId;
if (project.activeSceneId === this.assetId) {
const next = project.assets.find((a) => a.type === 'scene');
project.activeSceneId = next ? next.id : '';
}
}
undo(project: Project): void {
if (!this.removed) return;
restoreAt(project.assets, [this.removed]);
const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
for (const entry of this.removedObjects) {
const holder = holders.get(entry.holderId);
if (holder) restoreAt(holder.objects, entry.removed);
}
project.activeSceneId = this.previousActiveScene;
}
}
export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
constructor(
private readonly assetId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
this.previousName = asset.name;
asset.name = this.name;
}
undo(project: Project): void {
requireAsset(project, this.assetId).name = this.previousName;
}
}
/** A `global` machine keeps one current state for the whole run instead of one per behavior instance. */
export class SetMachineScopeCommand implements Command {
readonly label: string;
private previous: Scope = 'local';
constructor(
private readonly assetId: string,
private readonly scope: Scope,
) {
this.label = `Make state machine ${scope}`;
}
execute(project: Project): void {
const asset = requireStateMachine(project, this.assetId);
this.previous = scopeOf(asset);
setScope(asset, this.scope);
}
undo(project: Project): void {
setScope(requireStateMachine(project, this.assetId), this.previous);
}
}
/**
* Which state a machine starts in, as a state node's id – `''` takes the mark off again, and the
* machine is back to starting in no state. Only one state can be the start because there is only
* one field to hold it.
*/
export class SetMachineStartCommand implements Command {
readonly label: string;
private previous = '';
constructor(
private readonly assetId: string,
private readonly start: string,
) {
this.label = start ? 'Set start state' : 'Remove start state';
}
execute(project: Project): void {
const asset = requireStateMachine(project, this.assetId);
this.previous = asset.start ?? '';
this.write(asset, this.start);
}
undo(project: Project): void {
this.write(requireStateMachine(project, this.assetId), this.previous);
}
/** Kept out of the document when there is none, the way a `local` scope is. */
private write(asset: StateMachineAsset, start: string): void {
if (start) asset.start = start;
else delete asset.start;
}
}
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
protected constructor(
private readonly assetId: string,
key: string,
label: string,
) {
super(key, label);
}
protected override resolveTarget(project: Project): Asset {
return requireAsset(project, this.assetId);
}
protected override bagOf(asset: Asset): PropertyBag {
return asset.props;
}
}
export class SetAssetPropertyCommand extends AssetPropertyCommand {
constructor(
assetId: string,
key: string,
private readonly value: PropertyValue,
) {
super(assetId, key, `Set ${key}`);
}
protected override computeNext(asset: Asset, project: Project): PropertyValue {
return coerceValue(requirePropDef(project, asset, this.key), this.value);
}
}
export class ResetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string) {
super(assetId, key, `Reset ${key}`);
}
protected override computeNext(asset: Asset, project: Project): PropertyValue {
return requirePropDef(project, asset, this.key).default;
}
}
/**
* Names the scene a real run begins with, or takes that name back (`''`). A deleted scene is not
* cleaned up here on purpose: the id then simply reads as none, and an undo of the deletion brings
* the start scene back with it.
*/
export class SetStartSceneCommand implements Command {
readonly label = 'Set start scene';
private previous = '';
constructor(private readonly sceneId: string) {}
execute(project: Project): void {
if (this.sceneId) {
const asset = requireAsset(project, this.sceneId);
if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`);
}
this.previous = project.startSceneId;
project.startSceneId = this.sceneId;
}
undo(project: Project): void {
project.startSceneId = this.previous;
}
}
/**
* Switches an editor plugin on or off for this project (#119). It is an undo step like any other
* change to the project: the switch changes the project, and the editor follows the project.
*/
export class SetPluginActiveCommand implements Command {
readonly label: string;
private previous: string[] = [];
constructor(
private readonly file: string,
private readonly active: boolean,
) {
this.label = `Switch plugin ${active ? 'on' : 'off'}`;
}
execute(project: Project): void {
this.previous = [...project.plugins];
const rest = this.previous.filter((f) => f !== this.file);
project.plugins = this.active ? [...rest, this.file].sort() : rest;
}
undo(project: Project): void {
project.plugins = [...this.previous];
}
}
export class SetActiveSceneCommand implements Command {
readonly label = 'Switch scene';
private previous = '';
constructor(private readonly sceneId: string) {}
execute(project: Project): void {
const asset = requireAsset(project, this.sceneId);
if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`);
this.previous = project.activeSceneId;
project.activeSceneId = this.sceneId;
}
undo(project: Project): void {
project.activeSceneId = this.previous;
}
}