Files
feuerwerk/src/core/commands/variableCommands.ts
T
rootandClaude Opus 5 3ed8b68ecf Describe every value kind in one table instead of spreading it over the code
A kind like colour or texture used to be spelled out in five parallel
lists, its port type, its empty value, its glyph, its label and its list
tag - 22 literal places for texture alone. core/properties/kinds.ts now
holds one row per kind and the rest is derived from it, so adding a kind
is one row. The five literal nodes collapse into literals.ts, the six
kind menus into one helper.

Number, Text, Boolean is now the one order everywhere; the variable and
port menus used to list Boolean before Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:47:17 +02:00

329 lines
12 KiB
TypeScript

import {
type FieldValue,
isStruct,
type PropertyBag,
type PropertyValue,
type StructValue,
} from '../model/Properties';
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import type { VariableKind } from '../properties/kinds';
import {
emptyFieldValue,
isValidFieldName,
isValidVariableName,
type PropertyDef,
type StructFieldDef,
variableDef,
} from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { renameVariablePorts } from './portCommands';
import { requireBehavior } from './require';
/** The variable's default value is written to `props` as well. */
export class AddVariableCommand implements Command {
readonly label: string;
readonly def: PropertyDef;
constructor(
private readonly assetId: string,
key: string,
kind: VariableKind,
) {
this.def = variableDef(key, kind);
this.label = `Add variable ${key}`;
}
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
if (asset.variables.some((v) => v.key === this.def.key))
throw new CommandError(`Variable "${this.def.key}" already exists`);
asset.variables.push(this.def);
asset.props[this.def.key] = this.def.default;
}
undo(project: Project): void {
const asset = requireBehavior(project, this.assetId);
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
delete asset.props[this.def.key];
}
}
/** Removes a variable together with its default and every instance override. */
export class RemoveVariableCommand implements Command {
readonly label: string;
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = [];
constructor(
private readonly assetId: string,
private readonly key: string,
) {
this.label = `Remove variable ${key}`;
}
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
const def = removeFirst(asset.variables, (v) => v.key === this.key);
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
this.removed = { def, value: asset.props[this.key] };
delete asset.props[this.key];
this.overrides = [];
for (const site of overrideSites(project, (id) => id === this.assetId)) {
const value = site.bag[this.key];
if (value === undefined) continue;
this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
delete site.bag[this.key];
}
}
undo(project: Project): void {
if (!this.removed) return;
const asset = requireBehavior(project, this.assetId);
restoreAt(asset.variables, [this.removed.def]);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
const sites = overrideSites(project, (id) => id === this.assetId);
for (const o of this.overrides) {
const site = sites.find(
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
);
if (site) site.bag[this.key] = o.value;
}
}
}
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
export class RenameVariableCommand implements Command {
readonly label: string;
constructor(
private readonly assetId: string,
private readonly from: string,
private readonly to: string,
) {
this.label = `Rename variable ${from}`;
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
const asset = requireBehavior(project, this.assetId);
if (from === to) return;
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
const def = asset.variables.find((v) => v.key === from);
if (!def) throw new CommandError(`Variable "${from}" not found`);
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
def.key = to;
def.label = to;
if (asset.props[from] !== undefined) {
asset.props[to] = asset.props[from]!;
delete asset.props[from];
}
for (const { bag } of overrideSites(project, (id) => id === this.assetId)) {
if (bag[from] === undefined) continue;
bag[to] = bag[from]!;
delete bag[from];
}
renameVariablePorts(project, this.assetId, from, to);
}
}
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */
interface FieldOwner {
fields(): readonly StructFieldDef[];
setFields(next: readonly StructFieldDef[]): void;
}
function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined {
return requireBehavior(project, assetId).variables.find((v) => v.key === key);
}
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
const def = variableOf(project, assetId, key);
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
return { fields: () => def.fields, setFields: (next) => (def.fields = next) };
}
/**
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
*/
interface StructSite {
id: string;
read(): StructValue;
write(value: StructValue): void;
}
function structSites(project: Project, assetId: string, key: string): StructSite[] {
return (
valueHolders(project, assetId, key)
// An override the object does not have stays absent: writing one here would invent a deviation.
.filter((h) => !h.optional || isStruct(h.read()))
.map((holder) => ({
id: holder.id,
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
write: (value: StructValue) => holder.write(value),
}))
);
}
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
interface ValueHolder {
id: string;
/** An override may simply not be there; the declared default and the behavior's own always are. */
optional: boolean;
read(): PropertyValue | undefined;
write(value: PropertyValue): void;
}
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
const asset = requireBehavior(project, assetId);
const def = asset.variables.find((v) => v.key === key);
const holders: ValueHolder[] = [];
if (def)
holders.push({
id: 'declared',
optional: false,
read: () => def.default,
write: (value) => {
def.default = value;
},
});
holders.push(bagHolder('asset', false, asset.props, key));
for (const site of overrideSites(project, (id) => id === assetId))
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
return holders;
}
function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
}
function withoutField(value: StructValue, key: string): StructValue {
const next: Record<string, FieldValue> = { ...value };
delete next[key];
return next;
}
/** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */
function renameField(value: StructValue, from: string, to: string): StructValue {
const next: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
return next;
}
export class AddStructFieldCommand implements Command {
readonly label: string;
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly field: StructFieldDef,
) {
this.label = `Add field ${field.key}`;
}
execute(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
if (owner.fields().some((f) => f.key === this.field.key))
throw new CommandError(`Field "${this.field.key}" already exists`);
owner.setFields([...owner.fields(), { ...this.field }]);
for (const site of structSites(project, this.assetId, this.key))
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
}
undo(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
for (const site of structSites(project, this.assetId, this.key))
site.write(withoutField(site.read(), this.field.key));
}
}
/** Takes the field's value out of every struct that carries it, and gives each one back on undo. */
export class RemoveStructFieldCommand implements Command {
readonly label: string;
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly field: string,
) {
this.label = `Remove field ${field}`;
}
execute(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
const fields = [...owner.fields()];
const field = removeFirst(fields, (f) => f.key === this.field);
if (!field) throw new CommandError(`Field "${this.field}" not found`);
const values = new Map<string, FieldValue>();
for (const site of structSites(project, this.assetId, this.key)) {
const value = site.read();
const item = value[this.field];
if (item !== undefined) values.set(site.id, item);
site.write(withoutField(value, this.field));
}
owner.setFields(fields);
this.removed = { field, values };
}
undo(project: Project): void {
if (!this.removed) return;
const owner = requireFieldOwner(project, this.assetId, this.key);
const fields = [...owner.fields()];
restoreAt(fields, [this.removed.field]);
owner.setFields(fields);
for (const site of structSites(project, this.assetId, this.key)) {
const value = this.removed.values.get(site.id);
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
}
}
}
export class RenameStructFieldCommand implements Command {
readonly label: string;
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly from: string,
private readonly to: string,
) {
this.label = `Rename field ${from}`;
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
const owner = requireFieldOwner(project, this.assetId, this.key);
if (from === to) return;
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
const fields = owner.fields();
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
for (const site of structSites(project, this.assetId, this.key))
site.write(renameField(site.read(), from, to));
}
}