Add GLTF model import and related assets
This commit is contained in:
289
src/assets/gltf-model-import.ts
Normal file
289
src/assets/gltf-model-import.ts
Normal 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));
|
||||
}
|
||||
|
||||
46
src/assets/model-instance-labels.ts
Normal file
46
src/assets/model-instance-labels.ts
Normal 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)
|
||||
}));
|
||||
}
|
||||
|
||||
131
src/assets/model-instances.ts
Normal file
131
src/assets/model-instances.ts
Normal 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";
|
||||
}
|
||||
|
||||
186
src/assets/project-asset-storage.ts
Normal file
186
src/assets/project-asset-storage.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
168
src/assets/project-assets.ts
Normal file
168
src/assets/project-assets.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
||||
92
src/commands/import-model-asset-command.ts
Normal file
92
src/commands/import-model-asset-command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
98
src/commands/upsert-model-instance-command.ts
Normal file
98
src/commands/upsert-model-instance-command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user