feat: implement ModelDownloader for fetching and caching 3D models from URLs

This commit is contained in:
MayaTheShy
2025-11-08 23:29:47 -05:00
parent e69a24e225
commit c3ae1bf929

View File

@@ -0,0 +1,176 @@
// Model downloader for fetching GLTF/GLB models from URLs
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
use std::io::Write;
/// Computes a simple hash of a URL for cache filenames
fn url_hash(url: &str) -> String {
// Simple hash: use last path component + length as identifier
// In production, use a proper hash like SHA256
let sanitized = url.replace(['/', ':', '?', '&', '='], "_");
let len = url.len();
format!("{:x}_{}", len, sanitized.chars().rev().take(32).collect::<String>())
}
/// Model cache entry
#[derive(Clone)]
struct CacheEntry {
path: PathBuf,
downloading: bool,
}
/// Downloads and caches 3D models from HTTP URLs
pub struct ModelDownloader {
cache_dir: PathBuf,
cache: Arc<Mutex<HashMap<String, CacheEntry>>>,
client: reqwest::blocking::Client,
}
impl ModelDownloader {
/// Create a new model downloader with the given cache directory
pub fn new(cache_dir: PathBuf) -> Result<Self, std::io::Error> {
fs::create_dir_all(&cache_dir)?;
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
Ok(Self {
cache_dir,
cache: Arc::new(Mutex::new(HashMap::new())),
client,
})
}
/// Get a model from URL, downloading if necessary
/// Returns PathBuf if available, None if downloading or failed
pub fn get_model(&self, url: &str) -> Option<PathBuf> {
// Check cache first
{
let cache = self.cache.lock().ok()?;
if let Some(entry) = cache.get(url) {
if entry.downloading {
eprintln!("[downloader] Model still downloading: {}", url);
return None;
}
if entry.path.exists() {
return Some(entry.path.clone());
}
}
}
// Determine file extension from URL
let extension = if url.ends_with(".glb") {
"glb"
} else if url.ends_with(".gltf") {
"gltf"
} else if url.ends_with(".vrm") {
"vrm"
} else {
// Default to GLB
eprintln!("[downloader] Unknown extension for {}, assuming .glb", url);
"glb"
};
let hash = url_hash(url);
let filename = format!("{}.{}", hash, extension);
let dest_path = self.cache_dir.join(&filename);
// Check if already on disk
if dest_path.exists() {
eprintln!("[downloader] Found cached model: {}", dest_path.display());
let mut cache = self.cache.lock().ok()?;
cache.insert(url.to_string(), CacheEntry {
path: dest_path.clone(),
downloading: false,
});
return Some(dest_path);
}
// Mark as downloading
{
let mut cache = self.cache.lock().ok()?;
cache.insert(url.to_string(), CacheEntry {
path: dest_path.clone(),
downloading: true,
});
}
eprintln!("[downloader] Downloading model: {}", url);
// Download in current thread (blocking)
match self.download_file(url, &dest_path) {
Ok(_) => {
eprintln!("[downloader] Downloaded successfully: {}", dest_path.display());
let mut cache = self.cache.lock().ok()?;
cache.insert(url.to_string(), CacheEntry {
path: dest_path.clone(),
downloading: false,
});
Some(dest_path)
}
Err(e) => {
eprintln!("[downloader] Failed to download {}: {}", url, e);
// Remove from cache on failure
let mut cache = self.cache.lock().ok()?;
cache.remove(url);
None
}
}
}
/// Download a file from URL to destination path
fn download_file(&self, url: &str, dest: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let response = self.client.get(url).send()?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()).into());
}
let bytes = response.bytes()?;
// Create temporary file first
let temp_path = dest.with_extension("tmp");
let mut file = fs::File::create(&temp_path)?;
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
// Rename to final destination
fs::rename(&temp_path, dest)?;
Ok(())
}
/// Clear the download cache
pub fn clear_cache(&self) -> Result<(), std::io::Error> {
let mut cache = self.cache.lock().unwrap();
cache.clear();
if self.cache_dir.exists() {
fs::remove_dir_all(&self.cache_dir)?;
fs::create_dir_all(&self.cache_dir)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_hash() {
let hash1 = url_hash("https://example.com/model.glb");
let hash2 = url_hash("https://example.com/model.glb");
let hash3 = url_hash("https://example.com/other.glb");
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
}
}