Spaces:
Running
Running
/** | |
* Urdf Drag and Drop Utility | |
* | |
* This file provides functionality for handling drag and drop of Urdf folders. | |
* It converts the dropped files into accessible blobs for visualization. | |
*/ | |
/** | |
* Converts a DataTransfer structure into an object with all paths and files. | |
* @param dataTransfer The DataTransfer object from the drop event | |
* @returns A promise that resolves with the file structure object | |
*/ | |
export function dataTransferToFiles( | |
dataTransfer: DataTransfer | |
): Promise<Record<string, File>> { | |
if (!(dataTransfer instanceof DataTransfer)) { | |
throw new Error('Data must be of type "DataTransfer"'); | |
} | |
const files: Record<string, File> = {}; | |
/** | |
* Recursively processes a directory entry to extract all files | |
* Using type 'unknown' and then type checking for safety with WebKit's non-standard API | |
*/ | |
function recurseDirectory(item: unknown): Promise<void> { | |
// Type guard for file entries | |
const isFileEntry = ( | |
entry: unknown | |
): entry is { | |
isFile: boolean; | |
fullPath: string; | |
file: (callback: (file: File) => void) => void; | |
} => | |
entry !== null && | |
typeof entry === "object" && | |
"isFile" in entry && | |
typeof (entry as Record<string, unknown>).file === "function" && | |
"fullPath" in entry; | |
// Type guard for directory entries | |
const isDirEntry = ( | |
entry: unknown | |
): entry is { | |
isFile: boolean; | |
createReader: () => { | |
readEntries: (callback: (entries: unknown[]) => void) => void; | |
}; | |
} => | |
entry !== null && | |
typeof entry === "object" && | |
"isFile" in entry && | |
typeof (entry as Record<string, unknown>).createReader === "function"; | |
if (isFileEntry(item) && item.isFile) { | |
return new Promise((resolve) => { | |
item.file((file: File) => { | |
files[item.fullPath] = file; | |
resolve(); | |
}); | |
}); | |
} else if (isDirEntry(item) && !item.isFile) { | |
const reader = item.createReader(); | |
return new Promise((resolve) => { | |
const promises: Promise<void>[] = []; | |
// Exhaustively read all directory entries | |
function readNextEntries() { | |
reader.readEntries((entries: unknown[]) => { | |
if (entries.length === 0) { | |
Promise.all(promises).then(() => resolve()); | |
} else { | |
entries.forEach((entry) => { | |
promises.push(recurseDirectory(entry)); | |
}); | |
readNextEntries(); | |
} | |
}); | |
} | |
readNextEntries(); | |
}); | |
} | |
return Promise.resolve(); | |
} | |
return new Promise((resolve) => { | |
// Process dropped items | |
const dtitems = dataTransfer.items && Array.from(dataTransfer.items); | |
const dtfiles = Array.from(dataTransfer.files); | |
if (dtitems && dtitems.length && "webkitGetAsEntry" in dtitems[0]) { | |
const promises: Promise<void>[] = []; | |
for (let i = 0; i < dtitems.length; i++) { | |
const item = dtitems[i] as unknown as { | |
webkitGetAsEntry: () => unknown; | |
}; | |
if (typeof item.webkitGetAsEntry === "function") { | |
const entry = item.webkitGetAsEntry(); | |
if (entry) { | |
promises.push(recurseDirectory(entry)); | |
} | |
} | |
} | |
Promise.all(promises).then(() => resolve(files)); | |
} else { | |
// Add a '/' prefix to match the file directory entry on webkit browsers | |
dtfiles | |
.filter((f) => f.size !== 0) | |
.forEach((f) => (files["/" + f.name] = f)); | |
resolve(files); | |
} | |
}); | |
} | |
/** | |
* Cleans a file path by removing '..' and '.' tokens and normalizing slashes | |
*/ | |
export function cleanFilePath(path: string): string { | |
return path | |
.replace(/\\/g, "/") | |
.split(/\//g) | |
.reduce((acc, el) => { | |
if (el === "..") acc.pop(); | |
else if (el !== ".") acc.push(el); | |
return acc; | |
}, [] as string[]) | |
.join("/"); | |
} | |
/** | |
* Interface representing the structure of an Urdf processor | |
*/ | |
export interface UrdfProcessor { | |
loadUrdf: (path: string) => void; | |
setUrlModifierFunc: (func: (url: string) => string) => void; | |
getPackage: () => string; | |
} | |
// Reference to hold the package path | |
const packageRef = { current: "" }; | |
/** | |
* Reads the content of a Urdf file | |
* @param file The Urdf file object | |
* @returns A promise that resolves with the content of the file as a string | |
*/ | |
export function readUrdfFileContent(file: File): Promise<string> { | |
return new Promise((resolve, reject) => { | |
const reader = new FileReader(); | |
reader.onload = (event) => { | |
if (event.target && event.target.result) { | |
resolve(event.target.result as string); | |
} else { | |
reject(new Error("Failed to read Urdf file content")); | |
} | |
}; | |
reader.onerror = () => reject(new Error("Error reading Urdf file")); | |
reader.readAsText(file); | |
}); | |
} | |
/** | |
* Downloads a zip file from a URL and extracts its contents | |
* @param zipUrl URL of the zip file to download | |
* @param urdfProcessor The Urdf processor to use for loading | |
* @returns A promise that resolves with the extraction results | |
*/ | |
export async function downloadAndExtractZip( | |
zipUrl: string, | |
urdfProcessor: UrdfProcessor | |
): Promise<{ | |
files: Record<string, File>; | |
availableModels: string[]; | |
blobUrls: Record<string, string>; | |
}> { | |
console.log("🔄 Downloading zip file from:", zipUrl); | |
try { | |
// Download the zip file | |
const response = await fetch(zipUrl); | |
if (!response.ok) { | |
throw new Error(`Failed to download zip: ${response.statusText}`); | |
} | |
const zipBlob = await response.blob(); | |
// Load JSZip dynamically since it's much easier to work with than manual Blob handling | |
// We use dynamic import to avoid adding a dependency | |
const JSZip = (await import("jszip")).default; | |
const zip = new JSZip(); | |
// Load the zip content | |
const contents = await zip.loadAsync(zipBlob); | |
// Convert zip contents to files | |
const files: Record<string, File> = {}; | |
const filePromises: Promise<void>[] = []; | |
// Process each file in the zip | |
contents.forEach((relativePath, zipEntry) => { | |
if (!zipEntry.dir) { | |
const promise = zipEntry.async("blob").then((blob) => { | |
// Create a file with the proper name and path | |
const path = "/" + relativePath; | |
files[path] = new File( | |
[blob], | |
relativePath.split("/").pop() || "unknown", | |
{ | |
type: getMimeType(relativePath.split(".").pop() || ""), | |
} | |
); | |
}); | |
filePromises.push(promise); | |
} | |
}); | |
// Wait for all files to be processed | |
await Promise.all(filePromises); | |
// Get all file paths and clean them | |
const fileNames = Object.keys(files).map((n) => cleanFilePath(n)); | |
// Filter all files ending in Urdf | |
const availableModels = fileNames.filter((n) => /urdf$/i.test(n)); | |
// Create blob URLs for Urdf files | |
const blobUrls: Record<string, string> = {}; | |
availableModels.forEach((path) => { | |
blobUrls[path] = URL.createObjectURL(files[path]); | |
}); | |
// Extract the package base path from the first Urdf model for reference | |
let packageBasePath = ""; | |
if (availableModels.length > 0) { | |
// Extract the main directory path (e.g., '/cassie_description/') | |
const firstModel = availableModels[0]; | |
const packageMatch = firstModel.match(/^(\/[^/]+\/)/); | |
if (packageMatch && packageMatch[1]) { | |
packageBasePath = packageMatch[1]; | |
} | |
} | |
// Store the package path for future reference | |
const packagePathRef = packageBasePath; | |
urdfProcessor.setUrlModifierFunc((url) => { | |
// Find the matching file given the requested URL | |
// Store package reference for future use | |
if (packagePathRef) { | |
packageRef.current = packagePathRef; | |
} | |
// Simple approach: just find the first file that matches the end of the URL | |
const cleaned = cleanFilePath(url); | |
// Get the filename from the URL | |
const urlFilename = cleaned.split("/").pop() || ""; | |
// Find the first file that ends with this filename | |
let fileName = fileNames.find((name) => name.endsWith(urlFilename)); | |
// If no match found, just take the first file with a similar extension | |
if (!fileName && urlFilename.includes(".")) { | |
const extension = "." + urlFilename.split(".").pop(); | |
fileName = fileNames.find((name) => name.endsWith(extension)); | |
} | |
if (fileName !== undefined && fileName !== null) { | |
// Extract file extension for content type | |
const fileExtension = fileName.split(".").pop()?.toLowerCase() || ""; | |
// Create blob URL with extension in the searchParams to help with format detection | |
const blob = new Blob([files[fileName]], { | |
type: getMimeType(fileExtension), | |
}); | |
const blobUrl = URL.createObjectURL(blob) + "#." + fileExtension; | |
// Don't revoke immediately, wait for the mesh to be loaded | |
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); | |
return blobUrl; | |
} | |
console.warn(`No matching file found for: ${url}`); | |
return url; | |
}); | |
return { | |
files, | |
availableModels, | |
blobUrls, | |
}; | |
} catch (error) { | |
console.error("❌ Error downloading or extracting zip:", error); | |
throw error; | |
} | |
} | |
/** | |
* Processes dropped files and returns information about available Urdf models | |
*/ | |
export async function processDroppedFiles( | |
dataTransfer: DataTransfer, | |
urdfProcessor: UrdfProcessor | |
): Promise<{ | |
files: Record<string, File>; | |
availableModels: string[]; | |
blobUrls: Record<string, string>; | |
}> { | |
// Reset the package reference | |
packageRef.current = ""; | |
// Convert dropped files into a structured format | |
const files = await dataTransferToFiles(dataTransfer); | |
// Get all file paths and clean them | |
const fileNames = Object.keys(files).map((n) => cleanFilePath(n)); | |
// Filter all files ending in Urdf | |
const availableModels = fileNames.filter((n) => /urdf$/i.test(n)); | |
// Create blob URLs for Urdf files | |
const blobUrls: Record<string, string> = {}; | |
availableModels.forEach((path) => { | |
blobUrls[path] = URL.createObjectURL(files[path]); | |
}); | |
// Extract the package base path from the first Urdf model for reference | |
let packageBasePath = ""; | |
if (availableModels.length > 0) { | |
// Extract the main directory path (e.g., '/cassie_description/') | |
const firstModel = availableModels[0]; | |
const packageMatch = firstModel.match(/^(\/[^/]+\/)/); | |
if (packageMatch && packageMatch[1]) { | |
packageBasePath = packageMatch[1]; | |
} | |
} | |
// Store the package path for future reference | |
const packagePathRef = packageBasePath; | |
urdfProcessor.setUrlModifierFunc((url) => { | |
// Find the matching file given the requested URL | |
// Store package reference for future use | |
if (packagePathRef) { | |
packageRef.current = packagePathRef; | |
} | |
// Simple approach: just find the first file that matches the end of the URL | |
const cleaned = cleanFilePath(url); | |
// Get the filename from the URL | |
const urlFilename = cleaned.split("/").pop() || ""; | |
// Find the first file that ends with this filename | |
let fileName = fileNames.find((name) => name.endsWith(urlFilename)); | |
// If no match found, just take the first file with a similar extension | |
if (!fileName && urlFilename.includes(".")) { | |
const extension = "." + urlFilename.split(".").pop(); | |
fileName = fileNames.find((name) => name.endsWith(extension)); | |
} | |
if (fileName !== undefined && fileName !== null) { | |
// Extract file extension for content type | |
const fileExtension = fileName.split(".").pop()?.toLowerCase() || ""; | |
// Create blob URL with extension in the searchParams to help with format detection | |
const blob = new Blob([files[fileName]], { | |
type: getMimeType(fileExtension), | |
}); | |
const blobUrl = URL.createObjectURL(blob) + "#." + fileExtension; | |
// Don't revoke immediately, wait for the mesh to be loaded | |
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); | |
return blobUrl; | |
} | |
console.warn(`No matching file found for: ${url}`); | |
return url; | |
}); | |
return { | |
files, | |
availableModels, | |
blobUrls, | |
}; | |
} | |
/** | |
* Get the MIME type for a file extension | |
*/ | |
function getMimeType(extension: string): string { | |
switch (extension.toLowerCase()) { | |
case "stl": | |
return "model/stl"; | |
case "obj": | |
return "model/obj"; | |
case "gltf": | |
case "glb": | |
return "model/gltf+json"; | |
case "dae": | |
return "model/vnd.collada+xml"; | |
case "urdf": | |
return "application/xml"; | |
default: | |
return "application/octet-stream"; | |
} | |
} | |