diff --git a/src/ModelCache.cpp b/src/ModelCache.cpp new file mode 100644 index 0000000..97dd6f3 --- /dev/null +++ b/src/ModelCache.cpp @@ -0,0 +1,331 @@ +// ModelCache.cpp +#include "ModelCache.hpp" + +#include +#include +#include +#include +#include +#include + +// For HTTP downloads - using libcurl (cross-platform) +#include + +// For hashing URLs to filenames +#include + +namespace { + // CURL write callback + size_t writeCallback(void* contents, size_t size, size_t nmemb, void* userp) { + size_t totalSize = size * nmemb; + auto* stream = static_cast(userp); + stream->write(static_cast(contents), totalSize); + return totalSize; + } + + // CURL progress callback + int progressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow) { + auto* callbacks = static_cast*>(clientp); + if (callbacks && !callbacks->empty()) { + // Note: We'd need to pass URL here, but CURL doesn't make that easy + // For now, just track progress internally + } + return 0; // Return 0 to continue download + } + + // Convert URL to SHA256 hash for filename + std::string sha256(const std::string& str) { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(str.c_str()), str.length(), hash); + + std::ostringstream oss; + for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) { + oss << std::hex << std::setw(2) << std::setfill('0') << static_cast(hash[i]); + } + return oss.str(); + } + + // Determine file extension from URL or Content-Type + std::string getExtensionFromUrl(const std::string& url) { + // Simple heuristic: look for common 3D model extensions + if (url.find(".glb") != std::string::npos || url.find(".GLB") != std::string::npos) { + return ".glb"; + } else if (url.find(".gltf") != std::string::npos || url.find(".GLTF") != std::string::npos) { + return ".gltf"; + } else if (url.find(".fbx") != std::string::npos || url.find(".FBX") != std::string::npos) { + return ".fbx"; + } else if (url.find(".obj") != std::string::npos || url.find(".OBJ") != std::string::npos) { + return ".obj"; + } + // Default to GLB for Overte compatibility + return ".glb"; + } +} + +ModelCache& ModelCache::instance() { + static ModelCache instance; + return instance; +} + +ModelCache::ModelCache() { + // Initialize libcurl globally + curl_global_init(CURL_GLOBAL_DEFAULT); + + // Set default cache directory: ~/.cache/starworld/models/ + const char* home = std::getenv("HOME"); + if (home) { + cacheDir_ = fs::path(home) / ".cache" / "starworld" / "models"; + } else { + cacheDir_ = fs::path("/tmp") / "starworld" / "models"; + } + + // Create cache directory if it doesn't exist + try { + fs::create_directories(cacheDir_); + std::cout << "[ModelCache] Cache directory: " << cacheDir_ << std::endl; + } catch (const fs::filesystem_error& e) { + std::cerr << "[ModelCache] Failed to create cache directory: " << e.what() << std::endl; + } +} + +void ModelCache::setCacheDirectory(const fs::path& dir) { + std::lock_guard lock(mutex_); + cacheDir_ = dir; + try { + fs::create_directories(cacheDir_); + } catch (const fs::filesystem_error& e) { + std::cerr << "[ModelCache] Failed to create cache directory: " << e.what() << std::endl; + } +} + +std::string ModelCache::urlToFilename(const std::string& url) const { + // Hash the URL to get a unique filename + std::string hash = sha256(url); + std::string ext = getExtensionFromUrl(url); + return hash + ext; +} + +bool ModelCache::isCached(const std::string& url) const { + std::lock_guard lock(mutex_); + + std::string filename = urlToFilename(url); + fs::path localPath = cacheDir_ / filename; + + return fs::exists(localPath) && fs::is_regular_file(localPath); +} + +std::string ModelCache::getCachedPath(const std::string& url) const { + std::lock_guard lock(mutex_); + + std::string filename = urlToFilename(url); + fs::path localPath = cacheDir_ / filename; + + if (fs::exists(localPath) && fs::is_regular_file(localPath)) { + return localPath.string(); + } + return ""; +} + +ModelCache::State ModelCache::getState(const std::string& url) const { + std::lock_guard lock(mutex_); + + auto it = resources_.find(url); + if (it != resources_.end()) { + return it->second->state; + } + return State::NotStarted; +} + +void ModelCache::requestModel(const std::string& url, + CompletionCallback onComplete, + ProgressCallback onProgress) { + // Check if already cached + if (isCached(url)) { + std::string cachedPath = getCachedPath(url); + std::cout << "[ModelCache] Using cached model: " << url << " -> " << cachedPath << std::endl; + if (onComplete) { + onComplete(url, true, cachedPath); + } + return; + } + + { + std::lock_guard lock(mutex_); + + // Check if download is already in progress + auto it = resources_.find(url); + if (it != resources_.end()) { + // Download already in progress, just add callbacks + if (onComplete) { + completionCallbacks_[url].push_back(onComplete); + } + if (onProgress) { + progressCallbacks_[url].push_back(onProgress); + } + std::cout << "[ModelCache] Download already in progress: " << url << std::endl; + return; + } + + // Create new resource entry + auto resource = std::make_shared(); + resource->url = url; + resource->localPath = cacheDir_ / urlToFilename(url); + resource->state = State::Downloading; + resources_[url] = resource; + + // Store callbacks + if (onComplete) { + completionCallbacks_[url].push_back(onComplete); + } + if (onProgress) { + progressCallbacks_[url].push_back(onProgress); + } + } + + // Start download in background thread + std::cout << "[ModelCache] Starting download: " << url << std::endl; + std::thread([this, url]() { + this->startDownload(url); + }).detach(); +} + +void ModelCache::startDownload(const std::string& url) { + CURL* curl = curl_easy_init(); + if (!curl) { + std::cerr << "[ModelCache] Failed to initialize CURL for: " << url << std::endl; + onDownloadComplete(url, false, "Failed to initialize CURL"); + return; + } + + std::shared_ptr resource; + fs::path localPath; + { + std::lock_guard lock(mutex_); + auto it = resources_.find(url); + if (it == resources_.end()) { + curl_easy_cleanup(curl); + return; + } + resource = it->second; + localPath = resource->localPath; + } + + // Open output file + std::ofstream outFile(localPath, std::ios::binary); + if (!outFile) { + std::cerr << "[ModelCache] Failed to open output file: " << localPath << std::endl; + curl_easy_cleanup(curl); + onDownloadComplete(url, false, "Failed to open output file"); + return; + } + + // Configure CURL + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &outFile); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "Starworld/1.0 (Overte Client for StardustXR)"); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + // Progress tracking + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progressCallback); + + // Perform download + CURLcode res = curl_easy_perform(curl); + outFile.close(); + + if (res != CURLE_OK) { + std::string error = curl_easy_strerror(res); + std::cerr << "[ModelCache] Download failed: " << url << " - " << error << std::endl; + + // Clean up failed download + try { + fs::remove(localPath); + } catch (...) {} + + curl_easy_cleanup(curl); + onDownloadComplete(url, false, error); + return; + } + + // Check HTTP response code + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + + if (httpCode >= 400) { + std::cerr << "[ModelCache] HTTP error " << httpCode << " for: " << url << std::endl; + + try { + fs::remove(localPath); + } catch (...) {} + + curl_easy_cleanup(curl); + onDownloadComplete(url, false, "HTTP error " + std::to_string(httpCode)); + return; + } + + // Get download size + curl_off_t downloadSize = 0; + curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &downloadSize); + + curl_easy_cleanup(curl); + + std::cout << "[ModelCache] Download complete: " << url << " (" << downloadSize << " bytes) -> " << localPath << std::endl; + onDownloadComplete(url, true); +} + +void ModelCache::onDownloadComplete(const std::string& url, bool success, const std::string& error) { + std::vector callbacks; + std::string localPath; + + { + std::lock_guard lock(mutex_); + + auto it = resources_.find(url); + if (it != resources_.end()) { + it->second->state = success ? State::Completed : State::Failed; + if (!error.empty()) { + it->second->errorMessage = error; + } + localPath = it->second->localPath.string(); + } + + // Get callbacks + auto cbIt = completionCallbacks_.find(url); + if (cbIt != completionCallbacks_.end()) { + callbacks = std::move(cbIt->second); + completionCallbacks_.erase(cbIt); + } + + // Clear progress callbacks + progressCallbacks_.erase(url); + } + + // Call all completion callbacks + for (auto& cb : callbacks) { + if (cb) { + cb(url, success, success ? localPath : ""); + } + } +} + +void ModelCache::clearCache() { + std::lock_guard lock(mutex_); + + try { + // Remove all files in cache directory + for (const auto& entry : fs::directory_iterator(cacheDir_)) { + if (entry.is_regular_file()) { + fs::remove(entry.path()); + } + } + std::cout << "[ModelCache] Cache cleared" << std::endl; + } catch (const fs::filesystem_error& e) { + std::cerr << "[ModelCache] Failed to clear cache: " << e.what() << std::endl; + } + + resources_.clear(); + completionCallbacks_.clear(); + progressCallbacks_.clear(); +} diff --git a/src/ModelCache.hpp b/src/ModelCache.hpp new file mode 100644 index 0000000..1db38bc --- /dev/null +++ b/src/ModelCache.hpp @@ -0,0 +1,81 @@ +// ModelCache.hpp +// Manages downloading and caching of 3D models from HTTP/HTTPS URLs +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +class ModelCache { +public: + enum class State { + NotStarted, + Downloading, + Completed, + Failed + }; + + struct ModelResource { + std::string url; + fs::path localPath; + State state = State::NotStarted; + size_t bytesReceived = 0; + size_t bytesTotal = 0; + std::string errorMessage; + }; + + using ProgressCallback = std::function; + using CompletionCallback = std::function; + + static ModelCache& instance(); + + // Request a model from URL. If already cached, returns path immediately via callback. + // Otherwise, starts download and calls callback when complete. + void requestModel(const std::string& url, + CompletionCallback onComplete, + ProgressCallback onProgress = nullptr); + + // Synchronous check if model is already cached + bool isCached(const std::string& url) const; + + // Get local path if cached (empty string if not) + std::string getCachedPath(const std::string& url) const; + + // Get current state of a model request + State getState(const std::string& url) const; + + // Clear all cached models + void clearCache(); + + // Set cache directory (default: ~/.cache/starworld/models/) + void setCacheDirectory(const fs::path& dir); + fs::path getCacheDirectory() const { return cacheDir_; } + +private: + ModelCache(); + ~ModelCache() = default; + ModelCache(const ModelCache&) = delete; + ModelCache& operator=(const ModelCache&) = delete; + + // Generate cache filename from URL (using hash) + std::string urlToFilename(const std::string& url) const; + + // Start actual download (runs in background thread) + void startDownload(const std::string& url); + + // Handle download completion + void onDownloadComplete(const std::string& url, bool success, const std::string& error = ""); + + mutable std::mutex mutex_; + fs::path cacheDir_; + std::unordered_map> resources_; + + // Callbacks stored per URL + std::unordered_map> completionCallbacks_; + std::unordered_map> progressCallbacks_; +};