Add functionality for deleting project assets and related commands

This commit is contained in:
2026-04-12 03:37:29 +02:00
parent f28bda68ef
commit 37b53f509c
5 changed files with 496 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
import type { ProjectDocument, ProjectScene } from "../document/scene-document";
import { createDefaultWorldSettings } from "../document/world-settings";
import type { InteractionLink } from "../interactions/interaction-links";
import type { ProjectAssetRecord } from "./project-assets";
function removeInvalidatedInteractionLinks(
interactionLinks: ProjectScene["interactionLinks"],
removedModelInstanceIds: ReadonlySet<string>,
silencedSoundEmitterIds: ReadonlySet<string>
): ProjectScene["interactionLinks"] {
let didChange = false;
const nextInteractionLinks: ProjectScene["interactionLinks"] = {};
for (const [linkId, link] of Object.entries(interactionLinks)) {
const shouldRemove = (() => {
switch (link.action.type) {
case "playAnimation":
case "stopAnimation":
return removedModelInstanceIds.has(link.action.targetModelInstanceId);
case "playSound":
case "stopSound":
return silencedSoundEmitterIds.has(link.action.targetSoundEmitterId);
default:
return false;
}
})();
if (shouldRemove) {
didChange = true;
continue;
}
nextInteractionLinks[linkId] = link;
}
return didChange ? nextInteractionLinks : interactionLinks;
}
function cleanupSceneForDeletedAsset(
scene: ProjectScene,
asset: ProjectAssetRecord
): ProjectScene {
let nextWorld = scene.world;
let nextModelInstances = scene.modelInstances;
let nextEntities = scene.entities;
const removedModelInstanceIds = new Set<string>();
const silencedSoundEmitterIds = new Set<string>();
if (
asset.kind === "image" &&
scene.world.background.mode === "image" &&
scene.world.background.assetId === asset.id
) {
nextWorld = {
...scene.world,
background: createDefaultWorldSettings().background
};
}
if (asset.kind === "model") {
const remainingModelInstances: ProjectScene["modelInstances"] = {};
for (const [modelInstanceId, modelInstance] of Object.entries(
scene.modelInstances
)) {
if (modelInstance.assetId === asset.id) {
removedModelInstanceIds.add(modelInstanceId);
continue;
}
remainingModelInstances[modelInstanceId] = modelInstance;
}
if (removedModelInstanceIds.size > 0) {
nextModelInstances = remainingModelInstances;
}
}
if (asset.kind === "audio") {
const updatedEntities: ProjectScene["entities"] = {};
let didChangeEntities = false;
for (const [entityId, entity] of Object.entries(scene.entities)) {
if (entity.kind === "soundEmitter" && entity.audioAssetId === asset.id) {
silencedSoundEmitterIds.add(entityId);
updatedEntities[entityId] = {
...entity,
audioAssetId: null,
autoplay: false
};
didChangeEntities = true;
continue;
}
updatedEntities[entityId] = entity;
}
if (didChangeEntities) {
nextEntities = updatedEntities;
}
}
const nextInteractionLinks = removeInvalidatedInteractionLinks(
scene.interactionLinks,
removedModelInstanceIds,
silencedSoundEmitterIds
);
if (
nextWorld === scene.world &&
nextModelInstances === scene.modelInstances &&
nextEntities === scene.entities &&
nextInteractionLinks === scene.interactionLinks
) {
return scene;
}
return {
...scene,
world: nextWorld,
modelInstances: nextModelInstances,
entities: nextEntities,
interactionLinks: nextInteractionLinks
};
}
export function deleteProjectAssetFromProjectDocument(
projectDocument: ProjectDocument,
assetId: string
): ProjectDocument {
const asset = projectDocument.assets[assetId];
if (asset === undefined) {
throw new Error(`Project asset ${assetId} does not exist.`);
}
const nextAssets = {
...projectDocument.assets
};
delete nextAssets[assetId];
const nextScenes: ProjectDocument["scenes"] = {};
let didChangeScenes = false;
for (const [sceneId, scene] of Object.entries(projectDocument.scenes)) {
const nextScene = cleanupSceneForDeletedAsset(scene, asset);
nextScenes[sceneId] = nextScene;
didChangeScenes ||= nextScene !== scene;
}
return {
...projectDocument,
assets: nextAssets,
scenes: didChangeScenes ? nextScenes : projectDocument.scenes
};
}

View File

@@ -0,0 +1,83 @@
import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import { deleteProjectAssetFromProjectDocument } from "../assets/delete-project-asset";
import type { EditorCommand } from "./command";
function selectionTargetsMissingObject(
selection: EditorSelection,
activeScene: ReturnType<EditorCommand["execute"]> extends never ? never : { modelInstances: Record<string, unknown>; entities: Record<string, unknown> }
): boolean {
if (selection.kind === "modelInstances") {
return selection.ids.some((id) => activeScene.modelInstances[id] === undefined);
}
if (selection.kind === "entities") {
return selection.ids.some((id) => activeScene.entities[id] === undefined);
}
return false;
}
export function createDeleteProjectAssetCommand(assetId: string): EditorCommand {
let previousSelection: EditorSelection | null = null;
let previousToolMode: ToolMode | null = null;
let previousProjectDocument = null;
let assetLabel: string | null = null;
return {
id: createOpaqueId("command"),
label: "Delete project asset",
execute(context) {
const currentProjectDocument = context.getProjectDocument();
const currentAsset = currentProjectDocument.assets[assetId];
if (currentAsset === undefined) {
throw new Error(`Project asset ${assetId} does not exist.`);
}
if (previousProjectDocument === null) {
previousProjectDocument = currentProjectDocument;
}
if (previousSelection === null) {
previousSelection = cloneEditorSelection(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
assetLabel = currentAsset.sourceName;
const nextProjectDocument = deleteProjectAssetFromProjectDocument(
currentProjectDocument,
assetId
);
context.setProjectDocument(nextProjectDocument);
const activeScene = nextProjectDocument.scenes[nextProjectDocument.activeSceneId];
if (selectionTargetsMissingObject(context.getSelection(), activeScene)) {
context.setSelection({ kind: "none" });
}
context.setToolMode("select");
},
undo(context) {
if (previousProjectDocument === null) {
return;
}
context.setProjectDocument(previousProjectDocument);
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,74 @@
import { createOpaqueId } from "../core/ids";
import { getBoxBrushOrThrow, replaceBrush } from "./brush-command-helpers";
import type { EditorCommand } from "./command";
interface SetBoxBrushAuthoredStateCommandOptions {
brushId: string;
visible?: boolean;
enabled?: boolean;
}
function createCommandLabel(options: SetBoxBrushAuthoredStateCommandOptions): string {
if (options.enabled !== undefined && options.visible === undefined) {
return options.enabled ? "Enable box brush" : "Disable box brush";
}
if (options.visible !== undefined && options.enabled === undefined) {
return options.visible ? "Show box brush" : "Hide box brush";
}
return "Update box brush state";
}
export function createSetBoxBrushAuthoredStateCommand(
options: SetBoxBrushAuthoredStateCommandOptions
): EditorCommand {
if (options.visible === undefined && options.enabled === undefined) {
throw new Error("Box brush authored state command requires at least one change.");
}
let previousVisible: boolean | null = null;
let previousEnabled: boolean | null = null;
return {
id: createOpaqueId("command"),
label: createCommandLabel(options),
execute(context) {
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
if (previousVisible === null) {
previousVisible = brush.visible;
}
if (previousEnabled === null) {
previousEnabled = brush.enabled;
}
context.setDocument(
replaceBrush(currentDocument, {
...brush,
visible: options.visible ?? brush.visible,
enabled: options.enabled ?? brush.enabled
})
);
},
undo(context) {
if (previousVisible === null || previousEnabled === null) {
return;
}
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
context.setDocument(
replaceBrush(currentDocument, {
...brush,
visible: previousVisible,
enabled: previousEnabled
})
);
}
};
}

View File

@@ -0,0 +1,90 @@
import { createOpaqueId } from "../core/ids";
import { cloneEntityInstance } from "../entities/entity-instances";
import type { EditorCommand } from "./command";
interface SetEntityAuthoredStateCommandOptions {
entityId: string;
visible?: boolean;
enabled?: boolean;
}
function createCommandLabel(options: SetEntityAuthoredStateCommandOptions): string {
if (options.enabled !== undefined && options.visible === undefined) {
return options.enabled ? "Enable entity" : "Disable entity";
}
if (options.visible !== undefined && options.enabled === undefined) {
return options.visible ? "Show entity" : "Hide entity";
}
return "Update entity state";
}
export function createSetEntityAuthoredStateCommand(
options: SetEntityAuthoredStateCommandOptions
): EditorCommand {
if (options.visible === undefined && options.enabled === undefined) {
throw new Error("Entity authored state command requires at least one change.");
}
let previousVisible: boolean | null = null;
let previousEnabled: boolean | null = null;
return {
id: createOpaqueId("command"),
label: createCommandLabel(options),
execute(context) {
const currentDocument = context.getDocument();
const entity = currentDocument.entities[options.entityId];
if (entity === undefined) {
throw new Error(`Entity ${options.entityId} does not exist.`);
}
if (previousVisible === null) {
previousVisible = entity.visible;
}
if (previousEnabled === null) {
previousEnabled = entity.enabled;
}
context.setDocument({
...currentDocument,
entities: {
...currentDocument.entities,
[entity.id]: cloneEntityInstance({
...entity,
visible: options.visible ?? entity.visible,
enabled: options.enabled ?? entity.enabled
})
}
});
},
undo(context) {
if (previousVisible === null || previousEnabled === null) {
return;
}
const currentDocument = context.getDocument();
const entity = currentDocument.entities[options.entityId];
if (entity === undefined) {
return;
}
context.setDocument({
...currentDocument,
entities: {
...currentDocument.entities,
[entity.id]: cloneEntityInstance({
...entity,
visible: previousVisible,
enabled: previousEnabled
})
}
});
}
};
}

View File

@@ -0,0 +1,92 @@
import { createOpaqueId } from "../core/ids";
import { cloneModelInstance } from "../assets/model-instances";
import type { EditorCommand } from "./command";
interface SetModelInstanceAuthoredStateCommandOptions {
modelInstanceId: string;
visible?: boolean;
enabled?: boolean;
}
function createCommandLabel(
options: SetModelInstanceAuthoredStateCommandOptions
): string {
if (options.enabled !== undefined && options.visible === undefined) {
return options.enabled ? "Enable model instance" : "Disable model instance";
}
if (options.visible !== undefined && options.enabled === undefined) {
return options.visible ? "Show model instance" : "Hide model instance";
}
return "Update model instance state";
}
export function createSetModelInstanceAuthoredStateCommand(
options: SetModelInstanceAuthoredStateCommandOptions
): EditorCommand {
if (options.visible === undefined && options.enabled === undefined) {
throw new Error("Model instance authored state command requires at least one change.");
}
let previousVisible: boolean | null = null;
let previousEnabled: boolean | null = null;
return {
id: createOpaqueId("command"),
label: createCommandLabel(options),
execute(context) {
const currentDocument = context.getDocument();
const modelInstance = currentDocument.modelInstances[options.modelInstanceId];
if (modelInstance === undefined) {
throw new Error(`Model instance ${options.modelInstanceId} does not exist.`);
}
if (previousVisible === null) {
previousVisible = modelInstance.visible;
}
if (previousEnabled === null) {
previousEnabled = modelInstance.enabled;
}
context.setDocument({
...currentDocument,
modelInstances: {
...currentDocument.modelInstances,
[modelInstance.id]: cloneModelInstance({
...modelInstance,
visible: options.visible ?? modelInstance.visible,
enabled: options.enabled ?? modelInstance.enabled
})
}
});
},
undo(context) {
if (previousVisible === null || previousEnabled === null) {
return;
}
const currentDocument = context.getDocument();
const modelInstance = currentDocument.modelInstances[options.modelInstanceId];
if (modelInstance === undefined) {
return;
}
context.setDocument({
...currentDocument,
modelInstances: {
...currentDocument.modelInstances,
[modelInstance.id]: cloneModelInstance({
...modelInstance,
visible: previousVisible,
enabled: previousEnabled
})
}
});
}
};
}