feat: add ModelCache for downloading and caching 3D models from URLs
This commit is contained in:
331
src/ModelCache.cpp
Normal file
331
src/ModelCache.cpp
Normal file
@@ -0,0 +1,331 @@
|
||||
// ModelCache.cpp
|
||||
#include "ModelCache.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <thread>
|
||||
#include <cstring>
|
||||
|
||||
// For HTTP downloads - using libcurl (cross-platform)
|
||||
#include <curl/curl.h>
|
||||
|
||||
// For hashing URLs to filenames
|
||||
#include <openssl/sha.h>
|
||||
|
||||
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<std::ofstream*>(userp);
|
||||
stream->write(static_cast<const char*>(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<std::vector<ModelCache::ProgressCallback>*>(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<const unsigned char*>(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<int>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<ModelResource>();
|
||||
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<ModelResource> resource;
|
||||
fs::path localPath;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<CompletionCallback> callbacks;
|
||||
std::string localPath;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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();
|
||||
}
|
||||
81
src/ModelCache.hpp
Normal file
81
src/ModelCache.hpp
Normal file
@@ -0,0 +1,81 @@
|
||||
// ModelCache.hpp
|
||||
// Manages downloading and caching of 3D models from HTTP/HTTPS URLs
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <filesystem>
|
||||
|
||||
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<void(const std::string& url, size_t bytesReceived, size_t bytesTotal)>;
|
||||
using CompletionCallback = std::function<void(const std::string& url, bool success, const std::string& localPath)>;
|
||||
|
||||
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<std::string, std::shared_ptr<ModelResource>> resources_;
|
||||
|
||||
// Callbacks stored per URL
|
||||
std::unordered_map<std::string, std::vector<CompletionCallback>> completionCallbacks_;
|
||||
std::unordered_map<std::string, std::vector<ProgressCallback>> progressCallbacks_;
|
||||
};
|
||||
Reference in New Issue
Block a user