import { Box3, Group, Mesh } from "three"; import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { createModelInstance } from "./model-instances"; import { createProjectAssetStorageKey } from "./project-assets"; import { createOpaqueId } from "../core/ids"; const DRACO_DECODER_PATH = "/draco/gltf/"; let sharedDracoLoader = null; function getErrorDetail(error) { if (error instanceof Error && error.message.trim().length > 0) { return error.message.trim(); } return "Unknown error."; } function getFileExtension(sourceName) { const match = /\.([^.]+)$/u.exec(sourceName.trim()); return match === null ? "" : match[1].toLowerCase(); } function inferFileMimeType(sourceName, fallbackMimeType) { if (fallbackMimeType.trim().length > 0 && fallbackMimeType !== "application/octet-stream") { return fallbackMimeType; } switch (getFileExtension(sourceName)) { case "bin": return "application/octet-stream"; case "png": return "image/png"; case "jpg": case "jpeg": return "image/jpeg"; case "gif": return "image/gif"; case "webp": return "image/webp"; case "avif": return "image/avif"; case "ktx2": return "image/ktx2"; case "wav": return "audio/wav"; case "mp3": return "audio/mpeg"; case "ogg": return "audio/ogg"; case "glb": return "model/gltf-binary"; case "gltf": return "model/gltf+json"; default: return fallbackMimeType.trim().length > 0 ? fallbackMimeType : "application/octet-stream"; } } function inferModelAssetFormat(sourceName, mimeType) { 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) { return format === "glb" ? "model/gltf-binary" : "model/gltf+json"; } function stripUrlQueryAndHash(path) { const queryIndex = path.search(/[?#]/u); return queryIndex === -1 ? path : path.slice(0, queryIndex); } function normalizeRelativePath(path) { const normalizedPath = stripUrlQueryAndHash(path.trim()).replace(/\\/gu, "/"); const segments = normalizedPath.split("/"); const resolvedSegments = []; for (const segment of segments) { if (segment === "" || segment === ".") { continue; } if (segment === "..") { const previousSegment = resolvedSegments.at(-1); if (previousSegment !== undefined && previousSegment !== "..") { resolvedSegments.pop(); } else { resolvedSegments.push(".."); } continue; } resolvedSegments.push(segment); } return resolvedSegments.join("/"); } function getPathDirectory(path) { const normalizedPath = normalizeRelativePath(path); const lastSlashIndex = normalizedPath.lastIndexOf("/"); return lastSlashIndex === -1 ? "" : normalizedPath.slice(0, lastSlashIndex); } function getRelativePath(fromDirectory, targetPath) { const normalizedFromSegments = normalizeRelativePath(fromDirectory).split("/").filter((segment) => segment.length > 0); const normalizedTargetSegments = normalizeRelativePath(targetPath).split("/").filter((segment) => segment.length > 0); while (normalizedFromSegments.length > 0 && normalizedTargetSegments.length > 0 && normalizedFromSegments[0] === normalizedTargetSegments[0]) { normalizedFromSegments.shift(); normalizedTargetSegments.shift(); } return [...new Array(normalizedFromSegments.length).fill(".."), ...normalizedTargetSegments].join("/"); } function getImportedFilePath(file) { const relativePath = typeof file.webkitRelativePath === "string" ? file.webkitRelativePath.trim() : ""; return normalizeRelativePath(relativePath.length > 0 ? relativePath : file.name.trim()); } function createBoundingBoxFromObject(object) { 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) { const names = new Set(); scene.traverse((object) => { const maybeMesh = object; 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) { 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) { 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) { let count = 0; scene.traverse(() => { count += 1; }); return count; } export function extractModelAssetMetadata(gltf, format) { gltf.scene.updateMatrixWorld(true); const boundingBox = createBoundingBoxFromObject(gltf.scene); let actualMeshCount = 0; gltf.scene.traverse((object) => { if (object.isMesh === true) { actualMeshCount += 1; } }); const parserJson = gltf.parser.json; const materialNames = collectMaterialNames(gltf.scene); const textureNames = collectTextureNames(parserJson); const animationNames = collectAnimationNames(gltf); const warnings = []; 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, template, animations) { return { assetId: asset.id, storageKey: asset.storageKey, metadata: asset.metadata, template, animations }; } function createModelAssetRecord(sourceName, mimeType, byteLength, metadata) { const assetId = createOpaqueId("asset-model"); return { id: assetId, kind: "model", sourceName, mimeType, storageKey: createProjectAssetStorageKey(assetId), byteLength, metadata }; } async function loadGltfFromArrayBuffer(arrayBuffer) { const loader = createConfiguredGltfLoader(); return loader.parseAsync(arrayBuffer, ""); } function createConfiguredGltfLoader() { const loader = new GLTFLoader(); loader.setDRACOLoader(getSharedDracoLoader()); return loader; } function getSharedDracoLoader() { if (sharedDracoLoader === null) { sharedDracoLoader = new DRACOLoader(); sharedDracoLoader.setDecoderPath(DRACO_DECODER_PATH); } return sharedDracoLoader; } function createDataUrlForStoredFile(file) { const byteArray = new Uint8Array(file.bytes); let binary = ""; const chunkSize = 0x8000; for (let index = 0; index < byteArray.length; index += chunkSize) { binary += String.fromCharCode(...byteArray.subarray(index, index + chunkSize)); } const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(file.bytes).toString("base64"); return `data:${file.mimeType};base64,${base64}`; } function createTransientResourceUrl(file) { if (typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") { const objectUrl = URL.createObjectURL(new Blob([file.bytes], { type: file.mimeType })); return { url: objectUrl, revoke: () => { if (typeof URL.revokeObjectURL === "function") { URL.revokeObjectURL(objectUrl); } } }; } return { url: createDataUrlForStoredFile(file), revoke: () => undefined }; } function rewriteGltfResourceUris(gltfJson, files) { const dataUrlsByPath = new Map(); const revokeUrls = []; const missingUris = new Set(); const resolveUri = (uri) => { if (uri.startsWith("data:") || uri.startsWith("blob:")) { return uri; } const normalizedUri = normalizeRelativePath(uri); const storedFile = files[normalizedUri]; if (storedFile === undefined) { return null; } const cachedDataUrl = dataUrlsByPath.get(normalizedUri); if (cachedDataUrl !== undefined) { return cachedDataUrl; } const transientResourceUrl = createTransientResourceUrl(storedFile); dataUrlsByPath.set(normalizedUri, transientResourceUrl.url); revokeUrls.push(transientResourceUrl.revoke); return transientResourceUrl.url; }; const rewriteUri = (value) => { if (typeof value !== "string") { return value; } const resolvedUri = resolveUri(stripUrlQueryAndHash(value)); if (resolvedUri === null) { missingUris.add(normalizeRelativePath(value)); return value; } return resolvedUri; }; const buffers = Array.isArray(gltfJson.buffers) ? gltfJson.buffers : []; for (const buffer of buffers) { if (typeof buffer.uri === "string") { buffer.uri = rewriteUri(buffer.uri); } } const images = Array.isArray(gltfJson.images) ? gltfJson.images : []; for (const image of images) { if (typeof image.uri === "string") { image.uri = rewriteUri(image.uri); } } return { missingUris: [...missingUris], revokeUrls }; } function cloneTemplateScene(scene) { // Use SkeletonUtils.clone so that SkinnedMesh.skeleton.bones are remapped // to the cloned hierarchy. A plain scene.clone(true) leaves the bones array // pointing at the original loader's nodes, which are gone after parsing, // making every skinned mesh invisible at runtime. return cloneSkeleton(scene); } function cloneMaterial(material) { return material.clone(); } function cloneMeshResources(object) { const maybeMesh = object; if (maybeMesh.isMesh !== true) { return; } maybeMesh.geometry = maybeMesh.geometry.clone(); maybeMesh.material = Array.isArray(maybeMesh.material) ? maybeMesh.material.map((material) => cloneMaterial(material)) : cloneMaterial(maybeMesh.material); } function disposeTexture(texture, seenTextures) { if (seenTextures.has(texture)) { return; } seenTextures.add(texture); texture.dispose(); } function disposeMaterialResources(material, disposeTextures, seenTextures) { if (disposeTextures) { for (const value of Object.values(material)) { if (value === null || value === undefined) { continue; } if (Array.isArray(value)) { for (const entry of value) { if (entry !== null && typeof entry === "object" && "isTexture" in entry) { disposeTexture(entry, seenTextures); } } continue; } if (typeof value === "object" && "isTexture" in value) { disposeTexture(value, seenTextures); } } } material.dispose(); } function disposeMeshResources(object, disposeTextures, seenTextures) { const maybeMesh = object; if (maybeMesh.isMesh !== true) { return; } maybeMesh.geometry.dispose(); if (Array.isArray(maybeMesh.material)) { for (const material of maybeMesh.material) { disposeMaterialResources(material, disposeTextures, seenTextures); } } else { disposeMaterialResources(maybeMesh.material, disposeTextures, seenTextures); } } export function instantiateModelTemplate(template) { const clone = cloneSkeleton(template); clone.traverse(cloneMeshResources); return clone; } export function disposeModelTemplate(template) { const seenTextures = new Set(); template.traverse((object) => { disposeMeshResources(object, true, seenTextures); }); } export function disposeModelInstance(instance) { const seenTextures = new Set(); instance.traverse((object) => { disposeMeshResources(object, false, seenTextures); }); } async function loadModelFileSet(files) { if (files.length === 0) { throw new Error("Select a .glb or .gltf file to import."); } const modelFiles = files.filter((file) => { try { inferModelAssetFormat(file.name, file.type); return true; } catch { return false; } }); if (modelFiles.length === 0) { throw new Error("Select a .glb or .gltf file to import."); } if (modelFiles.length > 1) { throw new Error("Select exactly one .glb or .gltf file and any matching sidecar resources."); } const rootFile = modelFiles[0]; const rootSourcePath = getImportedFilePath(rootFile); const rootDirectory = getPathDirectory(rootSourcePath); const importedFiles = await Promise.all(files.map(async (file) => ({ file, bytes: await file.arrayBuffer() }))); const fileEntries = []; const packageFiles = {}; for (const { file, bytes } of importedFiles) { const sourcePath = file === rootFile ? normalizeRelativePath(rootFile.name.trim()) : getRelativePath(rootDirectory, getImportedFilePath(file)); const mimeType = inferFileMimeType(file.name, file.type); if (packageFiles[sourcePath] !== undefined) { throw new Error(`Duplicate imported file path ${sourcePath}.`); } const entry = { bytes, mimeType, path: sourcePath }; fileEntries.push(entry); packageFiles[sourcePath] = { bytes, mimeType }; } const rootEntry = fileEntries.find((entry) => entry.path === normalizeRelativePath(rootFile.name.trim())); if (rootEntry === undefined) { throw new Error(`Unable to locate the root model file ${rootFile.name}.`); } // Keep the root file's canonical storage path equal to its source name so reloads can find it directly. const packageRecord = { files: packageFiles }; return { fileEntries, packageRecord, rootFile: rootEntry, totalByteLength: fileEntries.reduce((total, entry) => total + entry.bytes.byteLength, 0) }; } async function loadGltfFromImportedModelFileSet(fileSet) { const rootFormat = inferModelAssetFormat(fileSet.rootFile.path, fileSet.rootFile.mimeType); if (rootFormat === "glb") { return loadGltfFromArrayBuffer(fileSet.rootFile.bytes); } const text = new TextDecoder().decode(fileSet.rootFile.bytes); const gltfJson = JSON.parse(text); const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileSet.packageRecord.files); if (missingUris.length > 0) { for (const revokeUrl of revokeUrls) { revokeUrl(); } throw new Error(`Missing external model resource(s): ${missingUris.join(", ")}.`); } const loader = createConfiguredGltfLoader(); try { return await loader.parseAsync(JSON.stringify(gltfJson), ""); } finally { for (const revokeUrl of revokeUrls) { revokeUrl(); } } } function createModelAssetRecordFromFileSet(sourceName, mimeType, byteLength, metadata) { return createModelAssetRecord(sourceName, mimeType, byteLength, metadata); } export async function importModelAssetFromFiles(files, storage) { let fileSet; try { fileSet = await loadModelFileSet(files); } catch (error) { throw new Error(`Model import failed: ${getErrorDetail(error)}`); } const sourceName = fileSet.rootFile.path; const format = inferModelAssetFormat(sourceName, fileSet.rootFile.mimeType); const mimeType = inferModelMimeType(format); let gltf; try { gltf = await loadGltfFromImportedModelFileSet(fileSet); } catch (error) { throw new Error(`Model import failed for ${sourceName}: ${getErrorDetail(error)}`); } const metadata = extractModelAssetMetadata(gltf, format); const asset = createModelAssetRecordFromFileSet(sourceName, mimeType, fileSet.totalByteLength, metadata); try { await storage.putAsset(asset.storageKey, fileSet.packageRecord); const modelInstance = createModelInstance({ assetId: asset.id, name: undefined }); return { asset, modelInstance, loadedAsset: createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations) }; } catch (error) { await storage.deleteAsset(asset.storageKey).catch(() => undefined); throw error; } } export async function importModelAssetFromFile(file, storage) { return importModelAssetFromFiles([file], storage); } function getStoredModelAssetFile(asset, storedAsset) { const directFile = storedAsset.files[asset.sourceName]; if (directFile !== undefined) { return directFile; } const storedFiles = Object.values(storedAsset.files); if (storedFiles.length === 1) { return storedFiles[0]; } return null; } export async function loadModelAssetFromStorage(storage, asset) { let storedAsset; try { storedAsset = await storage.getAsset(asset.storageKey); } catch (error) { throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); } if (storedAsset === null) { throw new Error(`Missing stored binary data for imported model asset ${asset.sourceName}.`); } const storedModelFile = getStoredModelAssetFile(asset, storedAsset); if (storedModelFile === null) { throw new Error(`Missing stored root file for imported model asset ${asset.sourceName}.`); } if (asset.metadata.format === "glb") { try { const gltf = await loadGltfFromArrayBuffer(storedModelFile.bytes); return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations); } catch (error) { throw new Error(`Model asset reload failed for ${asset.sourceName}: ${getErrorDetail(error)}`); } } const fileEntries = storedAsset.files; const rootFileBytes = storedModelFile.bytes; const gltfJson = JSON.parse(new TextDecoder().decode(rootFileBytes)); const { missingUris, revokeUrls } = rewriteGltfResourceUris(gltfJson, fileEntries); if (missingUris.length > 0) { for (const revokeUrl of revokeUrls) { revokeUrl(); } throw new Error(`Missing stored external model resource(s): ${missingUris.join(", ")}.`); } const loader = createConfiguredGltfLoader(); try { const gltf = await loader.parseAsync(JSON.stringify(gltfJson), ""); return createLoadedModelAsset(asset, cloneTemplateScene(gltf.scene), gltf.animations); } finally { for (const revokeUrl of revokeUrls) { revokeUrl(); } } }