From c3ae1bf92936f76a9db332e186b1267566966d73 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 8 Nov 2025 23:29:47 -0500 Subject: [PATCH] feat: implement ModelDownloader for fetching and caching 3D models from URLs --- bridge/src/model_downloader.rs | 176 +++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 bridge/src/model_downloader.rs diff --git a/bridge/src/model_downloader.rs b/bridge/src/model_downloader.rs new file mode 100644 index 0000000..345e09c --- /dev/null +++ b/bridge/src/model_downloader.rs @@ -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::()) +} + +/// 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>>, + client: reqwest::blocking::Client, +} + +impl ModelDownloader { + /// Create a new model downloader with the given cache directory + pub fn new(cache_dir: PathBuf) -> Result { + 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 { + // 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> { + 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); + } +}