Add GLTF model import and related assets

This commit is contained in:
2026-03-31 17:30:05 +02:00
parent 7b98c38d92
commit bcb6f5686e
7 changed files with 1010 additions and 0 deletions

View File

@@ -0,0 +1,289 @@
import { Box3, BoxGeometry, Group, Mesh, MeshStandardMaterial, Object3D, type BufferGeometry, type Material } from "three";
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
import { createOpaqueId } from "../core/ids";
import type { Vec3 } from "../core/vector";
import { createModelInstance, type ModelInstance } from "./model-instances";
import {
createProjectAssetStorageKey,
type ModelAssetMetadata,
type ModelAssetRecord,
type ProjectAssetStorage
} from "./project-assets";
import type { ProjectAssetStorageRecord } from "./project-asset-storage";
export interface LoadedModelAsset {
assetId: string;
storageKey: string;
metadata: ModelAssetMetadata;
template: Group;
}
export interface ImportedModelAssetResult {
asset: ModelAssetRecord;
modelInstance: ModelInstance;
loadedAsset: LoadedModelAsset;
}
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function getErrorDetail(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim();
}
return "Unknown error.";
}
function getFileExtension(sourceName: string): string {
const match = /\.([^.]+)$/u.exec(sourceName.trim());
return match === null ? "" : match[1].toLowerCase();
}
function inferModelAssetFormat(sourceName: string, mimeType: string): "glb" | "gltf" {
const extension = getFileExtension(sourceName);
if (mimeType === "model/gltf-binary" || extension === "glb") {
return "glb";
}
if (mimeType === "model/gltf+json" || mimeType === "application/json" || extension === "gltf") {
return "gltf";
}
throw new Error(`Unsupported model asset format for ${sourceName}. Use .glb or .gltf.`);
}
function inferModelMimeType(format: "glb" | "gltf"): string {
return format === "glb" ? "model/gltf-binary" : "model/gltf+json";
}
function createBoundingBoxFromObject(object: Object3D): ModelAssetMetadata["boundingBox"] {
const box = new Box3().setFromObject(object);
if (box.isEmpty()) {
return null;
}
const min = {
x: box.min.x,
y: box.min.y,
z: box.min.z
};
const max = {
x: box.max.x,
y: box.max.y,
z: box.max.z
};
return {
min,
max,
size: {
x: max.x - min.x,
y: max.y - min.y,
z: max.z - min.z
}
};
}
function collectMaterialNames(scene: Group): string[] {
const names = new Set<string>();
scene.traverse((object) => {
const maybeMesh = object as Mesh | null;
if (maybeMesh.isMesh !== true) {
return;
}
const materials = Array.isArray(maybeMesh.material) ? maybeMesh.material : [maybeMesh.material];
for (const material of materials) {
if (material.name.trim().length > 0) {
names.add(material.name);
}
}
});
return [...names].sort((left, right) => left.localeCompare(right));
}
function collectTextureNames(parserJson: { textures?: Array<{ name?: string }> }): string[] {
const textures = parserJson.textures ?? [];
const names = new Set<string>();
for (const texture of textures) {
if (texture.name !== undefined && texture.name.trim().length > 0) {
names.add(texture.name);
}
}
return [...names].sort((left, right) => left.localeCompare(right));
}
function collectAnimationNames(gltf: GLTF): string[] {
return gltf.animations
.map((animation, index) => animation.name.trim().length > 0 ? animation.name : `Animation ${index + 1}`)
.sort((left, right) => left.localeCompare(right));
}
function countNodes(scene: Group): number {
let count = 0;
scene.traverse(() => {
count += 1;
});
return count;
}
export function extractModelAssetMetadata(gltf: GLTF, format: "glb" | "gltf"): ModelAssetMetadata {
gltf.scene.updateMatrixWorld(true);
const boundingBox = createBoundingBoxFromObject(gltf.scene);
const meshCount = gltf.scene.children.length === 0 ? 0 : gltf.scene.getObjectByProperty("isMesh", true) !== undefined ? 1 : 0;
let actualMeshCount = 0;
gltf.scene.traverse((object) => {
if ((object as Mesh).isMesh === true) {
actualMeshCount += 1;
}
});
const parserJson = gltf.parser.json as { materials?: Array<{ name?: string }>; textures?: Array<{ name?: string }> };
const materialNames = collectMaterialNames(gltf.scene);
const textureNames = collectTextureNames(parserJson);
const animationNames = collectAnimationNames(gltf);
const warnings: string[] = [];
if (boundingBox === null) {
warnings.push("The imported model does not contain measurable geometry.");
}
if (actualMeshCount === 0) {
warnings.push("The imported model does not contain any meshes.");
}
if (materialNames.length === 0 && (parserJson.materials?.length ?? 0) > 0) {
for (const material of parserJson.materials ?? []) {
if (material.name !== undefined && material.name.trim().length > 0) {
materialNames.push(material.name);
}
}
}
return {
kind: "model",
format,
sceneName: gltf.scene.name.trim().length > 0 ? gltf.scene.name : null,
nodeCount: countNodes(gltf.scene),
meshCount: actualMeshCount ?? meshCount,
materialNames: [...new Set(materialNames)].sort((left, right) => left.localeCompare(right)),
textureNames,
animationNames,
boundingBox,
warnings
};
}
function createLoadedModelAsset(asset: ModelAssetRecord, template: Group): LoadedModelAsset {
return {
assetId: asset.id,
storageKey: asset.storageKey,
metadata: asset.metadata,
template
};
}
function createModelAssetRecord(sourceName: string, mimeType: string, bytes: ArrayBuffer, metadata: ModelAssetMetadata): ModelAssetRecord {
const assetId = createOpaqueId("asset-model");
return {
id: assetId,
kind: "model",
sourceName,
mimeType,
storageKey: createProjectAssetStorageKey(assetId),
byteLength: bytes.byteLength,
metadata
};
}
async function loadGltfFromBlob(blob: Blob): Promise<GLTF> {
const loader = new GLTFLoader();
const objectUrl = URL.createObjectURL(blob);
try {
return await loader.loadAsync(objectUrl);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function cloneTemplateScene(scene: Group): Group {
return scene.clone(true);
}
export async function importModelAssetFromFile(
file: File,
storage: ProjectAssetStorage
): Promise<ImportedModelAssetResult> {
const sourceName = file.name;
const format = inferModelAssetFormat(sourceName, file.type);
const mimeType = inferModelMimeType(format);
const bytes = await file.arrayBuffer();
let gltf: GLTF;
try {
gltf = await loadGltfFromBlob(new Blob([bytes], { type: mimeType }));
} catch (error) {
throw new Error(`Model import failed for ${sourceName}: ${getErrorDetail(error)}`);
}
const metadata = extractModelAssetMetadata(gltf, format);
const asset = createModelAssetRecord(sourceName, mimeType, bytes, metadata);
await storage.putAsset(asset.storageKey, {
bytes,
mimeType
} satisfies ProjectAssetStorageRecord);
const modelInstance = createModelInstance({
assetId: asset.id,
name: undefined
});
return {
asset,
modelInstance,
loadedAsset: createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene))
};
}
export async function loadModelAssetFromStorage(
storage: ProjectAssetStorage,
asset: ModelAssetRecord
): Promise<LoadedModelAsset> {
const storedAsset = await storage.getAsset(asset.storageKey);
if (storedAsset === null) {
throw new Error(`Missing stored binary data for imported model asset ${asset.sourceName}.`);
}
const gltf = await loadGltfFromBlob(
new Blob([storedAsset.bytes], {
type: storedAsset.mimeType || asset.mimeType
})
);
return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene));
}

View File

@@ -0,0 +1,46 @@
import type { ModelInstance } from "./model-instances";
import { getModelInstances } from "./model-instances";
import type { ProjectAssetRecord } from "./project-assets";
function getModelInstanceBaseLabel(modelInstance: ModelInstance, assets: Record<string, ProjectAssetRecord>): string {
if (modelInstance.name !== undefined) {
return modelInstance.name;
}
const asset = assets[modelInstance.assetId];
if (asset === undefined) {
return "Model Instance";
}
return asset.sourceName;
}
export function getModelInstanceDisplayLabel(modelInstance: ModelInstance, assets: Record<string, ProjectAssetRecord>): string {
return getModelInstanceBaseLabel(modelInstance, assets);
}
export function getModelInstanceDisplayLabelById(
modelInstanceId: string,
modelInstances: Record<string, ModelInstance>,
assets: Record<string, ProjectAssetRecord>
): string {
const modelInstance = modelInstances[modelInstanceId];
if (modelInstance === undefined) {
return "Model Instance";
}
return getModelInstanceDisplayLabel(modelInstance, assets);
}
export function getSortedModelInstanceDisplayLabels(
modelInstances: Record<string, ModelInstance>,
assets: Record<string, ProjectAssetRecord>
): Array<{ modelInstance: ModelInstance; label: string }> {
return getModelInstances(modelInstances).map((modelInstance) => ({
modelInstance,
label: getModelInstanceDisplayLabel(modelInstance, assets)
}));
}

View File

@@ -0,0 +1,131 @@
import { createOpaqueId } from "../core/ids";
import type { Vec3 } from "../core/vector";
export interface ModelInstance {
id: string;
kind: "modelInstance";
assetId: string;
name?: string;
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
}
export const DEFAULT_MODEL_INSTANCE_POSITION: Vec3 = {
x: 0,
y: 0,
z: 0
};
export const DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES: Vec3 = {
x: 0,
y: 0,
z: 0
};
export const DEFAULT_MODEL_INSTANCE_SCALE: Vec3 = {
x: 1,
y: 1,
z: 1
};
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function areVec3Equal(left: Vec3, right: Vec3): boolean {
return left.x === right.x && left.y === right.y && left.z === right.z;
}
export function normalizeModelInstanceName(name: string | null | undefined): string | undefined {
if (name === undefined || name === null) {
return undefined;
}
const trimmedName = name.trim();
return trimmedName.length === 0 ? undefined : trimmedName;
}
function assertFiniteVec3(vector: Vec3, label: string) {
if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) {
throw new Error(`${label} must be finite on every axis.`);
}
}
function assertPositiveFiniteVec3(vector: Vec3, label: string) {
assertFiniteVec3(vector, label);
if (vector.x <= 0 || vector.y <= 0 || vector.z <= 0) {
throw new Error(`${label} must remain positive on every axis.`);
}
}
export function createModelInstance(
overrides: Partial<Pick<ModelInstance, "id" | "name" | "position" | "rotationDegrees" | "scale">> & Pick<ModelInstance, "assetId">
): ModelInstance {
const position = cloneVec3(overrides.position ?? DEFAULT_MODEL_INSTANCE_POSITION);
const rotationDegrees = cloneVec3(overrides.rotationDegrees ?? DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES);
const scale = cloneVec3(overrides.scale ?? DEFAULT_MODEL_INSTANCE_SCALE);
if (overrides.assetId.trim().length === 0) {
throw new Error("Model instance assetId must be a non-empty string.");
}
assertFiniteVec3(position, "Model instance position");
assertFiniteVec3(rotationDegrees, "Model instance rotation");
assertPositiveFiniteVec3(scale, "Model instance scale");
return {
id: overrides.id ?? createOpaqueId("model-instance"),
kind: "modelInstance",
assetId: overrides.assetId,
name: normalizeModelInstanceName(overrides.name),
position,
rotationDegrees,
scale
};
}
export function cloneModelInstance(instance: ModelInstance): ModelInstance {
return createModelInstance(instance);
}
export function areModelInstancesEqual(left: ModelInstance, right: ModelInstance): boolean {
return (
left.id === right.id &&
left.kind === right.kind &&
left.assetId === right.assetId &&
left.name === right.name &&
areVec3Equal(left.position, right.position) &&
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
areVec3Equal(left.scale, right.scale)
);
}
export function compareModelInstances(left: ModelInstance, right: ModelInstance): number {
if (left.assetId !== right.assetId) {
return left.assetId.localeCompare(right.assetId);
}
const leftName = left.name ?? "";
const rightName = right.name ?? "";
if (leftName !== rightName) {
return leftName.localeCompare(rightName);
}
return left.id.localeCompare(right.id);
}
export function getModelInstances(modelInstances: Record<string, ModelInstance>): ModelInstance[] {
return Object.values(modelInstances).sort(compareModelInstances);
}
export function getModelInstanceKindLabel(): string {
return "Model Instance";
}

View File

@@ -0,0 +1,186 @@
export interface ProjectAssetStorageRecord {
bytes: ArrayBuffer;
mimeType: string;
}
export interface ProjectAssetStorage {
getAsset(storageKey: string): Promise<ProjectAssetStorageRecord | null>;
putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise<void>;
deleteAsset(storageKey: string): Promise<void>;
}
export interface ProjectAssetStorageAccessResult {
storage: ProjectAssetStorage | null;
diagnostic: string | null;
}
const PROJECT_ASSET_DATABASE_NAME = "webeditor3d-project-assets";
const PROJECT_ASSET_DATABASE_VERSION = 1;
const PROJECT_ASSET_OBJECT_STORE_NAME = "assets";
function cloneArrayBuffer(bytes: ArrayBuffer): ArrayBuffer {
return bytes.slice(0);
}
function getErrorDetail(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim();
}
return "Unknown error.";
}
function formatDiagnostic(prefix: string, error: unknown): string {
return `${prefix} ${getErrorDetail(error)}`;
}
function promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
request.addEventListener("success", () => {
resolve(request.result);
});
request.addEventListener("error", () => {
reject(request.error ?? new Error("IndexedDB request failed."));
});
});
}
function openIndexedDb(): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(PROJECT_ASSET_DATABASE_NAME, PROJECT_ASSET_DATABASE_VERSION);
request.addEventListener("upgradeneeded", () => {
const database = request.result;
if (!database.objectStoreNames.contains(PROJECT_ASSET_OBJECT_STORE_NAME)) {
database.createObjectStore(PROJECT_ASSET_OBJECT_STORE_NAME);
}
});
request.addEventListener("success", () => {
resolve(request.result);
});
request.addEventListener("error", () => {
reject(request.error ?? new Error("IndexedDB open failed."));
});
});
}
class IndexedDbProjectAssetStorage implements ProjectAssetStorage {
private readonly databasePromise: Promise<IDBDatabase>;
constructor() {
this.databasePromise = openIndexedDb();
}
private async withStore<T>(mode: IDBTransactionMode, callback: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const database = await this.databasePromise;
const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, mode);
const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME);
const result = await promisifyRequest(callback(store));
await new Promise<void>((resolve, reject) => {
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed.")));
transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction aborted.")));
});
return result;
}
async getAsset(storageKey: string): Promise<ProjectAssetStorageRecord | null> {
const database = await this.databasePromise;
const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, "readonly");
const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME);
const result = await promisifyRequest<ProjectAssetStorageRecord | undefined>(store.get(storageKey));
return result === undefined ? null : { bytes: cloneArrayBuffer(result.bytes), mimeType: result.mimeType };
}
async putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise<void> {
await this.withStore("readwrite", (store) =>
store.put(
{
bytes: cloneArrayBuffer(asset.bytes),
mimeType: asset.mimeType
},
storageKey
)
);
}
async deleteAsset(storageKey: string): Promise<void> {
await this.withStore("readwrite", (store) => store.delete(storageKey));
}
}
class InMemoryProjectAssetStorage implements ProjectAssetStorage {
private readonly values = new Map<string, ProjectAssetStorageRecord>();
constructor(initialValues: Record<string, ProjectAssetStorageRecord> = {}) {
for (const [storageKey, asset] of Object.entries(initialValues)) {
this.values.set(storageKey, {
bytes: cloneArrayBuffer(asset.bytes),
mimeType: asset.mimeType
});
}
}
async getAsset(storageKey: string): Promise<ProjectAssetStorageRecord | null> {
const asset = this.values.get(storageKey);
if (asset === undefined) {
return null;
}
return {
bytes: cloneArrayBuffer(asset.bytes),
mimeType: asset.mimeType
};
}
async putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise<void> {
this.values.set(storageKey, {
bytes: cloneArrayBuffer(asset.bytes),
mimeType: asset.mimeType
});
}
async deleteAsset(storageKey: string): Promise<void> {
this.values.delete(storageKey);
}
}
export function createInMemoryProjectAssetStorage(initialValues: Record<string, ProjectAssetStorageRecord> = {}): ProjectAssetStorage {
return new InMemoryProjectAssetStorage(initialValues);
}
export async function getBrowserProjectAssetStorageAccess(): Promise<ProjectAssetStorageAccessResult> {
if (typeof window === "undefined") {
return {
storage: null,
diagnostic: null
};
}
if (typeof indexedDB === "undefined") {
return {
storage: null,
diagnostic: "IndexedDB is unavailable in this browser environment."
};
}
try {
return {
storage: new IndexedDbProjectAssetStorage(),
diagnostic: null
};
} catch (error) {
return {
storage: null,
diagnostic: formatDiagnostic("Project asset storage could not be opened.", error)
};
}
}

View File

@@ -0,0 +1,168 @@
import type { Vec3 } from "../core/vector";
export const PROJECT_ASSET_KINDS = ["model", "image", "audio"] as const;
export type ProjectAssetKind = (typeof PROJECT_ASSET_KINDS)[number];
export interface ProjectAssetBoundingBox {
min: Vec3;
max: Vec3;
size: Vec3;
}
export interface ModelAssetMetadata {
kind: "model";
format: "glb" | "gltf";
sceneName: string | null;
nodeCount: number;
meshCount: number;
materialNames: string[];
textureNames: string[];
animationNames: string[];
boundingBox: ProjectAssetBoundingBox | null;
warnings: string[];
}
export interface ImageAssetMetadata {
kind: "image";
width: number;
height: number;
hasAlpha: boolean;
warnings: string[];
}
export interface AudioAssetMetadata {
kind: "audio";
durationSeconds: number | null;
channelCount: number | null;
sampleRateHz: number | null;
warnings: string[];
}
export type ProjectAssetMetadata = ModelAssetMetadata | ImageAssetMetadata | AudioAssetMetadata;
export interface ProjectAssetRecordBase<TKind extends ProjectAssetKind, TMetadata extends ProjectAssetMetadata> {
id: string;
kind: TKind;
sourceName: string;
mimeType: string;
storageKey: string;
byteLength: number;
metadata: TMetadata;
}
export type ModelAssetRecord = ProjectAssetRecordBase<"model", ModelAssetMetadata>;
export type ImageAssetRecord = ProjectAssetRecordBase<"image", ImageAssetMetadata>;
export type AudioAssetRecord = ProjectAssetRecordBase<"audio", AudioAssetMetadata>;
export type ProjectAssetRecord = ModelAssetRecord | ImageAssetRecord | AudioAssetRecord;
export function createProjectAssetStorageKey(assetId: string): string {
return `project-asset:${assetId}`;
}
export function isProjectAssetKind(value: unknown): value is ProjectAssetKind {
return value === "model" || value === "image" || value === "audio";
}
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function cloneBoundingBox(boundingBox: ProjectAssetBoundingBox | null): ProjectAssetBoundingBox | null {
if (boundingBox === null) {
return null;
}
return {
min: cloneVec3(boundingBox.min),
max: cloneVec3(boundingBox.max),
size: cloneVec3(boundingBox.size)
};
}
function cloneModelAssetMetadata(metadata: ModelAssetMetadata): ModelAssetMetadata {
return {
kind: "model",
format: metadata.format,
sceneName: metadata.sceneName,
nodeCount: metadata.nodeCount,
meshCount: metadata.meshCount,
materialNames: [...metadata.materialNames],
textureNames: [...metadata.textureNames],
animationNames: [...metadata.animationNames],
boundingBox: cloneBoundingBox(metadata.boundingBox),
warnings: [...metadata.warnings]
};
}
function cloneImageAssetMetadata(metadata: ImageAssetMetadata): ImageAssetMetadata {
return {
kind: "image",
width: metadata.width,
height: metadata.height,
hasAlpha: metadata.hasAlpha,
warnings: [...metadata.warnings]
};
}
function cloneAudioAssetMetadata(metadata: AudioAssetMetadata): AudioAssetMetadata {
return {
kind: "audio",
durationSeconds: metadata.durationSeconds,
channelCount: metadata.channelCount,
sampleRateHz: metadata.sampleRateHz,
warnings: [...metadata.warnings]
};
}
export function cloneProjectAssetRecord(asset: ProjectAssetRecord): ProjectAssetRecord {
switch (asset.kind) {
case "model":
return {
id: asset.id,
kind: "model",
sourceName: asset.sourceName,
mimeType: asset.mimeType,
storageKey: asset.storageKey,
byteLength: asset.byteLength,
metadata: cloneModelAssetMetadata(asset.metadata)
};
case "image":
return {
id: asset.id,
kind: "image",
sourceName: asset.sourceName,
mimeType: asset.mimeType,
storageKey: asset.storageKey,
byteLength: asset.byteLength,
metadata: cloneImageAssetMetadata(asset.metadata)
};
case "audio":
return {
id: asset.id,
kind: "audio",
sourceName: asset.sourceName,
mimeType: asset.mimeType,
storageKey: asset.storageKey,
byteLength: asset.byteLength,
metadata: cloneAudioAssetMetadata(asset.metadata)
};
}
}
export function getProjectAssetKindLabel(kind: ProjectAssetKind): string {
switch (kind) {
case "model":
return "Model";
case "image":
return "Image";
case "audio":
return "Audio";
}
}

View File

@@ -0,0 +1,92 @@
import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import { cloneModelInstance, type ModelInstance } from "../assets/model-instances";
import { cloneProjectAssetRecord, type ModelAssetRecord } from "../assets/project-assets";
import type { EditorCommand } from "./command";
interface ImportModelAssetCommandOptions {
asset: ModelAssetRecord;
modelInstance: ModelInstance;
label?: string;
}
function setSingleModelInstanceSelection(modelInstanceId: string): EditorSelection {
return {
kind: "modelInstances",
ids: [modelInstanceId]
};
}
export function createImportModelAssetCommand(options: ImportModelAssetCommandOptions): EditorCommand {
const nextAsset = cloneProjectAssetRecord(options.asset);
const nextModelInstance = cloneModelInstance(options.modelInstance);
let previousSelection: EditorSelection | null = null;
let previousToolMode: ToolMode | null = 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,98 @@
import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import { cloneModelInstance, getModelInstanceKindLabel, type ModelInstance } from "../assets/model-instances";
import { cloneProjectAssetRecord, getProjectAssetKindLabel } from "../assets/project-assets";
import type { EditorCommand } from "./command";
interface UpsertModelInstanceCommandOptions {
modelInstance: ModelInstance;
label?: string;
}
function setSingleModelInstanceSelection(modelInstanceId: string): EditorSelection {
return {
kind: "modelInstances",
ids: [modelInstanceId]
};
}
function createDefaultModelInstanceCommandLabel(modelInstance: ModelInstance, isNewModelInstance: boolean): string {
const action = isNewModelInstance ? "Place" : "Update";
return `${action} ${getModelInstanceKindLabel().toLowerCase()}`;
}
export function createUpsertModelInstanceCommand(options: UpsertModelInstanceCommandOptions): EditorCommand {
const nextModelInstance = cloneModelInstance(options.modelInstance);
let previousModelInstance: ModelInstance | null = null;
let previousSelection: EditorSelection | null = null;
let previousToolMode: ToolMode | null = null;
return {
id: createOpaqueId("command"),
label: options.label ?? createDefaultModelInstanceCommandLabel(nextModelInstance, 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);
}
}
};
}