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;
-4
View File
@@ -44,10 +44,6 @@ export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
return graph.nodes.find((n) => n.id === nodeId);
}
export function findEdge(graph: Graph, edgeId: string): GraphEdge | undefined {
return graph.edges.find((e) => e.id === edgeId);
}
/** Edges arriving at one input port (data inputs allow at most one, exec inputs any number). */
export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge[] {
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
+2 -9
View File
@@ -1,4 +1,5 @@
import type { PropertyBag } from '../model/Properties';
import { sumAlongChain } from '../model/walk';
import { enabledAlongChain } from '../properties/enabled';
/**
@@ -21,15 +22,7 @@ export interface EntityHandle {
/** World position of a handle: its local position plus those of all ancestors. */
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
const result = { x: handle.x, y: handle.y, z: handle.z };
const seen = new Set<EntityHandle>([handle]);
for (let p = handle.parent; p && !seen.has(p); p = p.parent) {
result.x += p.x;
result.y += p.y;
result.z += p.z;
seen.add(p);
}
return result;
return sumAlongChain(handle, (h) => h.parent, (h) => h);
}
/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
+19 -8
View File
@@ -1,4 +1,4 @@
import type { Asset, SceneAsset } from './Asset';
import type { Asset, ContainerAsset, SceneAsset } from './Asset';
import { isSceneAsset } from './Asset';
import type { SceneObject } from './SceneObject';
@@ -32,21 +32,32 @@ export function listScenes(project: Project): SceneAsset[] {
return project.assets.filter(isSceneAsset);
}
/**
* Everything that holds placed objects. Container assets do so just like scenes: their
* `objects` are the template a placed container is instantiated from. Anything that walks
* "all placed objects" has to cover both, or it silently skips the templates.
*/
export type ObjectHolder = SceneAsset | ContainerAsset;
export function objectHolders(project: Project): ObjectHolder[] {
return project.assets.filter((a): a is ObjectHolder => a.type === 'scene' || a.type === 'container');
}
export interface SceneObjectRef {
scene: SceneAsset;
holder: ObjectHolder;
object: SceneObject;
}
/** Objects of all scenes that satisfy `predicate`, each with the scene it belongs to. */
export function sceneObjectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
export function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
const result: SceneObjectRef[] = [];
for (const scene of listScenes(project)) {
for (const object of scene.objects) if (predicate(object)) result.push({ scene, object });
for (const holder of objectHolders(project)) {
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
}
return result;
}
/** Every placed instance of an asset, across all scenes. */
/** Every placed instance of an asset, in scenes and in container templates alike. */
export function findUsages(project: Project, assetId: string): SceneObjectRef[] {
return sceneObjectsWhere(project, (o) => o.assetId === assetId);
return objectsWhere(project, (o) => o.assetId === assetId);
}
+7 -3
View File
@@ -1,7 +1,7 @@
import { newId } from '../ids';
import type { ContainerAsset } from './Asset';
import { chainOf, type ObjectTree } from './hierarchy';
import { findAsset, type Project, sceneObjectsWhere, type SceneObjectRef } from './Project';
import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
import type { SceneObject } from './SceneObject';
/** What a placed container's content object is linked to: the container and its template object. */
@@ -27,9 +27,13 @@ export function templateLinkOf(project: Project, tree: ObjectTree, object: Scene
return undefined;
}
/** Every placed copy of a template object, across all scenes (template ids are unique per project). */
/**
* Every placed copy of a template object (template ids are unique per project). Template
* objects themselves never carry a template id, so a template is never mistaken for a copy
* of itself even though the search covers container assets too.
*/
export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
return sceneObjectsWhere(project, (o) => o.templateId === templateId);
return objectsWhere(project, (o) => o.templateId === templateId);
}
/** Nesting depth at which a container inside a container inside … stops being expanded. */
+17 -29
View File
@@ -1,6 +1,7 @@
import { type Removed, removeWhere } from '../util/list';
import type { Position } from './Properties';
import type { SceneObject } from './SceneObject';
import { descendantsBelow, parentChain, sumAlongChain } from './walk';
/**
* Parent/child structure of a scene. `scene.objects` stays a flat list (storage order =
@@ -19,21 +20,18 @@ export function findObject(tree: ObjectTree, id: string): SceneObject | undefine
return tree.objects.find((o) => o.id === id);
}
export function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
return tree.objects.filter((o) => o.parentId === parentId);
}
/** The parent of an object, or undefined for a top-level one (and for a dangling link). */
function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined {
return object.parentId ? findObject(tree, object.parentId) : undefined;
}
/** All objects below `id`, depth-first, parents before children. */
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
const result: SceneObject[] = [];
const visit = (parentId: string) => {
for (const child of childrenOf(tree, parentId)) {
result.push(child);
visit(child.id);
}
};
visit(id);
return result;
return descendantsBelow(id, (parentId) => childrenOf(tree, parentId), (o) => o.id);
}
/** The ids of `rootIds` and of everything below them – what group operations act on. */
@@ -54,15 +52,7 @@ export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Re
/** An object and every parent above it, nearest first. Stops at a cycle (defensive). */
export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
const chain = [object];
const seen = new Set<string>([object.id]);
let parent = object.parentId ? findObject(tree, object.parentId) : undefined;
while (parent && !seen.has(parent.id)) {
chain.push(parent);
seen.add(parent.id);
parent = parent.parentId ? findObject(tree, parent.parentId) : undefined;
}
return chain;
return [...parentChain(object, (o) => parentOf(tree, o))];
}
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
@@ -86,19 +76,17 @@ export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[]
return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId)));
}
function sumAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
const result: Position = { x: 0, y: 0, z: 0 };
for (const o of chainOf(tree, object)) {
result.x += o.position.x + (withEditorOffset ? o.editorOffset.x : 0);
result.y += o.position.y + (withEditorOffset ? o.editorOffset.y : 0);
result.z += o.position.z;
}
return result;
function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
return sumAlongChain(
object,
(o) => parentOf(tree, o),
(o) => (withEditorOffset ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } : o.position),
);
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
return sumAlongChain(tree, object, false);
return positionAlongChain(tree, object, false);
}
/**
@@ -107,7 +95,7 @@ export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
* in the game, in collisions or in the graph ever sees it (they all use `worldPosition`).
*/
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
return sumAlongChain(tree, object, true);
return positionAlongChain(tree, object, true);
}
/** The local position that puts an object at `world` under the given parent ('' = top level). */
+60
View File
@@ -0,0 +1,60 @@
/**
* Walking a parent/child structure, generic over the shape. Two hierarchies exist side by
* side – scene objects (`parentId` + lookup in a flat list) and runtime entities (direct
* `parent` pointer) – and both walk the same way. Callers pass in how to get from an item to
* its parent (or children); everything else lives here, so the cycle guard exists once.
*/
import type { Position } from './Properties';
/**
* `start` and every ancestor above it, nearest first. An item that was already visited ends
* the walk instead of looping forever: a cyclic parent chain is always a bug (loading repairs
* one, see `store/serialize`), but no walk at runtime may hang because of it.
*/
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
const seen = new Set<T>();
for (let current: T | null | undefined = start; current && !seen.has(current); current = parentOf(current)) {
seen.add(current);
yield current;
}
}
/** Whether the parents of `start` lead into a cycle instead of up to a root. */
export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | undefined): boolean {
let last = start;
for (const item of parentChain(start, parentOf)) last = item;
// The walk stops either above a root or at an already visited item – only the latter still has a parent.
return Boolean(parentOf(last));
}
/**
* The local positions of `start` and all its ancestors summed up – the world position, as
* long as the hierarchy has no rotation or scale (it has none).
*/
export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | undefined, positionOf: (item: T) => Position): Position {
const total: Position = { x: 0, y: 0, z: 0 };
for (const item of parentChain(start, parentOf)) {
const position = positionOf(item);
total.x += position.x;
total.y += position.y;
total.z += position.z;
}
return total;
}
/**
* Everything below `rootKey`, depth-first, parents before children. `keyOf` turns a found item
* back into the key its own children are looked up by, which is what lets ids (scene objects)
* and object identity (entities) use the same walk.
*/
export function descendantsBelow<T, K>(rootKey: K, childrenOf: (key: K) => readonly T[], keyOf: (item: T) => K): T[] {
const result: T[] = [];
const visit = (key: K) => {
for (const child of childrenOf(key)) {
result.push(child);
visit(keyOf(child));
}
};
visit(rootKey);
return result;
}
+1 -3
View File
@@ -16,8 +16,6 @@ export type PropertyDef =
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string };
export type PropertyKind = PropertyDef['kind'];
export type PropertySchema = readonly PropertyDef[];
/** Kinds a behavior variable may have (what the node graph can compute with). */
@@ -40,7 +38,7 @@ export function isCommonProp(key: string): boolean {
}
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
camera: [
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1 },
+2 -5
View File
@@ -5,6 +5,7 @@
* what makes a container usable as a screen: switch it on to show it, off to hide it.
*/
import type { PropertyBag } from '../model/Properties';
import { parentChain } from '../model/walk';
/** A missing value counts as on. */
function propsEnabled(props: PropertyBag): boolean {
@@ -16,10 +17,6 @@ function propsEnabled(props: PropertyBag): boolean {
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
*/
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
const seen = new Set<T>();
for (let current: T | null = start; current && !seen.has(current); current = parentOf(current)) {
if (!propsEnabled(propsOf(current))) return false;
seen.add(current);
}
for (const item of parentChain(start, parentOf)) if (!propsEnabled(propsOf(item))) return false;
return true;
}
+4 -12
View File
@@ -5,6 +5,7 @@ import { isAssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { hasParentCycle } from '../model/walk';
import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
/**
@@ -94,18 +95,9 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
// Parents must exist in the same scene, differ from the object itself and not form cycles.
const byId = new Map(objects.map((o) => [o.id, o]));
for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = '';
for (const o of objects) {
const seen = new Set<string>([o.id]);
let current = o.parentId ? byId.get(o.parentId) : undefined;
while (current) {
if (seen.has(current.id)) {
o.parentId = '';
break;
}
seen.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
}
// Cut one link per cycle: an object already unlinked here is a root for the objects checked after it.
const parentOf = (o: SceneObject) => (o.parentId ? byId.get(o.parentId) : undefined);
for (const o of objects) if (hasParentCycle(o, parentOf)) o.parentId = '';
return objects;
}
+19
View File
@@ -4,6 +4,17 @@ export interface Removed<T> {
index: number;
}
/**
* Removes the first item matching `predicate` in place and returns it with its former index.
* `undefined` means nothing matched – the list is then untouched, so a caller may reject the
* edit afterwards without having to revert anything.
*/
export function removeFirst<T>(list: T[], predicate: (item: T) => boolean): Removed<T> | undefined {
const index = list.findIndex(predicate);
// A valid index makes `splice` return exactly one element; the `!` is that index check.
return index < 0 ? undefined : { item: list.splice(index, 1)[0]!, index };
}
/** Removes every item matching `predicate` in place; returns them with their former indices, ascending. */
export function removeWhere<T>(list: T[], predicate: (item: T) => boolean): Removed<T>[] {
const removed: Removed<T>[] = [];
@@ -18,3 +29,11 @@ export function removeWhere<T>(list: T[], predicate: (item: T) => boolean): Remo
export function restoreAt<T>(list: T[], removed: readonly Removed<T>[]): void {
for (const entry of [...removed].sort((a, b) => a.index - b.index)) list.splice(entry.index, 0, entry.item);
}
/**
* A **new** list without the item carrying `id` – unlike the `remove*` helpers above this one
* does not mutate, which is what undoing a `push` needs: `list = removeById(list, id)`.
*/
export function removeById<T extends { id: string }>(list: readonly T[], id: string): T[] {
return list.filter((item) => item.id !== id);
}