Fix findUsages/undo/node-lookup bugs, split Viewport and Editor, share command and inspector helpers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 09:22:49 +02:00
1 parent 1eb28314cf
commit f140c2fd77
58 files changed
+1969 -1171

No files matched your search

+5
View File
@@ -4,6 +4,11 @@ import type { Project } from '../model/Project';
* An undoable edit of the project. Commands mutate the project in place and remember
* whatever they need to revert. They validate first: `execute` throws a `CommandError` on
* an invalid operation (e.g. placing a scene into a scene) before it changes anything.
*
* The same holds for `undo`: when its target is gone it throws a `CommandError` rather than
* reverting half of the edit. The command then stays on the undo stack (see `CommandStack`)
* and the editor shows the message. Validation belongs in `execute`/`undo`, never in the
* constructor – commands are built outside the editor's error handling.
*/
export interface Command {
readonly label: string;
+10 -4
View File
@@ -15,20 +15,26 @@ export class CommandStack {
this.redoStack.length = 0;
}
/** Reverts the last command; false when there is none. */
/**
* Reverts the last command; false when there is none. The command moves between the stacks
* only after it succeeded – a throwing `undo` leaves the history exactly as it was instead
* of dropping the command from both stacks.
*/
undo(): boolean {
const command = this.undoStack.pop();
const command = this.undoStack[this.undoStack.length - 1];
if (!command) return false;
command.undo(this.project);
this.undoStack.pop();
this.redoStack.push(command);
return true;
}
/** Re-applies the last undone command; false when there is none. */
/** Re-applies the last undone command; false when there is none. Moves it only on success. */
redo(): boolean {
const command = this.redoStack.pop();
const command = this.redoStack[this.redoStack.length - 1];
if (!command) return false;
command.execute(this.project);
this.redoStack.pop();
this.undoStack.push(command);
return true;
}
+44
View File
@@ -0,0 +1,44 @@
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
import type { Project } from '../model/Project';
import type { Command } from './Command';
/**
* The skeleton every "write one key into a property bag" edit shares: remember what the bag
* held, write the new value, put the old one back on undo. A key that is not in the bag is
* `undefined` on both ends – as a `computeNext` result it removes the key (this is how
* clearing an override works), and as remembered value it makes undo remove it again.
*
* Subclasses look their target up on every call instead of holding on to it, so an undo whose
* target is gone fails with a `CommandError` instead of writing into an orphaned object.
*/
export abstract class SetBagValueCommand<T> implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
protected constructor(protected readonly key: string, label: string) {
this.label = label;
}
/** Throws a `CommandError` when the target does not exist. */
protected abstract resolveTarget(project: Project): T;
protected abstract bagOf(target: T): PropertyBag;
/**
* The value to write, `undefined` to remove the key. Called before anything is mutated, so
* this is where a subclass rejects an invalid edit (an unknown property key, say).
*/
protected abstract computeNext(target: T, project: Project): PropertyValue | undefined;
execute(project: Project): void {
const target = this.resolveTarget(project);
const bag = this.bagOf(target);
const next = this.computeNext(target, project);
this.previous = bag[this.key];
setBagValue(bag, this.key, next);
}
undo(project: Project): void {
setBagValue(this.bagOf(this.resolveTarget(project)), this.key, this.previous);
}
}
+48 -37
View File
@@ -22,30 +22,55 @@ function requireLink(project: Project, sceneId: string, object: SceneObject): Te
return link;
}
/** Remembered override values of several objects, for undo. */
interface OverrideMemo {
/**
* One value that is applied on its own: an override key, or a single axis of the position.
* Applying works the same for both – the slot is the only thing that differs.
*/
interface Slot<T> {
read(object: SceneObject): T;
write(object: SceneObject, value: T): void;
}
const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
read: (object) => object.overrides[key],
write: (object, value) => setBagValue(object.overrides, key, value),
});
const positionSlot = (axis: Axis): Slot<number> => ({
read: (object) => object.position[axis],
write: (object, value) => {
object.position[axis] = value;
},
});
/** A slot's value on one object before it was overwritten. */
interface SlotMemo<T> {
object: SceneObject;
previous: PropertyValue | undefined;
slot: Slot<T>;
previous: T;
}
/**
* Sets the template's override for `key` and moves every instance that still had the old
* template value along with it. Returns what to restore on undo.
* Writes `values` into the template's slots and carries every instance that still agreed
* with the template along – copies the user changed on their own keep their value. Returns
* the memos in application order; `restoreSlots` undoes them.
*/
function updateTemplateOverride(project: Project, link: TemplateLink, key: string, next: PropertyValue | undefined): OverrideMemo[] {
const previous = link.template.overrides[key];
const memos: OverrideMemo[] = [{ object: link.template, previous }];
function applyToTemplate<T>(project: Project, link: TemplateLink, slots: readonly Slot<T>[], values: readonly T[]): SlotMemo<T>[] {
const before = slots.map((slot) => slot.read(link.template));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({ object: link.template, slot, previous: before[i]! }));
for (const { object } of instancesOf(project, link.template.id)) {
if (object.overrides[key] !== previous) continue;
memos.push({ object, previous: object.overrides[key] });
setBagValue(object.overrides, key, next);
slots.forEach((slot, i) => {
if (slot.read(object) !== before[i]) return;
memos.push({ object, slot, previous: slot.read(object) });
slot.write(object, values[i]!);
});
}
setBagValue(link.template.overrides, key, next);
slots.forEach((slot, i) => slot.write(link.template, values[i]!));
return memos;
}
function restoreOverrides(memos: OverrideMemo[], key: string): void {
for (const memo of [...memos].reverse()) setBagValue(memo.object.overrides, key, memo.previous);
function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
}
/**
@@ -57,7 +82,7 @@ export class ApplyOverrideToAssetCommand implements Command {
readonly label: string;
private previousAssetValue: PropertyValue | undefined;
private previousOverride: PropertyValue | undefined;
private templateMemos: OverrideMemo[] = [];
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
this.label = `Apply ${key} to asset`;
@@ -74,13 +99,13 @@ export class ApplyOverrideToAssetCommand implements Command {
asset.props[this.key] = value!;
delete object.overrides[this.key];
const link = scene && templateLinkOf(project, scene, object);
this.templateMemos = link ? updateTemplateOverride(project, link, this.key, undefined) : [];
this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
}
undo(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const asset = requireAsset(project, object.assetId);
restoreOverrides(this.templateMemos, this.key);
restoreSlots(this.templateMemos);
setBagValue(object.overrides, this.key, this.previousOverride);
setBagValue(asset.props, this.key, this.previousAssetValue);
}
@@ -89,7 +114,7 @@ export class ApplyOverrideToAssetCommand implements Command {
/** Writes the object's override for `key` into its container template (instances at the old value follow). */
export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
private memos: OverrideMemo[] = [];
private memos: SlotMemo<PropertyValue | undefined>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
this.label = `Apply ${key} to container`;
@@ -98,11 +123,11 @@ export class ApplyOverrideToContainerCommand implements Command {
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
this.memos = updateTemplateOverride(project, link, this.key, object.overrides[this.key]);
this.memos = applyToTemplate(project, link, [overrideSlot(this.key)], [object.overrides[this.key]]);
}
undo(): void {
restoreOverrides(this.memos, this.key);
restoreSlots(this.memos);
}
}
@@ -127,19 +152,13 @@ export class RevertOverrideToTemplateCommand implements Command {
}
}
/** Remembered positions of several objects, for undo. */
interface PositionMemo {
object: SceneObject;
previous: Position;
}
/**
* Writes the object's local position (one axis, or all) into its container template.
* Instances whose axis still matched the template follow; the others keep their own value.
*/
export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
private memos: PositionMemo[] = [];
private memos: SlotMemo<number>[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
this.label = axis ? `Apply ${axis} to container` : 'Apply position to container';
@@ -149,19 +168,11 @@ export class ApplyPositionToContainerCommand implements Command {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const axes = this.axis ? [this.axis] : ALL_AXES;
const before = { ...link.template.position };
this.memos = [{ object: link.template, previous: before }];
for (const instance of instancesOf(project, link.template.id)) {
const changed = axes.filter((a) => instance.object.position[a] === before[a]);
if (changed.length === 0) continue;
this.memos.push({ object: instance.object, previous: { ...instance.object.position } });
for (const a of changed) instance.object.position[a] = object.position[a];
}
for (const a of axes) link.template.position[a] = object.position[a];
this.memos = applyToTemplate(project, link, axes.map(positionSlot), axes.map((a) => object.position[a]));
}
undo(): void {
for (const memo of [...this.memos].reverse()) memo.object.position = { ...memo.previous };
restoreSlots(this.memos);
}
}
+38 -47
View File
@@ -1,15 +1,16 @@
import { createEmptyGraph } from '../graph/Graph';
import { newId } from '../ids';
import type { Asset, ContainerAsset, SceneAsset } from '../model/Asset';
import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { removeSubtrees } from '../model/hierarchy';
import { type PropertyValue, setBagValue } from '../model/Properties';
import type { Project } from '../model/Project';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { objectHolders, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
import { type Removed, restoreAt } from '../util/list';
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset, requirePropDef } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
/** A new asset of the given type with default properties (and empty content for scenes, behaviors, containers). */
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
@@ -50,7 +51,7 @@ export class CreateAssetCommand implements Command {
}
undo(project: Project): void {
project.assets = project.assets.filter((a) => a.id !== this.asset.id);
project.assets = removeById(project.assets, this.asset.id);
project.activeSceneId = this.previousActiveScene;
}
}
@@ -70,17 +71,16 @@ export class DeleteAssetCommand implements Command {
constructor(private readonly assetId: string) {}
execute(project: Project): void {
const index = project.assets.findIndex((a) => a.id === this.assetId);
const asset = project.assets[index];
if (!asset) throw new CommandError(`Asset ${this.assetId} not found`);
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 = { item: asset, index };
project.assets.splice(index, 1);
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');
@@ -90,20 +90,16 @@ export class DeleteAssetCommand implements Command {
undo(project: Project): void {
if (!this.removed) return;
project.assets.splice(this.removed.index, 0, this.removed.item);
restoreAt(project.assets, [this.removed]);
const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
for (const entry of this.removedObjects) {
const holder = objectHolders(project).find((h) => h.id === entry.holderId);
const holder = holders.get(entry.holderId);
if (holder) restoreAt(holder.objects, entry.removed);
}
project.activeSceneId = this.previousActiveScene;
}
}
/** Scenes and container assets: everything that holds placed objects. */
function objectHolders(project: Project): (SceneAsset | ContainerAsset)[] {
return project.assets.filter((a): a is SceneAsset | ContainerAsset => a.type === 'scene' || a.type === 'container');
}
export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
@@ -121,45 +117,40 @@ export class RenameAssetCommand implements Command {
}
}
/** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */
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;
}
}
/** Sets an asset property (coerced against its schema entry). */
export class SetAssetPropertyCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly assetId: string, private readonly key: string, private readonly value: PropertyValue) {
this.label = `Set ${key}`;
export class SetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string, private readonly value: PropertyValue) {
super(assetId, key, `Set ${key}`);
}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
const def = requirePropDef(asset, this.key);
this.previous = asset.props[this.key];
asset.props[this.key] = coerceValue(def, this.value);
}
undo(project: Project): void {
setBagValue(requireAsset(project, this.assetId).props, this.key, this.previous);
protected override computeNext(asset: Asset): PropertyValue {
return coerceValue(requirePropDef(asset, this.key), this.value);
}
}
/** Puts an asset property back to its schema default (the asset-level "clear override"). */
export class ResetAssetPropertyCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly assetId: string, private readonly key: string) {
this.label = `Reset ${key}`;
export class ResetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string) {
super(assetId, key, `Reset ${key}`);
}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
const def = requirePropDef(asset, this.key);
this.previous = asset.props[this.key];
asset.props[this.key] = def.default;
}
undo(project: Project): void {
setBagValue(requireAsset(project, this.assetId).props, this.key, this.previous);
protected override computeNext(asset: Asset): PropertyValue {
return requirePropDef(asset, this.key).default;
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { newId } from '../ids';
import type { Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
@@ -37,7 +38,7 @@ export class AddColliderCommand implements Command {
undo(project: Project): void {
const scene = requireScene(project, this.sceneId);
scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
project.assets = project.assets.filter((a) => a.id !== this.assetId);
scene.objects = removeById(scene.objects, this.objectId);
project.assets = removeById(project.assets, this.assetId);
}
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { findObject, localPositionFor, subtreeIds, subtreeRoots, treeOrder, worl
import type { Position } from '../model/Properties';
import { findScene, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
@@ -90,8 +91,8 @@ export class CreateContainerCommand implements Command {
object.position = { ...entry.position };
object.templateId = entry.templateId;
}
scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
scene.objects = removeById(scene.objects, this.objectId);
}
project.assets = project.assets.filter((a) => a.id !== this.assetId);
project.assets = removeById(project.assets, this.assetId);
}
}
+31 -29
View File
@@ -1,12 +1,13 @@
import { edgesInto, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
import { requireNodeDef } from '../graph/NodeRegistry';
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
import { nodeDef } from '../graph/NodeRegistry';
import { newId } from '../ids';
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
import { type Removed, removeWhere, restoreAt } from '../util/list';
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireBehavior, requireNode } from './require';
import { requireBehavior, requireNode, requireNodeDef } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
/** Adds a node of the given type at graph coordinates. Params start at their defaults. */
export class AddNodeCommand implements Command {
@@ -14,18 +15,21 @@ export class AddNodeCommand implements Command {
readonly node: GraphNode;
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
const def = requireNodeDef(type);
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def.params), ...params } };
this.label = `Add ${def.label}`;
// An unknown type is rejected in `execute`, not here: constructors run outside the
// editor's error handling, so throwing here would crash instead of showing a notice.
const def = nodeDef(type);
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params } };
this.label = def ? `Add ${def.label}` : `Add ${type}`;
}
execute(project: Project): void {
requireNodeDef(this.node.type);
requireBehavior(project, this.assetId).graph.nodes.push(this.node);
}
undo(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== this.node.id);
graph.nodes = removeById(graph.nodes, this.node.id);
}
}
@@ -38,12 +42,11 @@ export class RemoveNodeCommand implements Command {
execute(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
const index = graph.nodes.findIndex((n) => n.id === this.nodeId);
const node = graph.nodes[index];
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
const edges = removeWhere(graph.edges, (e) => e.from.node === this.nodeId || e.to.node === this.nodeId);
graph.nodes.splice(index, 1);
this.removed = { node: { item: node, index }, edges };
// `edgesOfNode` reads the edge list only, so it still finds them after the node is gone.
const touching = edgesOfNode(graph, this.nodeId);
this.removed = { node, edges: removeWhere(graph.edges, (e) => touching.includes(e)) };
}
undo(project: Project): void {
@@ -79,24 +82,23 @@ export class MoveNodeCommand implements Command {
}
/** Sets a node param or the inline value of an unconnected data input. */
export class SetNodeParamCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly key: string, private readonly value: PropertyValue) {
this.label = `Set ${key}`;
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
constructor(private readonly assetId: string, private readonly nodeId: string, key: string, private readonly value: PropertyValue) {
super(key, `Set ${key}`);
}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
protected override resolveTarget(project: Project): GraphNode {
return requireNode(project, this.assetId, this.nodeId);
}
protected override bagOf(node: GraphNode): PropertyBag {
return node.params;
}
protected override computeNext(node: GraphNode): PropertyValue {
const param = requireNodeDef(node.type).params.find((p) => p.key === this.key);
this.previous = node.params[this.key];
// Inline input values are stored as-is; declared params are coerced against their definition.
node.params[this.key] = param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
}
undo(project: Project): void {
setBagValue(requireNode(project, this.assetId, this.nodeId).params, this.key, this.previous);
return param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
}
}
@@ -131,7 +133,7 @@ export class ConnectCommand implements Command {
undo(project: Project): void {
const graph = requireBehavior(project, this.assetId).graph;
graph.edges = graph.edges.filter((e) => e.id !== this.edge.id);
graph.edges = removeById(graph.edges, this.edge.id);
restoreAt(graph.edges, this.replaced);
}
}
+12
View File
@@ -4,6 +4,7 @@
* message the editor can show.
*/
import { findNode, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
import { findObject } from '../model/hierarchy';
import { findAsset, findScene, type Project } from '../model/Project';
@@ -41,6 +42,17 @@ export function requireNode(project: Project, assetId: string, nodeId: string):
return node;
}
/**
* The definition of a node type. `NodeRegistry.requireNodeDef` throws a plain `Error`, which
* the editor does not catch – a command must reject an unknown type as a `CommandError` so it
* becomes a notice instead of a crash.
*/
export function requireNodeDef(type: string): NodeDef {
const def = nodeDef(type);
if (!def) throw new CommandError(`Unknown node type "${type}"`);
return def;
}
/** The schema entry for `key` on the asset – a property the asset's type (or the behavior) declares. */
export function requirePropDef(asset: Asset, key: string): PropertyDef {
const def = propDef(asset, key);
+29 -35
View File
@@ -2,13 +2,14 @@ import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { instantiateContainer } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { type Position, type PropertyValue, setBagValue, toPosition, type Vec2 } from '../model/Properties';
import { type Position, type PropertyBag, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset, requireObject, requirePropDef, requireScene } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
/**
* Places an asset into a scene, creating a scene object. Scenes cannot be placed. The
@@ -134,49 +135,42 @@ export class RenameSceneObjectCommand implements Command {
}
}
/** The half of `SetBagValueCommand` both override edits share: the bag is the object's overrides. */
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
protected constructor(private readonly sceneId: string, private readonly objectId: string, key: string, label: string) {
super(key, label);
}
protected override resolveTarget(project: Project): SceneObject {
return requireObject(project, this.sceneId, this.objectId);
}
protected override bagOf(object: SceneObject): PropertyBag {
return object.overrides;
}
}
/** Sets a per-object override of an asset property. */
export class SetOverrideCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(
private readonly sceneId: string,
private readonly objectId: string,
private readonly key: string,
private readonly value: PropertyValue,
) {
this.label = `Override ${key}`;
export class SetOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string, private readonly value: PropertyValue) {
super(sceneId, objectId, key, `Override ${key}`);
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const def = requirePropDef(requireAsset(project, object.assetId), this.key);
this.previous = object.overrides[this.key];
object.overrides[this.key] = coerceValue(def, this.value);
}
undo(project: Project): void {
setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
protected override computeNext(object: SceneObject, project: Project): PropertyValue {
return coerceValue(requirePropDef(requireAsset(project, object.assetId), this.key), this.value);
}
}
/** Removes an override so the object follows its asset again. */
export class ClearOverrideCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
this.label = `Reset ${key}`;
export class ClearOverrideCommand extends OverrideCommand {
constructor(sceneId: string, objectId: string, key: string) {
super(sceneId, objectId, key, `Reset ${key}`);
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
this.previous = object.overrides[this.key];
delete object.overrides[this.key];
}
undo(project: Project): void {
setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
// Unlike setting one, clearing an override does not check the key against the schema: a
// key the asset no longer declares is exactly what one wants to be able to clear.
protected override computeNext(): undefined {
return undefined;
}
}
+13 -11
View File
@@ -1,6 +1,7 @@
import type { PropertyValue } from '../model/Properties';
import { findUsages, 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';
@@ -32,8 +33,8 @@ export class AddVariableCommand implements Command {
/** Removes a variable together with its default and every instance override. */
export class RemoveVariableCommand implements Command {
readonly label: string;
private removed?: { def: PropertyDef; index: number; value: PropertyValue | undefined };
private overrides: { sceneId: string; objectId: string; value: PropertyValue }[] = [];
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = [];
constructor(private readonly assetId: string, private readonly key: string) {
this.label = `Remove variable ${key}`;
@@ -41,17 +42,15 @@ export class RemoveVariableCommand implements Command {
execute(project: Project): void {
const asset = requireBehavior(project, this.assetId);
const index = asset.variables.findIndex((v) => v.key === this.key);
const def = asset.variables[index];
const def = removeFirst(asset.variables, (v) => v.key === this.key);
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
this.removed = { def, index, value: asset.props[this.key] };
asset.variables.splice(index, 1);
this.removed = { def, value: asset.props[this.key] };
delete asset.props[this.key];
this.overrides = [];
for (const { scene, object } of findUsages(project, this.assetId)) {
for (const { holder, object } of findUsages(project, this.assetId)) {
const value = object.overrides[this.key];
if (value === undefined) continue;
this.overrides.push({ sceneId: scene.id, objectId: object.id, value });
this.overrides.push({ holderId: holder.id, objectId: object.id, value });
delete object.overrides[this.key];
}
}
@@ -59,17 +58,20 @@ export class RemoveVariableCommand implements Command {
undo(project: Project): void {
if (!this.removed) return;
const asset = requireBehavior(project, this.assetId);
asset.variables.splice(this.removed.index, 0, this.removed.def);
restoreAt(asset.variables, [this.removed.def]);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
const usages = findUsages(project, this.assetId);
for (const o of this.overrides) {
const object = usages.find((u) => u.scene.id === o.sceneId && u.object.id === o.objectId)?.object;
const object = usages.find((u) => u.holder.id === o.holderId && u.object.id === o.objectId)?.object;
if (object) object.overrides[this.key] = o.value;
}
}
}
/** Renames a variable everywhere: definition, default, instance overrides and graph nodes referring to it. */
/**
* Renames a variable everywhere: definition, default, instance overrides (in scenes and in
* container templates) and graph nodes referring to it.
*/
export class RenameVariableCommand implements Command {
readonly label: string;