auto-git:

[add] playwright.config.js
 [add] src/app/App.js
 [add] src/app/editor-store.js
 [add] src/app/use-editor-store.js
 [add] src/assets/audio-assets.js
 [add] src/assets/gltf-model-import.js
 [add] src/assets/image-assets.js
 [add] src/assets/model-instance-labels.js
 [add] src/assets/model-instance-rendering.js
 [add] src/assets/model-instances.js
 [add] src/assets/project-asset-storage.js
 [add] src/assets/project-assets.js
 [add] src/commands/brush-command-helpers.js
 [add] src/commands/command-history.js
 [add] src/commands/command.js
 [add] src/commands/commit-transform-session-command.js
 [add] src/commands/create-box-brush-command.js
 [add] src/commands/delete-box-brush-command.js
 [add] src/commands/delete-entity-command.js
 [add] src/commands/delete-interaction-link-command.js
 [add] src/commands/delete-model-instance-command.js
 [add] src/commands/import-audio-asset-command.js
 [add] src/commands/import-background-image-asset-command.js
 [add] src/commands/import-model-asset-command.js
 [add] src/commands/move-box-brush-command.js
 [add] src/commands/resize-box-brush-command.js
 [add] src/commands/rotate-box-brush-command.js
 [add] src/commands/set-box-brush-face-material-command.js
 [add] src/commands/set-box-brush-face-uv-state-command.js
 [add] src/commands/set-box-brush-name-command.js
 [add] src/commands/set-box-brush-transform-command.js
 [add] src/commands/set-entity-name-command.js
 [add] src/commands/set-model-instance-name-command.js
 [add] src/commands/set-player-start-command.js
 [add] src/commands/set-scene-name-command.js
 [add] src/commands/set-world-settings-command.js
 [add] src/commands/upsert-entity-command.js
 [add] src/commands/upsert-interaction-link-command.js
 [add] src/commands/upsert-model-instance-command.js
 [add] src/core/ids.js
 [add] src/core/selection.js
 [add] src/core/tool-mode.js
 [add] src/core/transform-session.js
 [add] src/core/vector.js
 [add] src/core/whitebox-selection-feedback.js
 [add] src/core/whitebox-selection-mode.js
 [add] src/document/brushes.js
 [add] src/document/migrate-scene-document.js
 [add] src/document/scene-document-validation.js
 [add] src/document/scene-document.js
 [add] src/document/world-settings.js
 [add] src/entities/entity-instances.js
 [add] src/entities/entity-labels.js
 [add] src/geometry/box-brush-components.js
 [add] src/geometry/box-brush-mesh.js
 [add] src/geometry/box-brush.js
 [add] src/geometry/box-face-uvs.js
 [add] src/geometry/grid-snapping.js
 [add] src/geometry/model-instance-collider-debug-mesh.js
 [add] src/geometry/model-instance-collider-generation.js
 [add] src/interactions/interaction-links.js
 [add] src/main.js
 [add] src/materials/starter-material-library.js
 [add] src/materials/starter-material-textures.js
 [add] src/rendering/advanced-rendering.js
 [add] src/runner-web/RunnerCanvas.js
 [add] src/runtime-three/first-person-navigation-controller.js
 [add] src/runtime-three/navigation-controller.js
 [add] src/runtime-three/orbit-visitor-navigation-controller.js
 [add] src/runtime-three/player-collision.js
 [add] src/runtime-three/rapier-collision-world.js
 [add] src/runtime-three/runtime-audio-system.js
 [add] src/runtime-three/runtime-host.js
 [add] src/runtime-three/runtime-interaction-system.js
 [add] src/runtime-three/runtime-scene-build.js
 [add] src/runtime-three/runtime-scene-validation.js
 [add] src/serialization/local-draft-storage.js
 [add] src/serialization/scene-document-json.js
 [add] src/shared-ui/HierarchicalMenu.js
 [add] src/shared-ui/Panel.js
 [add] src/shared-ui/world-background-style.js
 [add] src/viewport-three/ViewportCanvas.js
 [add] src/viewport-three/ViewportPanel.js
 [add] src/viewport-three/viewport-entity-markers.js
 [add] src/viewport-three/viewport-focus.js
 [add] src/viewport-three/viewport-host.js
 [add] src/viewport-three/viewport-layout.js
 [add] src/viewport-three/viewport-transient-state.js
 [add] src/viewport-three/viewport-view-modes.js
 [add] tests/domain/box-brush-face-editing.command.test.js
 [add] tests/domain/build-runtime-scene.test.js
 [add] tests/domain/create-box-brush.command.test.js
 [add] tests/domain/create-empty-scene-document.test.js
 [add] tests/domain/editor-store.test.js
 [add] tests/domain/entity.command.test.js
 [add] tests/domain/interaction-links.validation.test.js
 [add] tests/domain/model-import.test.js
 [add] tests/domain/model-instance.command.test.js
 [add] tests/domain/player-start.command.test.js
 [add] tests/domain/rapier-collision-world.test.js
 [add] tests/domain/runtime-audio-system.test.js
 [add] tests/domain/runtime-interaction-system.test.js
 [add] tests/domain/runtime-scene-validation.test.js
 [add] tests/domain/scene-document-validation.test.js
 [add] tests/domain/transform-session.command.test.js
 [add] tests/domain/world-settings.command.test.js
 [add] tests/domain/world-settings.test.js
 [add] tests/e2e/app-smoke.e2e.js
 [add] tests/e2e/box-brush-authoring.e2e.js
 [add] tests/e2e/entities-foundation.e2e.js
 [add] tests/e2e/face-material-authoring.e2e.js
 [add] tests/e2e/first-room-workflow.e2e.js
 [add] tests/e2e/import-draco-model-asset.e2e.js
 [add] tests/e2e/import-external-model-asset.e2e.js
 [add] tests/e2e/import-model-asset.e2e.js
 [add] tests/e2e/local-lights-and-background.e2e.js
 [add] tests/e2e/orthographic-views.e2e.js
 [add] tests/e2e/runner-v1.e2e.js
 [add] tests/e2e/runtime-click-interaction.e2e.js
 [add] tests/e2e/runtime-trigger-teleport.e2e.js
 [add] tests/e2e/viewport-quad-layout.e2e.js
 [add] tests/e2e/viewport-test-helpers.js
 [add] tests/e2e/whitebox-component-selection.e2e.js
 [add] tests/e2e/world-environment.e2e.js
 [add] tests/geometry/box-brush-geometry.test.js
 [add] tests/geometry/box-face-uvs.test.js
 [add] tests/geometry/model-instance-collider-generation.test.js
 [add] tests/helpers/model-collider-fixtures.js
 [add] tests/serialization/local-draft-storage.test.js
 [add] tests/serialization/project-asset-storage.test.js
 [add] tests/serialization/scene-document-json.test.js
 [add] tests/setup/vitest.setup.js
 [add] tests/unit/audio-assets.test.js
 [add] tests/unit/entity-instances.test.js
 [add] tests/unit/package-scripts.test.js
 [add] tests/unit/transform-foundation.integration.test.js
 [add] tests/unit/viewport-canvas.test.js
 [add] tests/unit/viewport-entity-markers.test.js
 [add] tests/unit/viewport-focus.test.js
 [add] tests/unit/viewport-layout.test.js
 [add] tests/unit/viewport-view-modes.test.js
 [add] vite.config.js
 [add] vitest.config.js
This commit is contained in:
2026-04-05 02:25:08 +02:00
parent b23cb0dd32
commit 0b54fa14c9
143 changed files with 26849 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
import { cloneEditorSelection } from "../core/selection";
import { cloneFaceUvState } from "../document/brushes";
export function getBoxBrushOrThrow(document, brushId) {
const brush = document.brushes[brushId];
if (brush === undefined) {
throw new Error(`Box brush ${brushId} does not exist.`);
}
if (brush.kind !== "box") {
throw new Error(`Brush ${brushId} is not a supported box brush.`);
}
return brush;
}
export function setSingleBrushSelection(brushId) {
return {
kind: "brushes",
ids: [brushId]
};
}
export function setSingleBrushFaceSelection(brushId, faceId) {
return {
kind: "brushFace",
brushId,
faceId
};
}
export function setSingleBrushEdgeSelection(brushId, edgeId) {
return {
kind: "brushEdge",
brushId,
edgeId
};
}
export function setSingleBrushVertexSelection(brushId, vertexId) {
return {
kind: "brushVertex",
brushId,
vertexId
};
}
export function cloneSelectionForCommand(selection) {
return cloneEditorSelection(selection);
}
export function replaceBrush(document, brush) {
return {
...document,
brushes: {
...document.brushes,
[brush.id]: brush
}
};
}
export function removeBrush(document, brushId) {
const remainingBrushes = {
...document.brushes
};
delete remainingBrushes[brushId];
return {
...document,
brushes: remainingBrushes
};
}
export function getBoxBrushFaceOrThrow(document, brushId, faceId) {
const brush = getBoxBrushOrThrow(document, brushId);
const face = brush.faces[faceId];
if (face === undefined) {
throw new Error(`Box brush ${brushId} does not contain face ${faceId}.`);
}
return face;
}
export function replaceBoxBrushFace(document, brushId, faceId, face) {
const brush = getBoxBrushOrThrow(document, brushId);
return replaceBrush(document, {
...brush,
faces: {
...brush.faces,
[faceId]: {
materialId: face.materialId,
uv: cloneFaceUvState(face.uv)
}
}
});
}

View File

@@ -0,0 +1,37 @@
export class CommandHistory {
undoStack = [];
redoStack = [];
execute(command, context) {
command.execute(context);
this.undoStack.push(command);
this.redoStack.length = 0;
}
undo(context) {
const command = this.undoStack.pop();
if (command === undefined) {
return null;
}
command.undo(context);
this.redoStack.push(command);
return command;
}
redo(context) {
const command = this.redoStack.pop();
if (command === undefined) {
return null;
}
command.execute(context);
this.undoStack.push(command);
return command;
}
clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
canUndo() {
return this.undoStack.length > 0;
}
canRedo() {
return this.redoStack.length > 0;
}
}

1
src/commands/command.js Normal file
View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,197 @@
import { createMoveBoxBrushCommand } from "./move-box-brush-command";
import { createResizeBoxBrushCommand } from "./resize-box-brush-command";
import { createRotateBoxBrushCommand } from "./rotate-box-brush-command";
import { createSetBoxBrushTransformCommand } from "./set-box-brush-transform-command";
import { createUpsertEntityCommand } from "./upsert-entity-command";
import { createUpsertModelInstanceCommand } from "./upsert-model-instance-command";
import { createModelInstance } from "../assets/model-instances";
import { createInteractableEntity, createPlayerStartEntity, createPointLightEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../entities/entity-instances";
import { getTransformOperationLabel } from "../core/transform-session";
function createTransformCommandLabel(session) {
return `${getTransformOperationLabel(session.operation)} ${session.target.kind === "brush"
? "whitebox box"
: session.target.kind === "brushFace"
? "whitebox face"
: session.target.kind === "brushEdge"
? "whitebox edge"
: session.target.kind === "brushVertex"
? "whitebox vertex"
: session.target.kind === "entity"
? session.target.entityKind === "playerStart"
? "player start"
: session.target.entityKind === "pointLight"
? "point light"
: session.target.entityKind === "spotLight"
? "spot light"
: session.target.entityKind === "soundEmitter"
? "sound emitter"
: session.target.entityKind === "triggerVolume"
? "trigger volume"
: session.target.entityKind === "teleportTarget"
? "teleport target"
: "interactable"
: "model instance"}`;
}
export function createCommitTransformSessionCommand(document, session) {
switch (session.target.kind) {
case "brush":
if (session.preview.kind !== "brush") {
throw new Error("Brush transform preview is invalid.");
}
switch (session.operation) {
case "translate":
return createMoveBoxBrushCommand({
brushId: session.target.brushId,
center: session.preview.center,
snapToGrid: false,
label: createTransformCommandLabel(session)
});
case "rotate":
return createRotateBoxBrushCommand({
brushId: session.target.brushId,
rotationDegrees: session.preview.rotationDegrees,
label: createTransformCommandLabel(session)
});
case "scale":
return createResizeBoxBrushCommand({
brushId: session.target.brushId,
size: session.preview.size,
snapToGrid: false,
label: createTransformCommandLabel(session)
});
}
case "brushFace":
if (session.preview.kind !== "brush") {
throw new Error("Whitebox face transform preview is invalid.");
}
return createSetBoxBrushTransformCommand({
selection: {
kind: "brushFace",
brushId: session.target.brushId,
faceId: session.target.faceId
},
center: session.preview.center,
rotationDegrees: session.preview.rotationDegrees,
size: session.preview.size,
label: createTransformCommandLabel(session)
});
case "brushEdge":
if (session.preview.kind !== "brush") {
throw new Error("Whitebox edge transform preview is invalid.");
}
return createSetBoxBrushTransformCommand({
selection: {
kind: "brushEdge",
brushId: session.target.brushId,
edgeId: session.target.edgeId
},
center: session.preview.center,
rotationDegrees: session.preview.rotationDegrees,
size: session.preview.size,
label: createTransformCommandLabel(session)
});
case "brushVertex":
if (session.preview.kind !== "brush") {
throw new Error("Whitebox vertex transform preview is invalid.");
}
return createSetBoxBrushTransformCommand({
selection: {
kind: "brushVertex",
brushId: session.target.brushId,
vertexId: session.target.vertexId
},
center: session.preview.center,
rotationDegrees: session.preview.rotationDegrees,
size: session.preview.size,
label: createTransformCommandLabel(session)
});
case "modelInstance": {
if (session.preview.kind !== "modelInstance") {
throw new Error("Model instance transform preview is invalid.");
}
const modelInstance = document.modelInstances[session.target.modelInstanceId];
if (modelInstance === undefined) {
throw new Error(`Model instance ${session.target.modelInstanceId} does not exist.`);
}
return createUpsertModelInstanceCommand({
modelInstance: createModelInstance({
...modelInstance,
position: session.preview.position,
rotationDegrees: session.preview.rotationDegrees,
scale: session.preview.scale
}),
label: createTransformCommandLabel(session)
});
}
case "entity": {
if (session.preview.kind !== "entity") {
throw new Error("Entity transform preview is invalid.");
}
const entity = document.entities[session.target.entityId];
if (entity === undefined) {
throw new Error(`Entity ${session.target.entityId} does not exist.`);
}
switch (entity.kind) {
case "pointLight":
return createUpsertEntityCommand({
entity: createPointLightEntity({
...entity,
position: session.preview.position
}),
label: createTransformCommandLabel(session)
});
case "spotLight":
return createUpsertEntityCommand({
entity: createSpotLightEntity({
...entity,
position: session.preview.position,
direction: session.preview.rotation.kind === "direction" ? session.preview.rotation.direction : entity.direction
}),
label: createTransformCommandLabel(session)
});
case "playerStart":
return createUpsertEntityCommand({
entity: createPlayerStartEntity({
...entity,
position: session.preview.position,
yawDegrees: session.preview.rotation.kind === "yaw" ? session.preview.rotation.yawDegrees : entity.yawDegrees
}),
label: createTransformCommandLabel(session)
});
case "soundEmitter":
return createUpsertEntityCommand({
entity: createSoundEmitterEntity({
...entity,
position: session.preview.position
}),
label: createTransformCommandLabel(session)
});
case "triggerVolume":
return createUpsertEntityCommand({
entity: createTriggerVolumeEntity({
...entity,
position: session.preview.position
}),
label: createTransformCommandLabel(session)
});
case "teleportTarget":
return createUpsertEntityCommand({
entity: createTeleportTargetEntity({
...entity,
position: session.preview.position,
yawDegrees: session.preview.rotation.kind === "yaw" ? session.preview.rotation.yawDegrees : entity.yawDegrees
}),
label: createTransformCommandLabel(session)
});
case "interactable":
return createUpsertEntityCommand({
entity: createInteractableEntity({
...entity,
position: session.preview.position
}),
label: createTransformCommandLabel(session)
});
}
}
}
}

View File

@@ -0,0 +1,51 @@
import { createBoxBrush, DEFAULT_BOX_BRUSH_CENTER, DEFAULT_BOX_BRUSH_SIZE } from "../document/brushes";
import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping";
import { createOpaqueId } from "../core/ids";
import { cloneSelectionForCommand, removeBrush, setSingleBrushSelection } from "./brush-command-helpers";
export function createCreateBoxBrushCommand(options = {}) {
const snapToGrid = options.snapToGrid ?? true;
const brush = createBoxBrush({
center: snapToGrid === false
? options.center ?? DEFAULT_BOX_BRUSH_CENTER
: snapVec3ToGrid(options.center ?? DEFAULT_BOX_BRUSH_CENTER, options.gridSize ?? DEFAULT_GRID_SIZE),
size: snapToGrid === false
? options.size ?? DEFAULT_BOX_BRUSH_SIZE
: snapPositiveSizeToGrid(options.size ?? DEFAULT_BOX_BRUSH_SIZE, options.gridSize ?? DEFAULT_GRID_SIZE)
});
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: "Create box brush",
execute(context) {
const currentDocument = context.getDocument();
if (currentDocument.brushes[brush.id] !== undefined) {
throw new Error(`Box brush ${brush.id} already exists.`);
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument({
...currentDocument,
brushes: {
...currentDocument.brushes,
[brush.id]: brush
}
});
context.setSelection(setSingleBrushSelection(brush.id));
context.setToolMode("select");
},
undo(context) {
context.setDocument(removeBrush(context.getDocument(), brush.id));
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,59 @@
import { createOpaqueId } from "../core/ids";
import { cloneBoxBrush } from "../document/brushes";
import { cloneSelectionForCommand, removeBrush } from "./brush-command-helpers";
function selectionIncludesBrush(selection, brushId) {
return ((selection.kind === "brushes" && selection.ids.includes(brushId)) ||
((selection.kind === "brushFace" || selection.kind === "brushEdge" || selection.kind === "brushVertex") &&
selection.brushId === brushId));
}
export function createDeleteBoxBrushCommand(brushId) {
let previousBrush = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: "Delete box brush",
execute(context) {
const currentDocument = context.getDocument();
const currentBrush = currentDocument.brushes[brushId];
if (currentBrush === undefined) {
throw new Error(`Box brush ${brushId} does not exist.`);
}
if (previousBrush === null) {
previousBrush = cloneBoxBrush(currentBrush);
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument(removeBrush(currentDocument, brushId));
if (selectionIncludesBrush(context.getSelection(), brushId)) {
context.setSelection({
kind: "none"
});
}
context.setToolMode("select");
},
undo(context) {
if (previousBrush === null) {
return;
}
const currentDocument = context.getDocument();
context.setDocument({
...currentDocument,
brushes: {
...currentDocument.brushes,
[previousBrush.id]: cloneBoxBrush(previousBrush)
}
});
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,64 @@
import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection } from "../core/selection";
import { cloneEntityInstance } from "../entities/entity-instances";
function selectionIncludesEntity(selection, entityId) {
return selection.kind === "entities" && selection.ids.includes(entityId);
}
export function createDeleteEntityCommand(entityId) {
let previousEntity = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: "Delete entity",
execute(context) {
const currentDocument = context.getDocument();
const currentEntity = currentDocument.entities[entityId];
if (currentEntity === undefined) {
throw new Error(`Entity ${entityId} does not exist.`);
}
if (previousEntity === null) {
previousEntity = cloneEntityInstance(currentEntity);
}
if (previousSelection === null) {
previousSelection = cloneEditorSelection(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
const nextEntities = {
...currentDocument.entities
};
delete nextEntities[entityId];
context.setDocument({
...currentDocument,
entities: nextEntities
});
if (selectionIncludesEntity(context.getSelection(), entityId)) {
context.setSelection({
kind: "none"
});
}
context.setToolMode("select");
},
undo(context) {
if (previousEntity === null) {
return;
}
const currentDocument = context.getDocument();
context.setDocument({
...currentDocument,
entities: {
...currentDocument.entities,
[previousEntity.id]: cloneEntityInstance(previousEntity)
}
});
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,40 @@
import { createOpaqueId } from "../core/ids";
import { cloneInteractionLink } from "../interactions/interaction-links";
export function createDeleteInteractionLinkCommand(linkId) {
let previousLink = null;
return {
id: createOpaqueId("command"),
label: "Delete interaction link",
execute(context) {
const currentDocument = context.getDocument();
const currentLink = currentDocument.interactionLinks[linkId];
if (currentLink === undefined) {
throw new Error(`Interaction link ${linkId} does not exist.`);
}
if (previousLink === null) {
previousLink = cloneInteractionLink(currentLink);
}
const nextInteractionLinks = {
...currentDocument.interactionLinks
};
delete nextInteractionLinks[linkId];
context.setDocument({
...currentDocument,
interactionLinks: nextInteractionLinks
});
},
undo(context) {
if (previousLink === null) {
return;
}
const currentDocument = context.getDocument();
context.setDocument({
...currentDocument,
interactionLinks: {
...currentDocument.interactionLinks,
[previousLink.id]: cloneInteractionLink(previousLink)
}
});
}
};
}

View File

@@ -0,0 +1,64 @@
import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection } from "../core/selection";
import { cloneModelInstance } from "../assets/model-instances";
function selectionIncludesModelInstance(selection, modelInstanceId) {
return selection.kind === "modelInstances" && selection.ids.includes(modelInstanceId);
}
export function createDeleteModelInstanceCommand(modelInstanceId) {
let previousModelInstance = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: "Delete model instance",
execute(context) {
const currentDocument = context.getDocument();
const currentModelInstance = currentDocument.modelInstances[modelInstanceId];
if (currentModelInstance === undefined) {
throw new Error(`Model instance ${modelInstanceId} does not exist.`);
}
if (previousModelInstance === null) {
previousModelInstance = cloneModelInstance(currentModelInstance);
}
if (previousSelection === null) {
previousSelection = cloneEditorSelection(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
const nextModelInstances = {
...currentDocument.modelInstances
};
delete nextModelInstances[modelInstanceId];
context.setDocument({
...currentDocument,
modelInstances: nextModelInstances
});
if (selectionIncludesModelInstance(context.getSelection(), modelInstanceId)) {
context.setSelection({
kind: "none"
});
}
context.setToolMode("select");
},
undo(context) {
if (previousModelInstance === null) {
return;
}
const currentDocument = context.getDocument();
context.setDocument({
...currentDocument,
modelInstances: {
...currentDocument.modelInstances,
[previousModelInstance.id]: cloneModelInstance(previousModelInstance)
}
});
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,33 @@
import { createOpaqueId } from "../core/ids";
import { cloneProjectAssetRecord } from "../assets/project-assets";
export function createImportAudioAssetCommand(options) {
const nextAsset = cloneProjectAssetRecord(options.asset);
return {
id: createOpaqueId("command"),
label: options.label ?? `Import ${nextAsset.sourceName}`,
execute(context) {
const currentDocument = context.getDocument();
if (currentDocument.assets[nextAsset.id] !== undefined) {
throw new Error(`Asset ${nextAsset.id} already exists.`);
}
context.setDocument({
...currentDocument,
assets: {
...currentDocument.assets,
[nextAsset.id]: cloneProjectAssetRecord(nextAsset)
}
});
},
undo(context) {
const currentDocument = context.getDocument();
const nextAssets = {
...currentDocument.assets
};
delete nextAssets[nextAsset.id];
context.setDocument({
...currentDocument,
assets: nextAssets
});
}
};
}

View File

@@ -0,0 +1,41 @@
import { createOpaqueId } from "../core/ids";
import { cloneWorldSettings } from "../document/world-settings";
import { cloneProjectAssetRecord } from "../assets/project-assets";
export function createImportBackgroundImageAssetCommand(options) {
const nextAsset = cloneProjectAssetRecord(options.asset);
const nextWorld = cloneWorldSettings(options.world);
let previousWorld = null;
return {
id: createOpaqueId("command"),
label: options.label ?? `Import ${nextAsset.sourceName} as background`,
execute(context) {
const currentDocument = context.getDocument();
if (currentDocument.assets[nextAsset.id] !== undefined) {
throw new Error(`Asset ${nextAsset.id} already exists.`);
}
if (previousWorld === null) {
previousWorld = cloneWorldSettings(currentDocument.world);
}
context.setDocument({
...currentDocument,
assets: {
...currentDocument.assets,
[nextAsset.id]: cloneProjectAssetRecord(nextAsset)
},
world: cloneWorldSettings(nextWorld)
});
},
undo(context) {
const currentDocument = context.getDocument();
const nextAssets = {
...currentDocument.assets
};
delete nextAssets[nextAsset.id];
context.setDocument({
...currentDocument,
assets: nextAssets,
world: previousWorld === null ? cloneWorldSettings(currentDocument.world) : cloneWorldSettings(previousWorld)
});
}
};
}

View File

@@ -0,0 +1,70 @@
import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection } from "../core/selection";
import { cloneModelInstance } from "../assets/model-instances";
import { cloneProjectAssetRecord } from "../assets/project-assets";
function setSingleModelInstanceSelection(modelInstanceId) {
return {
kind: "modelInstances",
ids: [modelInstanceId]
};
}
export function createImportModelAssetCommand(options) {
const nextAsset = cloneProjectAssetRecord(options.asset);
const nextModelInstance = cloneModelInstance(options.modelInstance);
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? `Import ${nextAsset.sourceName}`,
execute(context) {
const currentDocument = context.getDocument();
if (currentDocument.assets[nextAsset.id] !== undefined) {
throw new Error(`Asset ${nextAsset.id} already exists.`);
}
if (currentDocument.modelInstances[nextModelInstance.id] !== undefined) {
throw new Error(`Model instance ${nextModelInstance.id} already exists.`);
}
if (previousSelection === null) {
previousSelection = cloneEditorSelection(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument({
...currentDocument,
assets: {
...currentDocument.assets,
[nextAsset.id]: cloneProjectAssetRecord(nextAsset)
},
modelInstances: {
...currentDocument.modelInstances,
[nextModelInstance.id]: cloneModelInstance(nextModelInstance)
}
});
context.setSelection(setSingleModelInstanceSelection(nextModelInstance.id));
context.setToolMode("select");
},
undo(context) {
const currentDocument = context.getDocument();
const nextAssets = {
...currentDocument.assets
};
const nextModelInstances = {
...currentDocument.modelInstances
};
delete nextAssets[nextAsset.id];
delete nextModelInstances[nextModelInstance.id];
context.setDocument({
...currentDocument,
assets: nextAssets,
modelInstances: nextModelInstances
});
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,55 @@
import { DEFAULT_GRID_SIZE, snapVec3ToGrid } from "../geometry/grid-snapping";
import { createOpaqueId } from "../core/ids";
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers";
export function createMoveBoxBrushCommand(options) {
const resolvedCenter = options.snapToGrid === false ? options.center : snapVec3ToGrid(options.center, options.gridSize ?? DEFAULT_GRID_SIZE);
let previousCenter = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? "Move box brush",
execute(context) {
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
if (previousCenter === null) {
previousCenter = {
...brush.center
};
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument(replaceBrush(currentDocument, {
...brush,
center: {
...resolvedCenter
}
}));
context.setSelection(setSingleBrushSelection(options.brushId));
context.setToolMode("select");
},
undo(context) {
if (previousCenter === null) {
return;
}
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
context.setDocument(replaceBrush(currentDocument, {
...brush,
center: {
...previousCenter
}
}));
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,55 @@
import { DEFAULT_GRID_SIZE, snapPositiveSizeToGrid } from "../geometry/grid-snapping";
import { createOpaqueId } from "../core/ids";
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers";
export function createResizeBoxBrushCommand(options) {
const resolvedSize = options.snapToGrid === false ? options.size : snapPositiveSizeToGrid(options.size, options.gridSize ?? DEFAULT_GRID_SIZE);
let previousSize = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? "Resize box brush",
execute(context) {
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
if (previousSize === null) {
previousSize = {
...brush.size
};
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument(replaceBrush(currentDocument, {
...brush,
size: {
...resolvedSize
}
}));
context.setSelection(setSingleBrushSelection(options.brushId));
context.setToolMode("select");
},
undo(context) {
if (previousSize === null) {
return;
}
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
context.setDocument(replaceBrush(currentDocument, {
...brush,
size: {
...previousSize
}
}));
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,53 @@
import { createOpaqueId } from "../core/ids";
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushSelection } from "./brush-command-helpers";
export function createRotateBoxBrushCommand(options) {
let previousRotationDegrees = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? "Rotate box brush",
execute(context) {
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
if (previousRotationDegrees === null) {
previousRotationDegrees = {
...brush.rotationDegrees
};
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument(replaceBrush(currentDocument, {
...brush,
rotationDegrees: {
...options.rotationDegrees
}
}));
context.setSelection(setSingleBrushSelection(options.brushId));
context.setToolMode("select");
},
undo(context) {
if (previousRotationDegrees === null) {
return;
}
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
context.setDocument(replaceBrush(currentDocument, {
...brush,
rotationDegrees: {
...previousRotationDegrees
}
}));
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,50 @@
import { createOpaqueId } from "../core/ids";
import { cloneSelectionForCommand, getBoxBrushFaceOrThrow, replaceBoxBrushFace, setSingleBrushFaceSelection } from "./brush-command-helpers";
export function createSetBoxBrushFaceMaterialCommand(options) {
let previousMaterialId;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.materialId === null ? `Clear ${options.faceId} face material` : `Apply material to ${options.faceId} face`,
execute(context) {
const currentDocument = context.getDocument();
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
if (options.materialId !== null && currentDocument.materials[options.materialId] === undefined) {
throw new Error(`Material ${options.materialId} does not exist in the document registry.`);
}
if (previousMaterialId === undefined) {
previousMaterialId = currentFace.materialId;
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
...currentFace,
materialId: options.materialId
}));
context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId));
context.setToolMode("select");
},
undo(context) {
if (previousMaterialId === undefined) {
return;
}
const currentDocument = context.getDocument();
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
...currentFace,
materialId: previousMaterialId
}));
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,48 @@
import { createOpaqueId } from "../core/ids";
import { cloneFaceUvState } from "../document/brushes";
import { cloneSelectionForCommand, getBoxBrushFaceOrThrow, replaceBoxBrushFace, setSingleBrushFaceSelection } from "./brush-command-helpers";
export function createSetBoxBrushFaceUvStateCommand(options) {
let previousUvState = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? `Update ${options.faceId} face UVs`,
execute(context) {
const currentDocument = context.getDocument();
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
if (previousUvState === null) {
previousUvState = cloneFaceUvState(currentFace.uv);
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
...currentFace,
uv: cloneFaceUvState(options.uvState)
}));
context.setSelection(setSingleBrushFaceSelection(options.brushId, options.faceId));
context.setToolMode("select");
},
undo(context) {
if (previousUvState === null) {
return;
}
const currentDocument = context.getDocument();
const currentFace = getBoxBrushFaceOrThrow(currentDocument, options.brushId, options.faceId);
context.setDocument(replaceBoxBrushFace(currentDocument, options.brushId, options.faceId, {
...currentFace,
uv: cloneFaceUvState(previousUvState)
}));
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,30 @@
import { createOpaqueId } from "../core/ids";
import { normalizeBrushName } from "../document/brushes";
import { getBoxBrushOrThrow, replaceBrush } from "./brush-command-helpers";
export function createSetBoxBrushNameCommand(options) {
const normalizedName = normalizeBrushName(options.name);
let previousName;
return {
id: createOpaqueId("command"),
label: normalizedName === undefined ? "Clear box brush name" : `Rename box brush to ${normalizedName}`,
execute(context) {
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
if (previousName === undefined) {
previousName = brush.name;
}
context.setDocument(replaceBrush(currentDocument, {
...brush,
name: normalizedName
}));
},
undo(context) {
const currentDocument = context.getDocument();
const brush = getBoxBrushOrThrow(currentDocument, options.brushId);
context.setDocument(replaceBrush(currentDocument, {
...brush,
name: previousName
}));
}
};
}

View File

@@ -0,0 +1,88 @@
import { createOpaqueId } from "../core/ids";
import { cloneSelectionForCommand, getBoxBrushOrThrow, replaceBrush, setSingleBrushEdgeSelection, setSingleBrushFaceSelection, setSingleBrushSelection, setSingleBrushVertexSelection } from "./brush-command-helpers";
function cloneVec3(vector) {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function selectionToEditorSelection(selection) {
switch (selection.kind) {
case "brush":
return setSingleBrushSelection(selection.brushId);
case "brushFace":
return setSingleBrushFaceSelection(selection.brushId, selection.faceId);
case "brushEdge":
return setSingleBrushEdgeSelection(selection.brushId, selection.edgeId);
case "brushVertex":
return setSingleBrushVertexSelection(selection.brushId, selection.vertexId);
}
}
function getBrushId(selection) {
return selection.brushId;
}
function assertPositiveSize(size) {
if (!(size.x > 0 && size.y > 0 && size.z > 0)) {
throw new Error("Whitebox box size must remain positive on every axis.");
}
if (!Number.isFinite(size.x) || !Number.isFinite(size.y) || !Number.isFinite(size.z)) {
throw new Error("Whitebox box size values must be finite numbers.");
}
}
export function createSetBoxBrushTransformCommand(options) {
assertPositiveSize(options.size);
let previousSnapshot = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? "Set box brush transform",
execute(context) {
const currentDocument = context.getDocument();
const brushId = getBrushId(options.selection);
const brush = getBoxBrushOrThrow(currentDocument, brushId);
if (previousSnapshot === null) {
previousSnapshot = {
center: cloneVec3(brush.center),
rotationDegrees: cloneVec3(brush.rotationDegrees),
size: cloneVec3(brush.size)
};
}
if (previousSelection === null) {
previousSelection = cloneSelectionForCommand(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
context.setDocument(replaceBrush(currentDocument, {
...brush,
center: cloneVec3(options.center),
rotationDegrees: cloneVec3(options.rotationDegrees),
size: cloneVec3(options.size)
}));
context.setSelection(selectionToEditorSelection(options.selection));
context.setToolMode("select");
},
undo(context) {
if (previousSnapshot === null) {
return;
}
const currentDocument = context.getDocument();
const brushId = getBrushId(options.selection);
const brush = getBoxBrushOrThrow(currentDocument, brushId);
context.setDocument(replaceBrush(currentDocument, {
...brush,
center: cloneVec3(previousSnapshot.center),
rotationDegrees: cloneVec3(previousSnapshot.rotationDegrees),
size: cloneVec3(previousSnapshot.size)
}));
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,47 @@
import { createOpaqueId } from "../core/ids";
import { cloneEntityInstance, normalizeEntityName } from "../entities/entity-instances";
export function createSetEntityNameCommand(options) {
const normalizedName = normalizeEntityName(options.name);
let previousName;
return {
id: createOpaqueId("command"),
label: normalizedName === undefined ? "Clear entity name" : `Rename entity to ${normalizedName}`,
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 (previousName === undefined) {
previousName = entity.name;
}
context.setDocument({
...currentDocument,
entities: {
...currentDocument.entities,
[entity.id]: cloneEntityInstance({
...entity,
name: normalizedName
})
}
});
},
undo(context) {
const currentDocument = context.getDocument();
const entity = currentDocument.entities[options.entityId];
if (entity === undefined) {
throw new Error(`Entity ${options.entityId} does not exist.`);
}
context.setDocument({
...currentDocument,
entities: {
...currentDocument.entities,
[entity.id]: cloneEntityInstance({
...entity,
name: previousName
})
}
});
}
};
}

View File

@@ -0,0 +1,47 @@
import { createOpaqueId } from "../core/ids";
import { cloneModelInstance, normalizeModelInstanceName } from "../assets/model-instances";
export function createSetModelInstanceNameCommand(options) {
const normalizedName = normalizeModelInstanceName(options.name);
let previousName;
return {
id: createOpaqueId("command"),
label: normalizedName === undefined ? "Clear model instance name" : `Rename model instance to ${normalizedName}`,
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 (previousName === undefined) {
previousName = modelInstance.name;
}
context.setDocument({
...currentDocument,
modelInstances: {
...currentDocument.modelInstances,
[modelInstance.id]: cloneModelInstance({
...modelInstance,
name: normalizedName
})
}
});
},
undo(context) {
const currentDocument = context.getDocument();
const modelInstance = currentDocument.modelInstances[options.modelInstanceId];
if (modelInstance === undefined) {
throw new Error(`Model instance ${options.modelInstanceId} does not exist.`);
}
context.setDocument({
...currentDocument,
modelInstances: {
...currentDocument.modelInstances,
[modelInstance.id]: cloneModelInstance({
...modelInstance,
name: previousName
})
}
});
}
};
}

View File

@@ -0,0 +1,12 @@
import { createPlayerStartEntity } from "../entities/entity-instances";
import { createUpsertEntityCommand } from "./upsert-entity-command";
export function createSetPlayerStartCommand(options) {
return createUpsertEntityCommand({
entity: createPlayerStartEntity({
id: options.entityId,
position: options.position,
yawDegrees: options.yawDegrees
}),
label: options.entityId === undefined ? "Place player start" : "Move player start"
});
}

View File

@@ -0,0 +1,29 @@
import { createOpaqueId } from "../core/ids";
export function createSetSceneNameCommand(nextName) {
const normalizedName = nextName.trim() || "Untitled Scene";
let previousName = null;
return {
id: createOpaqueId("command"),
label: `Rename scene to ${normalizedName}`,
execute(context) {
const currentDocument = context.getDocument();
if (previousName === null) {
previousName = currentDocument.name;
}
context.setDocument({
...currentDocument,
name: normalizedName
});
},
undo(context) {
if (previousName === null) {
return;
}
const currentDocument = context.getDocument();
context.setDocument({
...currentDocument,
name: previousName
});
}
};
}

View File

@@ -0,0 +1,30 @@
import { createOpaqueId } from "../core/ids";
import { cloneWorldSettings } from "../document/world-settings";
export function createSetWorldSettingsCommand(options) {
const nextWorld = cloneWorldSettings(options.world);
let previousWorld = null;
return {
id: createOpaqueId("command"),
label: options.label,
execute(context) {
const currentDocument = context.getDocument();
if (previousWorld === null) {
previousWorld = cloneWorldSettings(currentDocument.world);
}
context.setDocument({
...currentDocument,
world: cloneWorldSettings(nextWorld)
});
},
undo(context) {
if (previousWorld === null) {
return;
}
const currentDocument = context.getDocument();
context.setDocument({
...currentDocument,
world: cloneWorldSettings(previousWorld)
});
}
};
}

View File

@@ -0,0 +1,70 @@
import { cloneEditorSelection } from "../core/selection";
import { createOpaqueId } from "../core/ids";
import { cloneEntityInstance, getEntityKindLabel } from "../entities/entity-instances";
function setSingleEntitySelection(entityId) {
return {
kind: "entities",
ids: [entityId]
};
}
function createDefaultEntityCommandLabel(entity, isNewEntity) {
const action = isNewEntity ? "Place" : "Update";
return `${action} ${getEntityKindLabel(entity.kind).toLowerCase()}`;
}
export function createUpsertEntityCommand(options) {
const nextEntity = cloneEntityInstance(options.entity);
let previousEntity = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? createDefaultEntityCommandLabel(nextEntity, true),
execute(context) {
const currentDocument = context.getDocument();
const currentEntity = currentDocument.entities[nextEntity.id];
if (currentEntity !== undefined && currentEntity.kind !== nextEntity.kind) {
throw new Error(`Entity ${nextEntity.id} is a ${currentEntity.kind}, not a ${nextEntity.kind}.`);
}
if (previousSelection === null) {
previousSelection = cloneEditorSelection(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
if (previousEntity === null && currentEntity !== undefined) {
previousEntity = cloneEntityInstance(currentEntity);
}
context.setDocument({
...currentDocument,
entities: {
...currentDocument.entities,
[nextEntity.id]: cloneEntityInstance(nextEntity)
}
});
context.setSelection(setSingleEntitySelection(nextEntity.id));
context.setToolMode("select");
},
undo(context) {
const currentDocument = context.getDocument();
const nextEntities = {
...currentDocument.entities
};
if (previousEntity === null) {
delete nextEntities[nextEntity.id];
}
else {
nextEntities[nextEntity.id] = cloneEntityInstance(previousEntity);
}
context.setDocument({
...currentDocument,
entities: nextEntities
});
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}

View File

@@ -0,0 +1,40 @@
import { cloneInteractionLink } from "../interactions/interaction-links";
import { createOpaqueId } from "../core/ids";
export function createUpsertInteractionLinkCommand(options) {
const nextLink = cloneInteractionLink(options.link);
let previousLink = null;
return {
id: createOpaqueId("command"),
label: options.label ?? "Update interaction link",
execute(context) {
const currentDocument = context.getDocument();
const currentLink = currentDocument.interactionLinks[nextLink.id];
if (previousLink === null && currentLink !== undefined) {
previousLink = cloneInteractionLink(currentLink);
}
context.setDocument({
...currentDocument,
interactionLinks: {
...currentDocument.interactionLinks,
[nextLink.id]: cloneInteractionLink(nextLink)
}
});
},
undo(context) {
const currentDocument = context.getDocument();
const nextInteractionLinks = {
...currentDocument.interactionLinks
};
if (previousLink === null) {
delete nextInteractionLinks[nextLink.id];
}
else {
nextInteractionLinks[nextLink.id] = cloneInteractionLink(previousLink);
}
context.setDocument({
...currentDocument,
interactionLinks: nextInteractionLinks
});
}
};
}

View File

@@ -0,0 +1,75 @@
import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection } from "../core/selection";
import { cloneModelInstance, getModelInstanceKindLabel } from "../assets/model-instances";
import { getProjectAssetKindLabel } from "../assets/project-assets";
function setSingleModelInstanceSelection(modelInstanceId) {
return {
kind: "modelInstances",
ids: [modelInstanceId]
};
}
function createDefaultModelInstanceCommandLabel(isNewModelInstance) {
const action = isNewModelInstance ? "Place" : "Update";
return `${action} ${getModelInstanceKindLabel().toLowerCase()}`;
}
export function createUpsertModelInstanceCommand(options) {
const nextModelInstance = cloneModelInstance(options.modelInstance);
let previousModelInstance = null;
let previousSelection = null;
let previousToolMode = null;
return {
id: createOpaqueId("command"),
label: options.label ?? createDefaultModelInstanceCommandLabel(true),
execute(context) {
const currentDocument = context.getDocument();
const currentAsset = currentDocument.assets[nextModelInstance.assetId];
if (currentAsset === undefined) {
throw new Error(`Model instance ${nextModelInstance.id} cannot reference missing asset ${nextModelInstance.assetId}.`);
}
if (currentAsset.kind !== "model") {
throw new Error(`Model instance ${nextModelInstance.id} must reference a model asset, not ${getProjectAssetKindLabel(currentAsset.kind).toLowerCase()}.`);
}
const currentModelInstance = currentDocument.modelInstances[nextModelInstance.id];
if (previousSelection === null) {
previousSelection = cloneEditorSelection(context.getSelection());
}
if (previousToolMode === null) {
previousToolMode = context.getToolMode();
}
if (previousModelInstance === null && currentModelInstance !== undefined) {
previousModelInstance = cloneModelInstance(currentModelInstance);
}
context.setDocument({
...currentDocument,
modelInstances: {
...currentDocument.modelInstances,
[nextModelInstance.id]: cloneModelInstance(nextModelInstance)
}
});
context.setSelection(setSingleModelInstanceSelection(nextModelInstance.id));
context.setToolMode("select");
},
undo(context) {
const currentDocument = context.getDocument();
const nextModelInstances = {
...currentDocument.modelInstances
};
if (previousModelInstance === null) {
delete nextModelInstances[nextModelInstance.id];
}
else {
nextModelInstances[nextModelInstance.id] = cloneModelInstance(previousModelInstance);
}
context.setDocument({
...currentDocument,
modelInstances: nextModelInstances
});
if (previousSelection !== null) {
context.setSelection(previousSelection);
}
if (previousToolMode !== null) {
context.setToolMode(previousToolMode);
}
}
};
}