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:
1 parent
1eb28314cf
commit
f140c2fd77
58 files changed
+1969
-1171
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user