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(); 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(); 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 { 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 { 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 { 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)); }