Files
webeditor3d/src/assets/gltf-model-import.ts

278 lines
7.3 KiB
TypeScript

import { Box3, Group, Mesh, Object3D } from "three";
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
import { createModelInstance, type ModelInstance } from "./model-instances";
import {
createProjectAssetStorageKey,
type ModelAssetMetadata,
type ModelAssetRecord,
type ProjectAssetStorage
} from "./project-assets";
import { createOpaqueId } from "../core/ids";
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 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 & { isMesh?: boolean };
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);
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,
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));
}