A scene now stores only the container object; its content is resolved from the template on every read, with deviations kept as sparse instanceEdits on the container object. This drops the propagation bookkeeping entirely -- templateSync.ts is gone and the three structural gaps (add, remove, reparent) cannot diverge any more. Values stay editable on an instance, structure does not; Unpack writes the content into the scene, keeping ids. Play mode bakes containers into the throwaway copy before entities are built. No migration: formatVersion is 2 and the seeders write the new format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
import type { PropertyValue } from '../model/Properties';
|
||
import { overrideSites } from '../model/instance';
|
||
import type { Project } from '../model/Project';
|
||
import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
|
||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||
import { type Command, CommandError } from './Command';
|
||
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];
|
||
}
|
||
// Every node that picks a variable stores it under the param key `variable`.
|
||
for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to;
|
||
}
|
||
}
|
||
|
||
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
||
|
||
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
|
||
export function isValidVariableName(name: string): boolean {
|
||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name);
|
||
}
|