Implements asset management system

Introduces a new asset management system for loading and managing game assets, including textures and models.

This system includes:
- Asset scanning and indexing with background loading.
- Virtual file system for asset identification.
- Resource tree for UI file browsing.
- Lazy loading of textures with garbage collection.
- OBJ model loading via Assimp integration

Fixes file browser integration and asset handling.
This commit is contained in:
2025-08-24 13:16:11 -05:00
parent 9e19f6ecf1
commit fd78e4582c
15 changed files with 1974 additions and 1303 deletions

View File

@@ -106,6 +106,7 @@ add_library(Core STATIC
src/core/systems/Scene/Components/CameraComponent.h
src/core/systems/Scene/Components/PointLightComponent.cpp
src/core/systems/Scene/Components/PointLightComponent.h
src/core/systems/assets/Model.h
)
target_include_directories(Core PUBLIC src/core)

View File

@@ -1,55 +1,172 @@
// AssetManager.cpp
#include "AssetManager.h"
#include "assets/Texture2D.h"
#include "Logger.h"
#include "Profiler.h"
#include <stb/stb_image.h>
#include <GL/glew.h>
#include <yaml-cpp/yaml.h>
#include <algorithm>
#include <fstream>
#include <ranges>
#include <filesystem>
#include <sstream>
#include <unordered_set>
#include "Profiler.h"
namespace fs = std::filesystem;
namespace OX
{
// statics
std::unordered_map<std::string, std::shared_ptr<Asset> > AssetManager::s_LoadedAssets;
std::unordered_map<std::string, AssetManager::AssetMetadata> AssetManager::s_MetadataMap;
namespace fs = std::filesystem;
std::atomic<size_t> AssetManager::s_TotalTexturesToLoad{0};
std::atomic<size_t> AssetManager::s_LoadedTexturesCount{0};
// ============================================================================
// Statics
// ============================================================================
std::filesystem::path AssetManager::s_ProjectRoot;
// Map from an actual file path to virtual ID
std::unordered_map<std::string, std::string> AssetManager::s_PathToID;
std::mutex AssetManager::s_AssetMutex;
std::mutex AssetManager::s_TreeMutex;
std::unordered_map<uint64_t, AssetManager::AssetRecord> AssetManager::s_RecordsById;
std::unordered_map<uint64_t, AssetMetadata> AssetManager::s_MetaById;
std::unordered_map<std::string, uint64_t> AssetManager::s_PathToId;
std::unordered_map<std::string, uint64_t> AssetManager::s_VPathToId;
std::shared_ptr<ResourceTreeNode> AssetManager::s_FileTree = std::make_shared<ResourceTreeNode>();
std::filesystem::path AssetManager::s_ProjectRoot;
std::atomic<bool> AssetManager::s_Scanning{true};
std::thread AssetManager::s_ScanThread;
std::queue<AssetManager::PendingTexture> AssetManager::s_TextureQueue;
std::mutex AssetManager::s_QueueMutex;
std::mutex AssetManager::s_AssetMutex;
std::mutex AssetManager::s_TreeMutex;
std::atomic<double> AssetManager::s_GCSeconds{120.0};
std::atomic<size_t> AssetManager::s_TotalTexturesDiscovered{0};
std::atomic<size_t> AssetManager::s_LoadedTextures{0};
// ============================================================================
// Helpers
// ============================================================================
static inline uint64_t fnv1a64(const char *s)
{
uint64_t h = 1469598103934665603ull;
while (*s) {
h ^= (uint8_t) *s++;
h *= 1099511628211ull;
}
return h;
}
uint64_t AssetManager::MakeIdFromVirtualPath_(const std::string &vpath)
{
return fnv1a64(vpath.c_str());
}
OX_AssetType AssetManager::DetectAssetKind_(const fs::path &path)
{
std::string ext = path.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || ext == ".tga")
return OX_AssetType::Texture;
if (ext == ".obj")
return OX_AssetType::Model;
if (ext == ".ttf" || ext == ".otf")
return OX_AssetType::Font;
if (ext == ".wav" || ext == ".ogg" || ext == ".mp3")
return OX_AssetType::Sound;
return OX_AssetType::Unknown;
}
void AssetManager::Touch_(AssetRecord &rec)
{
rec.lastAccess = std::chrono::steady_clock::now();
}
// ============================================================================
// Texture loading (sync, creates GL texture now)
// ============================================================================
std::shared_ptr<Texture2D> AssetManager::LoadTextureNow_(const AssetMetadata &meta, size_t &outBytes)
{
int w = 0, h = 0, ch = 0;
stbi_set_flip_vertically_on_load(1);
unsigned char *data = stbi_load(meta.absolutePath.c_str(), &w, &h, &ch, 0);
if (!data) {
Logger::LogWarning("Texture failed to load: %s", meta.absolutePath.c_str());
return nullptr;
}
GLuint texID = 0;
glGenTextures(1, &texID);
glBindTexture(GL_TEXTURE_2D, texID);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
GLenum format = GL_RED, internal = GL_R8;
switch (ch) {
case 1: format = GL_RED;
internal = GL_R8;
break;
case 2: format = GL_RG;
internal = GL_RG8;
break;
case 3: format = GL_RGB;
internal = GL_RGB8;
break;
case 4: format = GL_RGBA;
internal = GL_RGBA8;
break;
default:
Logger::LogWarning("Texture '%s' unexpected channels=%d, forcing RGBA", meta.absolutePath.c_str(), ch);
format = GL_RGBA;
internal = GL_RGBA8;
ch = 4;
break;
}
glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, GL_UNSIGNED_BYTE, data);
if (ch == 1) {
GLint sw[] = {GL_RED, GL_RED, GL_RED, GL_ONE};
glTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_RGBA, sw);
} else if (ch == 2) {
GLint sw[] = {GL_RED, GL_GREEN, GL_ZERO, GL_ONE};
glTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_RGBA, sw);
}
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
stbi_image_free(data);
glBindTexture(GL_TEXTURE_2D, 0);
auto tex = std::make_shared<Texture2D>();
tex->SetFromGL(texID, w, h);
outBytes = size_t(w) * size_t(h) * size_t(ch);
return tex;
}
// ============================================================================
// Public API
// ============================================================================
void AssetManager::Init(const std::string &projectRoot)
{
s_ProjectRoot = fs::absolute(projectRoot);
s_Scanning = true;
s_Scanning = true; {
std::scoped_lock lk(s_AssetMutex);
s_RecordsById.clear();
s_MetaById.clear();
s_PathToId.clear();
s_VPathToId.clear();
s_TotalTexturesDiscovered.store(0);
s_LoadedTextures.store(0);
} {
std::scoped_lock lk(s_TreeMutex);
s_FileTree = std::make_shared<ResourceTreeNode>();
s_FileTree->name = "res://";
s_FileTree->path = "res://";
s_FileTree->isDirectory = true;
}
s_FileTree = std::make_shared<ResourceTreeNode>();
s_FileTree->name = "res://";
s_FileTree->path = "res://";
s_FileTree->isDirectory = true;
s_TotalTexturesToLoad.store(0);
s_LoadedTexturesCount.store(0);
if (s_ScanThread.joinable()) s_ScanThread.join();
s_ScanThread = std::thread(BackgroundScan);
}
@@ -58,274 +175,31 @@ namespace OX
s_Scanning = false;
if (s_ScanThread.joinable())
s_ScanThread.join();
std::scoped_lock lk(s_AssetMutex);
s_RecordsById.clear();
s_MetaById.clear();
s_PathToId.clear();
s_VPathToId.clear();
s_TotalTexturesDiscovered.store(0);
s_LoadedTextures.store(0);
}
void AssetManager::Rescan()
{
if (s_Scanning)
return;
if (s_Scanning) return;
s_Scanning = true; {
std::scoped_lock lk(s_TreeMutex);
s_FileTree = std::make_shared<ResourceTreeNode>();
s_FileTree->name = "res://";
s_FileTree->path = "res://";
s_FileTree->isDirectory = true;
}
s_Scanning = true;
s_FileTree = std::make_shared<ResourceTreeNode>();
s_FileTree->name = "res://";
s_FileTree->path = "res://";
s_FileTree->isDirectory = true;
s_TotalTexturesToLoad.store(0);
s_LoadedTexturesCount.store(0);
if (s_ScanThread.joinable()) s_ScanThread.join();
s_ScanThread = std::thread(BackgroundScan);
}
void AssetManager::BackgroundScan()
{
size_t totalTextures = 0;
try {
for (auto it = fs::recursive_directory_iterator(s_ProjectRoot,
fs::directory_options::skip_permission_denied);
it != fs::recursive_directory_iterator(); ++it) {
if (!s_Scanning) break;
const auto &entry = *it;
if (!entry.is_directory() && DetectAssetType(entry.path()) == "texture2D")
++totalTextures;
}
} catch (const fs::filesystem_error &e) {
Logger::LogError("AssetManager scan error (count): %s", e.what());
}
s_TotalTexturesToLoad.store(totalTextures, std::memory_order_relaxed);
s_LoadedTexturesCount.store(0, std::memory_order_relaxed);
std::vector<std::pair<std::string, AssetMetadata> > newMetadata;
newMetadata.reserve(256);
bool showed_error = false;
while (s_Scanning) {
std::vector<fs::directory_entry> allEntries;
allEntries.reserve(1024);
try {
for (auto it = fs::recursive_directory_iterator(s_ProjectRoot,
fs::directory_options::skip_permission_denied);
it != fs::recursive_directory_iterator(); ++it) {
if (!s_Scanning) break;
allEntries.push_back(*it);
}
} catch (const fs::filesystem_error &e) {
if (!showed_error) {
Logger::LogError("AssetManager scan error (gather): %s", e.what());
showed_error = true;
}
}
newMetadata.clear();
std::unordered_set<std::string> foundAssets;
foundAssets.reserve(allEntries.size());
for (auto &entry: allEntries) {
if (!s_Scanning) break;
try {
const auto &path = entry.path();
std::string resPath = MakeVirtualPath(path);
foundAssets.insert(resPath);
if (entry.is_directory()) {
AddToTree(resPath, true);
continue;
} {
std::lock_guard<std::mutex> lock(s_AssetMutex);
if (s_MetadataMap.count(resPath))
continue;
s_MetadataMap.emplace(resPath, AssetMetadata{"", ""});
}
AddToTree(resPath, false);
if (DetectAssetType(path) == "texture2D") {
int w, h, ch;
stbi_set_flip_vertically_on_load(1);
unsigned char *data = stbi_load(path.string().c_str(), &w, &h, &ch, 0);
if (!data) {
Logger::LogWarning("Failed to load texture: %s", path.string().c_str());
continue;
}
newMetadata.emplace_back(resPath, AssetMetadata{"texture2D", path.string()}); {
std::lock_guard<std::mutex> qlock(s_QueueMutex);
s_TextureQueue.push({resPath, w, h, ch, data});
}
}
} catch (const std::exception &e) {
Logger::LogError("Error processing asset '%s': %s",
entry.path().string().c_str(),
e.what());
}
catch (...) {
Logger::LogError("Unknown error processing asset '%s'",
entry.path().string().c_str());
}
}
if (!newMetadata.empty()) {
std::lock_guard<std::mutex> lock(s_AssetMutex);
for (auto &kv: newMetadata)
s_MetadataMap[kv.first] = kv.second;
}
// ─────────────────────────────────────────────
// REMOVE FILES THAT NO LONGER EXIST
// ─────────────────────────────────────────────
{
std::lock_guard<std::mutex> lock(s_AssetMutex);
// 1) Remove from metadata map
for (auto it = s_MetadataMap.begin(); it != s_MetadataMap.end();) {
if (!foundAssets.contains(it->first)) {
Logger::LogInfo("Removing missing asset: %s", it->first.c_str());
// Also clear from loaded assets if present
s_LoadedAssets.erase(it->first);
// Remove path->id mapping
for (auto pit = s_PathToID.begin(); pit != s_PathToID.end();) {
if (pit->second == it->first)
pit = s_PathToID.erase(pit);
else
++pit;
}
it = s_MetadataMap.erase(it);
} else {
++it;
}
}
}
if (s_Scanning)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void AssetManager::Tick()
{
OX_PROFILE_FUNCTION();
std::lock_guard<std::mutex> qlock(s_QueueMutex);
while (!s_TextureQueue.empty()) {
auto pending = s_TextureQueue.front();
s_TextureQueue.pop();
s_LoadedTexturesCount.fetch_add(1, std::memory_order_relaxed);
if (!pending.data || pending.width <= 0 || pending.height <= 0) {
Logger::LogError("Texture load failed for '%s'", pending.id.c_str());
continue;
}
GLuint texID = 0;
glGenTextures(1, &texID);
glBindTexture(GL_TEXTURE_2D, texID);
// Avoid row alignment pitfalls (e.g., 1-channel images)
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
GLenum format = GL_RED; // source data format
GLenum internal = GL_R8; // GPU storage
GLenum type = GL_UNSIGNED_BYTE;
switch (pending.channels) {
case 1: format = GL_RED;
internal = GL_R8;
break;
case 2: format = GL_RG;
internal = GL_RG8;
break;
case 3: format = GL_RGB;
internal = GL_RGB8;
break;
case 4: format = GL_RGBA;
internal = GL_RGBA8;
break;
default:
// force RGBA if stb returned something unexpected
format = GL_RGBA;
internal = GL_RGBA8;
Logger::LogWarning("Unexpected channel count %d for '%s'; forcing RGBA",
pending.channels, pending.id.c_str());
break;
}
glTexImage2D(GL_TEXTURE_2D, 0, internal,
pending.width, pending.height, 0,
format, type, pending.data);
// If RED/RG, set swizzles so shader sampling .rgb/.rgba works
if (pending.channels == 1) {
GLint swizzle[] = {GL_RED, GL_RED, GL_RED, GL_ONE};
glTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_RGBA, swizzle);
} else if (pending.channels == 2) {
GLint swizzle[] = {GL_RED, GL_GREEN, GL_ZERO, GL_ONE};
glTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_RGBA, swizzle);
}
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
stbi_image_free(pending.data);
pending.data = nullptr;
auto tex = std::make_shared<Texture2D>();
tex->SetFromGL(texID, pending.width, pending.height); {
std::lock_guard<std::mutex> lock(s_AssetMutex);
s_LoadedAssets[pending.id] = tex;
auto it = s_MetadataMap.find(pending.id);
if (it != s_MetadataMap.end())
s_PathToID[it->second.absolutePath] = pending.id;
}
// optional: unbind to keep state clean
glBindTexture(GL_TEXTURE_2D, 0);
}
}
std::shared_ptr<Asset> AssetManager::Get(const std::string &keyOrPath)
{
std::string key = keyOrPath;
const std::string triple = "res:///";
if (key.rfind(triple, 0) == 0)
key = std::string("res://") + key.substr(triple.size());
std::lock_guard<std::mutex> lock(s_AssetMutex);
// 1) Direct ID lookup
if (auto it = s_LoadedAssets.find(key); it != s_LoadedAssets.end())
return it->second;
// 2) Path-to-ID
if (auto pit = s_PathToID.find(key); pit != s_PathToID.end()) {
if (auto ait = s_LoadedAssets.find(pit->second); ait != s_LoadedAssets.end())
return ait->second;
}
// 3) Filesystem path -> virtual
try {
std::string virt = MakeVirtualPath(fs::absolute(key));
if (!virt.empty() && virt.front() == '/')
virt.erase(0, 1);
if (auto vit = s_LoadedAssets.find(virt); vit != s_LoadedAssets.end())
return vit->second;
} catch (...) {
// ignore
}
return nullptr;
}
std::shared_ptr<ResourceTreeNode> AssetManager::GetFileTree()
{
return s_FileTree;
@@ -337,16 +211,31 @@ namespace OX
out << YAML::BeginMap
<< YAML::Key << "assets"
<< YAML::Value << YAML::BeginSeq; {
std::lock_guard<std::mutex> lock(s_AssetMutex);
for (auto &[id, meta]: s_MetadataMap) {
std::scoped_lock lk(s_AssetMutex);
for (const auto &[id, meta]: s_MetaById) {
fs::path rel = fs::relative(meta.absolutePath, s_ProjectRoot);
const char *typeStr = "unknown";
switch (meta.kind) {
case OX_AssetType::Texture: typeStr = "texture";
break;
case OX_AssetType::Model: typeStr = "model";
break;
case OX_AssetType::Font: typeStr = "font";
break;
case OX_AssetType::Sound: typeStr = "sound";
break;
default: break;
}
out << YAML::BeginMap
<< YAML::Key << "id" << YAML::Value << id
<< YAML::Key << "type" << YAML::Value << meta.type
<< YAML::Key << "type" << YAML::Value << typeStr
<< YAML::Key << "path" << YAML::Value << rel.generic_string()
<< YAML::EndMap;
}
}
out << YAML::EndSeq << YAML::EndMap;
std::ofstream fout(outputPath);
@@ -359,38 +248,278 @@ namespace OX
Logger::LogError("AssetManager: error writing to '%s'", outputPath.c_str());
return;
}
Logger::LogInfo("Saved asset pack: %s", outputPath.c_str());
}
std::string AssetManager::MakeVirtualPath(const fs::path &full)
uint64_t AssetManager::GetIdForPath(const std::string &keyOrPath)
{
auto rel = fs::relative(full, s_ProjectRoot);
return "res://" + rel.generic_string();
std::scoped_lock lk(s_AssetMutex);
// 1) already a virtual path?
if (auto it = s_VPathToId.find(keyOrPath); it != s_VPathToId.end())
return it->second;
// 2) absolute/native path?
if (auto it2 = s_PathToId.find(keyOrPath); it2 != s_PathToId.end())
return it2->second;
// 3) try to normalize filesystem path -> virtual then lookup
try {
std::string virt = MakeVirtualPath(fs::absolute(keyOrPath));
if (!virt.empty() && virt.front() == '/')
virt.erase(0, 1);
if (auto it3 = s_VPathToId.find(virt); it3 != s_VPathToId.end())
return it3->second;
} catch (...) {
}
return 0; // not found
}
OX_AssetType AssetManager::GetAssetTypeById(uint64_t id)
{
std::scoped_lock lk(s_AssetMutex);
if (auto it = s_MetaById.find(id); it != s_MetaById.end())
return it->second.kind;
return OX_AssetType::Invalid;
}
std::string AssetManager::GetAbsolutePathById(uint64_t id)
{
std::scoped_lock lk(s_AssetMutex);
if (auto it = s_MetaById.find(id); it != s_MetaById.end())
return it->second.absolutePath;
return {};
}
std::string AssetManager::GetVirtualPathById(uint64_t id)
{
std::scoped_lock lk(s_AssetMutex);
// Reverse lookup from id -> virtual path
for (const auto &[vpath, nid]: s_VPathToId)
if (nid == id) return vpath;
return {};
}
std::shared_ptr<Asset> AssetManager::GetAssetById(uint64_t id)
{
// Currently only textures are lazily loaded here; others return nullptr (external loaders handle them)
switch (GetAssetTypeById(id)) {
case OX_AssetType::Texture: return GetTexture(id);
default: return nullptr; // Model/Font/Sound not loaded here
}
}
std::shared_ptr<Texture2D> AssetManager::GetTexture(uint64_t id)
{
OX_PROFILE_FUNCTION();
AssetMetadata meta; {
std::scoped_lock lk(s_AssetMutex);
auto mit = s_MetaById.find(id);
if (mit == s_MetaById.end() || mit->second.kind != OX_AssetType::Texture)
return nullptr;
meta = mit->second;
// already loaded?
if (auto rit = s_RecordsById.find(id); rit != s_RecordsById.end()) {
if (rit->second.asset) {
Touch_(rit->second);
return std::dynamic_pointer_cast<Texture2D>(rit->second.asset);
}
}
}
// Load without holding the lock
size_t bytes = 0;
auto tex = LoadTextureNow_(meta, bytes);
if (!tex) return nullptr;
// Commit to cache and bump progress
{
std::scoped_lock lk(s_AssetMutex);
auto &rec = s_RecordsById[id];
rec.meta = meta;
rec.asset = tex;
rec.approxBytes = bytes;
Touch_(rec);
s_LoadedTextures.fetch_add(1, std::memory_order_relaxed);
}
return tex;
}
size_t AssetManager::GetTotalTexturesToLoad()
{
return s_TotalTexturesDiscovered.load(std::memory_order_relaxed);
}
size_t AssetManager::GetLoadedTexturesCount()
{
return s_LoadedTextures.load(std::memory_order_relaxed);
}
void AssetManager::Tick()
{
OX_PROFILE_FUNCTION();
RunGC_();
}
void AssetManager::SetGCSeconds(double seconds)
{
if (seconds < 1.0) seconds = 1.0;
s_GCSeconds.store(seconds);
}
// ============================================================================
// Background scan (indexes assets + builds tree; only counts textures)
// ============================================================================
void AssetManager::BackgroundScan()
{
bool loggedErr = false;
// First pass: count textures so UI progress bar has a target
size_t texCount = 0;
try {
for (auto it = fs::recursive_directory_iterator(s_ProjectRoot,
fs::directory_options::skip_permission_denied);
it != fs::recursive_directory_iterator(); ++it) {
if (!s_Scanning) break;
const auto &de = *it;
if (!de.is_directory() && DetectAssetKind_(de.path()) == OX_AssetType::Texture)
++texCount;
}
} catch (const fs::filesystem_error &e) {
Logger::LogError("AssetManager scan error (count): %s", e.what());
}
s_TotalTexturesDiscovered.store(texCount, std::memory_order_relaxed);
s_LoadedTextures.store(0, std::memory_order_relaxed);
while (s_Scanning) {
std::vector<fs::directory_entry> entries;
entries.reserve(2048);
try {
for (auto it = fs::recursive_directory_iterator(s_ProjectRoot,
fs::directory_options::skip_permission_denied);
it != fs::recursive_directory_iterator(); ++it) {
if (!s_Scanning) break;
entries.push_back(*it);
}
} catch (const fs::filesystem_error &e) {
if (!loggedErr) {
Logger::LogError("AssetManager scan error: %s", e.what());
loggedErr = true;
}
}
std::unordered_set<std::string> foundV;
foundV.reserve(entries.size());
for (auto &de: entries) {
if (!s_Scanning) break;
const auto &p = de.path();
try {
std::string vpath = MakeVirtualPath(p);
foundV.insert(vpath);
if (de.is_directory()) {
AddToTree(vpath, true);
continue;
}
// Insert/refresh metadata + id
{
std::scoped_lock lk(s_AssetMutex);
// meta by virtual path (string key used only here)
OX_AssetType kind = DetectAssetKind_(p);
auto abs = p.string();
// Build or update virtual->id
uint64_t id = 0;
if (auto vit = s_VPathToId.find(vpath); vit == s_VPathToId.end()) {
id = MakeIdFromVirtualPath_(vpath);
s_VPathToId[vpath] = id;
} else {
id = vit->second;
}
// Update absolute->id and meta maps
s_PathToId[abs] = id;
auto mit = s_MetaById.find(id);
if (mit == s_MetaById.end()) {
s_MetaById.emplace(id, AssetMetadata{kind, abs});
} else {
mit->second.kind = kind;
mit->second.absolutePath = abs;
}
}
AddToTree(vpath, false);
} catch (...) {
Logger::LogError("AssetManager: error indexing '%s'", p.string().c_str());
}
}
// Remove vanished assets from maps and cache
{
std::scoped_lock lk(s_AssetMutex);
// Build reverse map vpath->id snapshot to iterate safely
std::vector<std::pair<std::string, uint64_t> > vToId(s_VPathToId.begin(), s_VPathToId.end());
for (auto &[vpath, id]: vToId) {
if (!foundV.contains(vpath)) {
// erase meta + records
s_MetaById.erase(id);
s_RecordsById.erase(id);
// erase absolute mapping entries pointing to id
for (auto pit = s_PathToId.begin(); pit != s_PathToId.end();) {
if (pit->second == id) pit = s_PathToId.erase(pit);
else ++pit;
}
s_VPathToId.erase(vpath);
}
}
}
if (s_Scanning)
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
}
// ============================================================================
// Tree building
// ============================================================================
void AssetManager::AddToTree(const std::string &resPath, bool isDir)
{
std::vector<std::string> parts;
size_t pos = 0, next;
while ((next = resPath.find('/', pos)) != std::string::npos) {
if (next != pos)
parts.push_back(resPath.substr(pos, next - pos));
if (next != pos) parts.push_back(resPath.substr(pos, next - pos));
pos = next + 1;
}
if (pos < resPath.size())
parts.push_back(resPath.substr(pos));
if (pos < resPath.size()) parts.push_back(resPath.substr(pos));
std::lock_guard<std::mutex> lock(s_TreeMutex);
std::scoped_lock lk(s_TreeMutex);
auto current = s_FileTree;
for (size_t i = 1; i < parts.size(); ++i) {
auto &name = parts[i];
// skip "res:"
const auto &name = parts[i];
auto it = std::find_if(current->children.begin(), current->children.end(),
[&](auto &c) { return c->name == name; });
if (it == current->children.end()) {
auto node = std::make_shared<ResourceTreeNode>();
node->name = name;
node->path = current->path + "/" + name;
node->path = (current->path == "res://")
? std::string("res://") + name
: current->path + "/" + name;
node->isDirectory = (i + 1 < parts.size()) || isDir;
current->children.push_back(node);
current = node;
@@ -400,11 +529,122 @@ namespace OX
}
}
std::string AssetManager::DetectAssetType(const fs::path &path)
// ============================================================================
// GC
// ============================================================================
void AssetManager::RunGC_()
{
auto e = path.extension().string();
if (e == ".png" || e == ".jpg" || e == ".jpeg")
return "texture2D";
return "";
const auto now = std::chrono::steady_clock::now();
const double ttlSec = s_GCSeconds.load();
std::scoped_lock lk(s_AssetMutex);
for (auto &[id, rec]: s_RecordsById) {
if (!rec.asset) continue;
const auto age = std::chrono::duration<double>(now - rec.lastAccess).count();
if (age >= ttlSec) {
if (rec.asset.use_count() == 1) {
Logger::LogVerbose("GC: unloading id=%llu (idle %.1fs ~ %zu bytes)",
(unsigned long long) id, age, rec.approxBytes);
rec.asset.reset(); // Texture2D dtor should free GL resource
rec.approxBytes = 0;
// do NOT decrement s_LoadedTextures; this is "currently loaded", not lifetime
}
}
}
}
// ============================================================================
// Utilities
// ============================================================================
std::string AssetManager::MakeVirtualPath(const fs::path &full)
{
fs::path rel = fs::relative(full, s_ProjectRoot);
std::string s = rel.generic_string();
if (s == "." || s.empty())
return "res://";
return "res://" + s;
}
const char *AssetManager::AssetTypeToString(OX_AssetType t)
{
switch (t) {
case OX_AssetType::Texture: return "Texture";
case OX_AssetType::Model: return "Model";
case OX_AssetType::Font: return "Font";
case OX_AssetType::Sound: return "Sound";
case OX_AssetType::Unknown: return "Unknown";
/* if you have it: */
case OX_AssetType::Invalid: return "Invalid";
default: return "Unknown";
}
}
template<typename T>
std::shared_ptr<T> AssetManager::GetAsset(uint64_t id)
{
static_assert(std::is_base_of_v<Asset, T>, "T must derive from Asset");
if (!id) return nullptr;
// Fast path: cached?
{
std::scoped_lock lk(s_AssetMutex);
auto it = s_RecordsById.find(id);
if (it != s_RecordsById.end() && it->second.asset) {
Touch_(it->second);
return std::dynamic_pointer_cast<T>(it->second.asset);
}
}
const OX_AssetType kind = GetAssetTypeById(id);
size_t approxBytes = 0;
std::shared_ptr<Asset> loaded;
if constexpr (std::is_same_v<T, Texture2D>) {
if (kind != OX_AssetType::Texture) return nullptr;
AssetMetadata meta{}; {
std::scoped_lock lk(s_AssetMutex);
auto mit = s_MetaById.find(id);
if (mit == s_MetaById.end()) return nullptr;
meta = mit->second;
}
loaded = LoadTextureNow_(meta, approxBytes);
if (!loaded) return nullptr;
} else if constexpr (std::is_same_v<T, ModelAsset>) {
if (kind != OX_AssetType::Model) return nullptr;
// Your MeshLoaderOBJ currently has: bool LoadById(uint64_t, std::shared_ptr<ModelAsset>&)
std::shared_ptr<ModelAsset> model = MeshLoaderOBJ::LoadById(id);
approxBytes = model->vertices.size() * sizeof(model->vertices[0])
+ model->indices.size() * sizeof(model->indices[0]);
loaded = model;
} else {
// Unknown T requested
return nullptr;
}
// Commit to cache
{
std::scoped_lock lk(s_AssetMutex);
auto &rec = s_RecordsById[id];
auto mit = s_MetaById.find(id);
rec.meta = (mit != s_MetaById.end()) ? mit->second : AssetMetadata{};
rec.asset = loaded;
rec.approxBytes = approxBytes;
Touch_(rec);
if constexpr (std::is_same_v<T, Texture2D>) {
s_LoadedTextures.fetch_add(1, std::memory_order_relaxed);
}
}
return std::dynamic_pointer_cast<T>(loaded);
}
template std::shared_ptr<Texture2D> AssetManager::GetAsset<Texture2D>(uint64_t);
template std::shared_ptr<ModelAsset> AssetManager::GetAsset<ModelAsset>(uint64_t);
} // namespace OX

View File

@@ -1,96 +1,180 @@
/// AssetManager.h
#pragma once
#include <string>
#include <memory>
#include <unordered_map>
#include <queue>
#include <mutex>
#include <filesystem>
#include <thread>
#include <atomic>
#include <vector>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "assets/Asset.h"
#include "assets/MeshLoaderObj.h"
#include "assets/Model.h"
#include "assets/Texture2D.h"
namespace OX
{
class Asset;
namespace fs = std::filesystem;
// ============================================================================
// Asset Types
// ============================================================================
enum class OX_AssetType : uint8_t
{
Unknown = 0,
Invalid,
Texture,
Model,
Font,
Sound,
_MAX,
};
// ============================================================================
// Drag-and-drop payload (for ImGui or other UI)
// ============================================================================
inline constexpr const char *OX_ASSET_DRAG_TYPE = "OX_AssetDrag";
struct OX_AssetDrag
{
uint64_t id = 0; // 64-bit asset id
OX_AssetType type = OX_AssetType::Unknown;
};
// ============================================================================
// Resource tree node (used by FileBrowser/UI)
// ============================================================================
struct ResourceTreeNode
{
std::string name;
std::string path;
std::string name; // leaf name
std::string path; // virtual path "res://..."
bool isDirectory = false;
std::vector<std::shared_ptr<ResourceTreeNode> > children;
};
// ============================================================================
// Asset metadata
// ============================================================================
struct AssetMetadata
{
OX_AssetType kind = OX_AssetType::Unknown;
std::string absolutePath; // native absolute path
};
// ============================================================================
// AssetManager
// ============================================================================
class AssetManager
{
public:
// -- Lifecycle --
// Startup / shutdown
static void Init(const std::string &projectRoot);
static void Shutdown();
static void Tick();
// Trigger a new scan of project root
static void Rescan();
static size_t GetTotalTexturesToLoad() { return s_TotalTexturesToLoad.load(); }
static size_t GetLoadedTexturesCount() { return s_LoadedTexturesCount.load(); }
// -- Queries --
// Lookup by virtual path ("res://…") or real FS path.
static std::shared_ptr<Asset> Get(const std::string &path);
// Virtual resource tree for UI
static std::shared_ptr<ResourceTreeNode> GetFileTree();
// Save a tiny index of assets for packaging
static void SaveAssetPack(const std::string &outputPath);
static std::mutex s_TreeMutex;
// ---- ID & info lookups ----
static uint64_t GetIdForPath(const std::string &keyOrPath); // accepts res:// or native path
static OX_AssetType GetAssetTypeById(uint64_t id);
static std::string GetAbsolutePathById(uint64_t id); // for external loaders (OBJ, etc.)
static std::string GetVirtualPathById(uint64_t id);
// ---- Lazy getters (only textures are loaded here) ----
static std::shared_ptr<Asset> GetAssetById(uint64_t id); // generic (currently only textures)
static std::shared_ptr<Texture2D> GetTexture(uint64_t id);
// ---- Compatibility helpers (so your current FileBrowser keeps building) ----
static std::shared_ptr<Asset> Get(const std::string &keyOrPath)
{
const uint64_t id = GetIdForPath(keyOrPath);
return id ? GetAssetById(id) : nullptr;
}
// Progress bar compatibility (textures only)
static size_t GetTotalTexturesToLoad(); // discovered textures
static size_t GetLoadedTexturesCount(); // currently loaded textures
// Utilities
static std::string MakeVirtualPath(const fs::path &full);
// Call each frame; runs GC, etc.
static void Tick();
static void SetGCSeconds(double seconds);
static const char *AssetTypeToString(OX_AssetType t);
// Exposed for FileBrowser code that locks it directly
static std::mutex s_TreeMutex; // NOTE: public for legacy UI usage
private:
struct AssetMetadata
struct AssetRecord
{
std::string type;
std::string absolutePath;
};
struct PendingTexture
{
std::string id;
int width, height, channels;
unsigned char *data;
AssetMetadata meta;
std::shared_ptr<Asset> asset; // null when unloaded
std::chrono::steady_clock::time_point lastAccess{};
size_t approxBytes = 0;
};
// Internal
static void BackgroundScan();
static void AddToTree(const std::string &virtualPath, bool isDir);
static void AddToTree(const std::string &resPath, bool isDir);
static std::string DetectAssetType(const std::filesystem::path &path);
static void RunGC_();
static std::string MakeVirtualPath(const std::filesystem::path &full);
static void Touch_(AssetRecord &rec);
// loaded assets & metadata
static std::unordered_map<std::string, std::string> s_PathToID;
static std::unordered_map<std::string, std::shared_ptr<Asset> > s_LoadedAssets;
static std::unordered_map<std::string, AssetMetadata> s_MetadataMap;
static std::mutex s_AssetMutex;
// Loading
static std::shared_ptr<Texture2D> LoadTextureNow_(const AssetMetadata &meta, size_t &outBytes);
// directory tree
// Helpers
static uint64_t MakeIdFromVirtualPath_(const std::string &vpath);
static OX_AssetType DetectAssetKind_(const fs::path &path);
private:
// Project root
static std::filesystem::path s_ProjectRoot;
static std::unordered_map<uint64_t, AssetRecord> s_RecordsById; // id -> record (payload if loaded)
static std::unordered_map<uint64_t, AssetMetadata> s_MetaById; // id -> metadata
static std::unordered_map<std::string, uint64_t> s_PathToId; // absolute -> id
static std::unordered_map<std::string, uint64_t> s_VPathToId; // "res://..." -> id
// Tree
static std::shared_ptr<ResourceTreeNode> s_FileTree;
// background scan
static std::filesystem::path s_ProjectRoot;
// Threading
static std::atomic<bool> s_Scanning;
static std::thread s_ScanThread;
// texture upload queue
static std::queue<PendingTexture> s_TextureQueue;
static std::mutex s_QueueMutex;
static std::mutex s_AssetMutex;
static std::atomic<size_t> s_TotalTexturesToLoad;
static std::atomic<size_t> s_LoadedTexturesCount;
// GC control
static std::atomic<double> s_GCSeconds;
// Texture progress counters (for UI bar)
static std::atomic<size_t> s_TotalTexturesDiscovered;
static std::atomic<size_t> s_LoadedTextures;
public:
template<typename T>
static std::shared_ptr<T> GetAsset(uint64_t id);
};
}
} // namespace OX

View File

@@ -2,16 +2,17 @@
#include "systems/Logger.h"
#include "systems/Scene/GameObject.h"
#include "systems/assets/MeshLoaderOBJ.h"
#include "systems/AssetManager.h"
#include <stb/stb_image.h>
#include <unordered_map>
#include <stb/stb_image.h>
#include <GL/glew.h>
namespace OX
{
// ---------- texture helper ----------
// ---------------------------------------------------------------------
// GLTexture helpers
// ---------------------------------------------------------------------
void GLTexture::Destroy()
{
if (id) {
@@ -22,92 +23,12 @@ namespace OX
path.clear();
}
static GLuint CreateTextureFromFile(const std::string &path,
int *outW = nullptr, int *outH = nullptr, int *outC = nullptr)
{
stbi_set_flip_vertically_on_load(true);
int w = 0, h = 0, c = 0;
unsigned char *data = stbi_load(path.c_str(), &w, &h, &c, 0);
if (!data) {
Logger::LogWarning("stb_image failed to load: %s", path.c_str());
return 0;
}
GLenum fmt = (c == 4 ? GL_RGBA : GL_RGB);
GLuint tex = 0;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, fmt, w, h, 0, fmt, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glBindTexture(GL_TEXTURE_2D, 0);
stbi_image_free(data);
if (outW) *outW = w;
if (outH) *outH = h;
if (outC) *outC = c;
return tex;
}
static void LoadTexturesFor(Material &m)
{
if (!m.mapKd.empty()) {
m.texKd.id = CreateTextureFromFile(m.mapKd, &m.texKd.width, &m.texKd.height, &m.texKd.channels);
m.texKd.path = m.mapKd;
}
if (!m.mapKs.empty()) {
m.texKs.id = CreateTextureFromFile(m.mapKs, &m.texKs.width, &m.texKs.height, &m.texKs.channels);
m.texKs.path = m.mapKs;
}
if (!m.mapNormal.empty()) {
m.texNormal.id = CreateTextureFromFile(m.mapNormal, &m.texNormal.width, &m.texNormal.height,
&m.texNormal.channels);
m.texNormal.path = m.mapNormal;
}
}
// Compact a single submesh into local arrays
static void BuildCompactedSubmesh(
const std::vector<Vertex> &globalVerts,
const std::vector<uint32_t> &globalIdx,
uint32_t indexOffset,
uint32_t indexCount,
std::vector<Vertex> &outVerts,
std::vector<uint32_t> &outIdx)
{
outVerts.clear();
outIdx.clear();
outVerts.reserve(indexCount);
outIdx.reserve(indexCount);
std::unordered_map<uint32_t, uint32_t> remap;
remap.reserve(indexCount);
for (uint32_t i = 0; i < indexCount; ++i) {
uint32_t oldIdx = globalIdx[indexOffset + i];
auto it = remap.find(oldIdx);
if (it == remap.end()) {
uint32_t newIdx = static_cast<uint32_t>(outVerts.size());
remap.emplace(oldIdx, newIdx);
outVerts.push_back(globalVerts[oldIdx]);
outIdx.push_back(newIdx);
} else {
outIdx.push_back(it->second);
}
}
}
// ---------- MeshComponent ----------
MeshComponent::MeshComponent(const std::string &objPath)
: m_sourcePath(objPath)
void GLTexture::SetFromGL(unsigned int id, int width, int height)
{
Destroy();
this->id = id;
this->width = width;
this->height = height;
}
MeshComponent::~MeshComponent()
@@ -115,67 +36,81 @@ namespace OX
Clear();
}
bool MeshComponent::LoadOBJ(const std::string &path)
// ---------------------------------------------------------------------
// New ID-based API
// ---------------------------------------------------------------------
bool MeshComponent::LoadModel(uint64_t assetId)
{
Clear();
MeshLoadResult result;
if (!MeshLoaderOBJ::Load(path, result)) {
Logger::LogError("OBJ load failed: %s", path.c_str());
auto model = AssetManager::GetAsset<ModelAsset>(assetId);
if (!model) {
Logger::LogError("MeshComponent: invalid asset ID %" PRIu64, assetId);
return false;
}
m_sourcePath = path;
m_vertices = std::move(result.vertices);
m_indices = std::move(result.indices);
m_submeshes = std::move(result.submeshes);
m_materials = std::move(result.materials);
m_loadedSubmeshIndex = -1; // full mesh
m_sourceAssetId = assetId;
// Upload textures for materials
for (auto &m: m_materials) {
LoadTexturesFor(m);
}
m_vertices = model->vertices;
m_indices = model->indices;
m_submeshes = model->submeshes;
m_materials = model->materials;
m_loadedSubmeshIndex = -1;
CreateGL();
return true;
}
bool MeshComponent::LoadOBJSubmesh(const std::string &path, int submeshIndex)
bool MeshComponent::LoadModelSubmesh(uint64_t assetId, int submeshIndex)
{
Clear();
MeshLoadResult result;
if (!MeshLoaderOBJ::Load(path, result)) {
Logger::LogError("OBJ load failed: %s", path.c_str());
return false;
}
if (submeshIndex < 0 || submeshIndex >= (int) result.submeshes.size()) {
Logger::LogError("LoadOBJSubmesh: index %d out of range [0,%zu) for %s",
submeshIndex, result.submeshes.size(), path.c_str());
auto model = AssetManager::GetAsset<ModelAsset>(assetId);
if (!model) {
Logger::LogError("MeshComponent: invalid asset ID %" PRIu64, assetId);
return false;
}
const auto &sm = result.submeshes[submeshIndex];
if (submeshIndex < 0 || submeshIndex >= (int) model->submeshes.size()) {
Logger::LogError("MeshComponent: submesh index %d out of range for asset %" PRIu64,
submeshIndex, assetId);
return false;
}
// compact
const auto &sm = model->submeshes[submeshIndex];
// Compact into local buffers
std::vector<Vertex> v;
std::vector<uint32_t> i;
BuildCompactedSubmesh(result.vertices, result.indices, sm.indexOffset, sm.indexCount, v, i);
v.reserve(sm.indexCount);
i.reserve(sm.indexCount);
// set component data to "single submesh" layout
m_sourcePath = path;
std::unordered_map<uint32_t, uint32_t> remap;
remap.reserve(sm.indexCount);
for (uint32_t j = 0; j < sm.indexCount; ++j) {
uint32_t oldIdx = model->indices[sm.indexOffset + j];
auto it = remap.find(oldIdx);
if (it == remap.end()) {
uint32_t newIdx = static_cast<uint32_t>(v.size());
remap.emplace(oldIdx, newIdx);
v.push_back(model->vertices[oldIdx]);
i.push_back(newIdx);
} else {
i.push_back(it->second);
}
}
// Assign to component
m_sourceAssetId = assetId;
m_vertices = std::move(v);
m_indices = std::move(i);
m_materials.clear();
m_materials.reserve(1);
Material mat{};
if (sm.materialIndex >= 0 && sm.materialIndex < (int) result.materials.size()) {
mat = result.materials[sm.materialIndex];
}
LoadTexturesFor(mat);
m_materials.push_back(std::move(mat));
m_materials.clear();
if (sm.materialIndex >= 0 && sm.materialIndex < (int) model->materials.size())
m_materials.push_back(model->materials[sm.materialIndex]);
else
m_materials.emplace_back(Material{});
m_submeshes.clear();
SubMesh one{};
@@ -190,10 +125,124 @@ namespace OX
return true;
}
bool MeshComponent::ImportModel(GameObject *parent, uint64_t assetId, MeshImportMode mode)
{
if (!parent) return false;
auto model = AssetManager::GetAsset<ModelAsset>(assetId);
if (!model) {
Logger::LogError("MeshComponent: invalid asset ID %" PRIu64, assetId);
return false;
}
if (mode == MeshImportMode::SingleComponent) {
auto *mc = parent->AddComponent<MeshComponent>();
if (!mc) return false;
return mc->LoadModel(assetId);
}
if (model->submeshes.empty()) {
Logger::LogError("MeshComponent: no submeshes in asset %" PRIu64, assetId);
return false;
}
bool any = false;
for (size_t i = 0; i < model->submeshes.size(); ++i) {
std::string name = "Submesh_" + std::to_string(i);
const auto &sm = model->submeshes[i];
if (sm.materialIndex >= 0 && sm.materialIndex < (int) model->materials.size()) {
const auto &mat = model->materials[sm.materialIndex];
if (!mat.name.empty())
name += " [" + mat.name + "]";
}
GameObject *child = parent->createChild(name);
if (!child) {
Logger::LogError("MeshComponent: failed to create child for submesh %zu", i);
continue;
}
auto *mc = child->AddComponent<MeshComponent>();
if (!mc) {
Logger::LogError("MeshComponent: failed to add MeshComponent to child %s", name.c_str());
continue;
}
if (!mc->LoadModelSubmesh(assetId, static_cast<int>(i))) {
Logger::LogError("MeshComponent: failed to load submesh %zu from asset %" PRIu64, i, assetId);
continue;
}
any = true;
}
return any;
}
// ---------------------------------------------------------------------
// Path wrappers (legacy)
// ---------------------------------------------------------------------
bool MeshComponent::LoadOBJ(const std::string &path)
{
uint64_t id = AssetManager::GetIdForPath(path);
return LoadModel(id);
}
bool MeshComponent::LoadOBJSubmesh(const std::string &path, int submeshIndex)
{
uint64_t id = AssetManager::GetIdForPath(path);
return LoadModelSubmesh(id, submeshIndex);
}
bool MeshComponent::ImportOBJ(GameObject *parent, const std::string &path, MeshImportMode mode)
{
uint64_t id = AssetManager::GetIdForPath(path);
return ImportModel(parent, id, mode);
}
// ---------------------------------------------------------------------
// Component interface
// ---------------------------------------------------------------------
void MeshComponent::OnAttach(GameObject * /*owner*/)
{
}
void MeshComponent::OnDetach() { Clear(); }
YAML::Node MeshComponent::Serialize() const
{
YAML::Node n;
n["type"] = "MeshComponent";
n["asset_id"] = m_sourceAssetId;
if (m_loadedSubmeshIndex >= 0)
n["submesh"] = m_loadedSubmeshIndex;
return n;
}
void MeshComponent::Deserialize(const YAML::Node &node)
{
Clear();
if (node["asset_id"]) {
const uint64_t id = node["asset_id"].as<uint64_t>();
if (node["submesh"])
(void) LoadModelSubmesh(id, node["submesh"].as<int>());
else
(void) LoadModel(id);
} else if (node["path"]) {
const std::string path = node["path"].as<std::string>();
if (node["submesh"])
(void) LoadOBJSubmesh(path, node["submesh"].as<int>());
else
(void) LoadOBJ(path);
}
}
// ---------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------
void MeshComponent::Clear()
{
DestroyGL();
for (auto &m: m_materials) {
m.texKd.Destroy();
m.texKs.Destroy();
@@ -203,7 +252,7 @@ namespace OX
m_indices.clear();
m_submeshes.clear();
m_materials.clear();
m_sourcePath.clear();
m_sourceAssetId = 0;
m_loadedSubmeshIndex = -1;
}
@@ -223,7 +272,6 @@ namespace OX
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, m_indices.size() * sizeof(uint32_t), m_indices.data(), GL_STATIC_DRAW);
// layout: pos(0), norm(1), uv(2), tan(3), bitan(4)
GLsizei stride = sizeof(Vertex);
std::size_t offs = 0;
@@ -264,93 +312,4 @@ namespace OX
m_vao = 0;
}
}
void MeshComponent::OnAttach(GameObject * /*owner*/)
{
}
void MeshComponent::OnDetach() { Clear(); }
YAML::Node MeshComponent::Serialize() const
{
YAML::Node n;
n["type"] = "MeshComponent";
n["path"] = m_sourcePath;
if (m_loadedSubmeshIndex >= 0)
n["submesh"] = m_loadedSubmeshIndex; // indicates this component is single-submesh
return n;
}
void MeshComponent::Deserialize(const YAML::Node &node)
{
Clear();
if (!node["path"]) return;
const std::string p = node["path"].as<std::string>();
if (node["submesh"]) {
const int sm = node["submesh"].as<int>();
(void) LoadOBJSubmesh(p, sm);
} else {
(void) LoadOBJ(p);
}
}
// ---------- Static importer ----------
bool MeshComponent::ImportOBJ(GameObject *parent,
const std::string &path,
MeshImportMode mode)
{
if (!parent) return false;
if (mode == MeshImportMode::SingleComponent) {
auto *mc = parent->AddComponent<MeshComponent>();
if (!mc) return false;
return mc->LoadOBJ(path);
}
// ChildrenPerSubmesh
MeshLoadResult result;
if (!MeshLoaderOBJ::Load(path, result)) {
Logger::LogError("ImportOBJ: OBJ load failed: %s", path.c_str());
return false;
}
if (result.submeshes.empty()) {
Logger::LogError("ImportOBJ: no submeshes in %s", path.c_str());
return false;
}
bool any = false;
for (size_t i = 0; i < result.submeshes.size(); ++i) {
// Create a child GO named "Submesh_i [MatName]"
std::string name = "Submesh_" + std::to_string(i);
const auto &sm = result.submeshes[i];
if (sm.materialIndex >= 0 && sm.materialIndex < (int) result.materials.size()) {
const auto &mat = result.materials[sm.materialIndex];
if (!mat.name.empty()) name += " [" + mat.name + "]";
}
// If your API differs, change this:
GameObject *child = parent->createChild(name);
if (!child) {
Logger::LogError("ImportOBJ: failed to create child for submesh %zu", i);
continue;
}
auto *mc = child->AddComponent<MeshComponent>();
if (!mc) {
Logger::LogError("ImportOBJ: failed to add MeshComponent to child %s", name.c_str());
continue;
}
if (!mc->LoadOBJSubmesh(path, static_cast<int>(i))) {
Logger::LogError("ImportOBJ: LoadOBJSubmesh failed for %s submesh %zu", path.c_str(), i);
// Optionally remove child if failed
continue;
}
any = true;
}
return any;
}
} // namespace OX

View File

@@ -1,84 +1,47 @@
#pragma once
#include "Component.h"
#include <string>
#include <vector>
#include <array>
#include <yaml-cpp/yaml.h>
#include <memory>
#include <cstdint>
#include <yaml-cpp/yaml.h>
#include "systems/Scene/Components/Component.h"
#include "systems/assets/Asset.h"
#include "systems/assets/Model.h"
#include "systems/assets/MeshLoaderOBJ.h"
namespace OX
{
struct GLTexture
{
unsigned int id = 0;
int width = 0;
int height = 0;
int channels = 0;
std::string path;
void Destroy();
};
struct Material
{
std::string name;
std::array<float, 3> kd{1, 1, 1}; // diffuse
std::array<float, 3> ks{0, 0, 0}; // specular
float ns = 16.0f; // shininess
std::string mapKd; // diffuse map path
std::string mapKs; // specular map path
std::string mapNormal; // normal map path (bump/normal)
GLTexture texKd;
GLTexture texKs;
GLTexture texNormal;
};
struct Vertex
{
float px, py, pz; // position
float nx, ny, nz; // normal
float u, v; // uv
float tx, ty, tz; // tangent
float bx, by, bz; // bitangent
};
struct SubMesh
{
uint32_t indexOffset = 0;
uint32_t indexCount = 0;
int materialIndex = -1;
};
enum class MeshImportMode
{
SingleComponent, // one MeshComponent with submeshes (your current default)
ChildrenPerSubmesh // create child GOs, each with a single-submesh MeshComponent
SingleComponent,
ChildrenPerSubmesh
};
class MeshComponent : public Component
{
public:
MeshComponent() = default;
explicit MeshComponent(const std::string &objPath);
~MeshComponent() override;
~MeshComponent();
// Normal: load full mesh with submeshes/materials
// === New Asset-ID based API ===
bool LoadModel(uint64_t assetId);
bool LoadModelSubmesh(uint64_t assetId, int submeshIndex);
static bool ImportModel(GameObject *parent, uint64_t assetId, MeshImportMode mode);
// === Path-based API (legacy wrappers) ===
bool LoadOBJ(const std::string &path);
// Load ONLY the specified submesh into this component (compacted, single material)
bool LoadOBJSubmesh(const std::string &path, int submeshIndex);
void Clear(); // releases GPU and CPU
static bool ImportOBJ(GameObject *parent, const std::string &path, MeshImportMode mode);
// Static importer helpers
static bool ImportOBJ(class GameObject *parent,
const std::string &path,
MeshImportMode mode);
// GPU info
unsigned int vao() const { return m_vao; }
@@ -90,10 +53,11 @@ namespace OX
const std::vector<Material> &materials() const { return m_materials; }
const std::vector<uint32_t> &indices() const { return m_indices; }
const std::vector<Vertex> &vertices() const { return m_vertices; }
const std::string &sourcePath() const { return m_sourcePath; }
const uint64_t &sourceAssetId() const { return m_sourceAssetId; }
// Component API
void OnAttach(class GameObject *owner) override;
// === Component overrides ===
void OnAttach(GameObject *owner) override;
void OnDetach() override;
@@ -102,25 +66,21 @@ namespace OX
void Deserialize(const YAML::Node &node) override;
private:
// === Core helpers ===
void Clear();
void CreateGL();
void DestroyGL();
private:
std::string m_sourcePath;
// CPU
// === Fields ===
uint64_t m_sourceAssetId = 0; // ID of the model asset used
std::vector<Vertex> m_vertices;
std::vector<uint32_t> m_indices;
std::vector<SubMesh> m_submeshes;
std::vector<Material> m_materials;
int m_loadedSubmeshIndex = -1;
// GPU
unsigned int m_vao = 0;
unsigned int m_vbo = 0;
unsigned int m_ebo = 0;
// For importer mode and serialization when this represents a single submesh
int m_loadedSubmeshIndex = -1; // -1 = full mesh; >=0 = single-submesh mode
unsigned int m_vao = 0, m_vbo = 0, m_ebo = 0;
};
} // namespace OX

View File

@@ -0,0 +1,8 @@
//
// Created by spenc on 5/21/2025.
//
#include "Asset.h"
namespace OX {
} // OX

View File

@@ -0,0 +1,20 @@
//
// Created by spenc on 5/21/2025.
//
#ifndef ASSET_H
#define ASSET_H
#include "systems/Logger.h"
namespace OX
{
class Asset
{
public:
virtual ~Asset() = default;
virtual std::string GetTypeName() const = 0;
};
} // OX
#endif //ASSET_H

View File

@@ -1,5 +1,6 @@
#include "MeshLoaderOBJ.h"
#include "systems/Logger.h"
#include "systems/AssetManager.h"
#include <fstream>
#include <sstream>
@@ -45,14 +46,12 @@ namespace OX
return out;
}
// Like split_ws, but preserves \"quoted strings\" as one token and allows backslash-escaped spaces
// Like split_ws, but preserves "quoted strings" and backslash-escaped spaces
static std::vector<std::string> split_ws_escaped(const std::string &s)
{
std::vector<std::string> out;
std::string cur;
bool in_quotes = false;
bool esc = false;
bool in_quotes = false, esc = false;
auto flush = [&]()
{
if (!cur.empty()) {
@@ -60,21 +59,12 @@ namespace OX
cur.clear();
}
};
for (char c: s) {
if (esc) {
// keep char literally
cur.push_back(c);
esc = false;
} else if (c == '\\') {
esc = true;
} else if (c == '"') {
in_quotes = !in_quotes;
} else if (std::isspace((unsigned char) c) && !in_quotes) {
flush();
} else {
cur.push_back(c);
}
} else if (c == '\\') { esc = true; } else if (c == '"') { in_quotes = !in_quotes; } else if (
std::isspace((unsigned char) c) && !in_quotes) { flush(); } else cur.push_back(c);
}
flush();
return out;
@@ -82,15 +72,14 @@ namespace OX
static std::string unescape_backslashes(std::string s)
{
// turn "\ " -> " ", "\\\"" -> "\"", "\\\\" -> "\\"
std::string out;
out.reserve(s.size());
bool esc = false;
for (char c: s) {
if (!esc) {
if (c == '\\') { esc = true; } else out.push_back(c);
if (c == '\\') esc = true;
else out.push_back(c);
} else {
// consume the escape; keep the char as-is
out.push_back(c);
esc = false;
}
@@ -114,13 +103,6 @@ namespace OX
}
// ---------- math helpers ----------
static inline void v_add(float a[3], const float b[3])
{
a[0] += b[0];
a[1] += b[1];
a[2] += b[2];
}
static inline void v_sub(const float a[3], const float b[3], float r[3])
{
r[0] = a[0] - b[0];
@@ -150,34 +132,21 @@ namespace OX
// ---------- index fixing (OBJ supports negative indexes) ----------
static int fix_index(int idx, int count)
{
// input: 1-based or negative; output: 0-based
if (idx > 0) return idx - 1;
if (idx < 0) return count + idx;
return -1;
}
// ---------- Wavefront map_* tail parser ----------
// Extracts the filename from a "map_* ..." line, supporting:
// - Quoted filenames: map_Kd "C:\path with spaces\albedo.png"
// - Backslash-escaped spaces: map_Kd C:\path\with\spaces\albedo\ file.png (as "file.png" with escaped space)
// - Options like: -bm, -o/-s/-t (3 args), -mm (2), -clamp, -imfchan, -type, etc.
static std::string extract_map_path_from_line(const std::string &line, const char *key)
{
// Find the key at the start (after trimming leading spaces)
size_t start = 0;
while (start < line.size() && std::isspace((unsigned char) line[start])) ++start;
// Must begin with key
const size_t key_len = std::strlen(key);
if (line.compare(start, key_len, key) != 0) return {};
size_t pos = start + key_len;
// Skip spaces after key
while (pos < line.size() && std::isspace((unsigned char) line[pos])) ++pos;
if (pos >= line.size()) return {};
// If quoted, take quoted content
if (line[pos] == '"') {
++pos;
std::string out;
@@ -187,127 +156,38 @@ namespace OX
if (esc) {
out.push_back(c);
esc = false;
} else if (c == '\\') {
esc = true;
} else if (c == '"') {
} else if (c == '\\') { esc = true; } else if (c == '"') {
++pos;
break;
} else {
out.push_back(c);
}
} else out.push_back(c);
}
return out;
}
// Otherwise, split with quotes/escapes respected
std::string tail = line.substr(pos);
auto toks = split_ws_escaped(tail);
if (toks.empty()) return {};
// Consume known options and their parameters, per MTL spec
// Each "consume(N)" eats N following tokens (if present)
auto consume = [&](size_t &i, int n)
{
while (n-- > 0 && i < toks.size()) ++i;
};
auto consume = [&](size_t &i, int n) { while (n-- > 0 && i < toks.size()) ++i; };
size_t i = 0;
while (i < toks.size()) {
const std::string &t = toks[i];
if (t == "-blendu" || t == "-blendv" || t == "-cc" || t == "-clamp" || t == "-texopt") {
// on/off or flags
consume(i, 1); // value
} else if (t == "-bm" || t == "-boost" || t == "-texres" || t == "-type" || t == "-imfchan") {
consume(i, 1);
} else if (t == "-mm") {
consume(i, 1 + 1); // base, gain
} else if (t == "-o" || t == "-s" || t == "-t") {
// up to 3 values; many files give 1 or 3 — consume up to 3 if present
if (t == "-blendu" || t == "-blendv" || t == "-cc" || t == "-clamp" || t ==
"-texopt") { consume(i, 1); } else if (
t == "-bm" || t == "-boost" || t == "-texres" || t == "-type" || t ==
"-imfchan") { consume(i, 1); } else if (t == "-mm") { consume(i, 2); } else if (
t == "-o" || t == "-s" || t == "-t") {
size_t take = std::min<size_t>(3, toks.size() - (i + 1));
consume(i, 1 + (int) take);
} else if (!t.empty() && t[0] == '-') {
// Unknown option: conservatively consume next 1 value if present
consume(i, 1 + (i + 1 < toks.size() ? 1 : 0));
} else {
// First non-option token begins the filename; everything remaining is part of the path
break;
}
} else if (!t.empty() && t[0] == '-') { consume(i, 1 + (i + 1 < toks.size() ? 1 : 0)); } else break;
}
if (i >= toks.size()) return {};
// Join the remaining tokens with spaces (rebuild paths like "C:\foo bar\baz.png")
std::string joined = toks[i];
for (size_t j = i + 1; j < toks.size(); ++j) {
joined.push_back(' ');
joined += toks[j];
}
// Unescape any backslash-escapes used to keep spaces
return unescape_backslashes(joined);
}
// ---------- MTL parsing ----------
static void parse_mtl(const std::string &mtlFilePath,
std::vector<Material> &outMaterials,
std::unordered_map<std::string, int> &nameToIndex)
{
std::ifstream f(mtlFilePath);
if (!f) {
Logger::LogWarning("OBJ: failed to open MTL: %s", mtlFilePath.c_str());
return;
}
std::string base = basedir(mtlFilePath);
Material cur;
bool haveCur = false;
auto commit = [&]()
{
if (!haveCur) return;
nameToIndex[cur.name] = (int) outMaterials.size();
outMaterials.push_back(cur);
};
std::string line;
while (std::getline(f, line)) {
std::string raw = line; // keep raw for map_* parsing
trim(line);
if (line.empty() || line[0] == '#') continue;
auto toks = split_ws(line);
if (toks.empty()) continue;
const std::string &key = toks[0];
if (key == "newmtl") {
commit();
cur = Material{};
haveCur = true;
// For newmtl, keep everything after the keyword to preserve names with spaces
std::string name = raw.substr(raw.find("newmtl") + 6);
trim(name);
cur.name = name;
} else if (key == "Kd" && toks.size() >= 4) {
cur.kd = {std::stof(toks[1]), std::stof(toks[2]), std::stof(toks[3])};
} else if (key == "Ks" && toks.size() >= 4) {
cur.ks = {std::stof(toks[1]), std::stof(toks[2]), std::stof(toks[3])};
} else if (key == "Ns" && toks.size() >= 2) {
cur.ns = std::stof(toks[1]);
} else if (key == "map_Kd" || key == "map_kd") {
std::string path = extract_map_path_from_line(raw, key.c_str());
if (!path.empty()) cur.mapKd = join_path(base, path);
} else if (key == "map_Ks" || key == "map_ks") {
std::string path = extract_map_path_from_line(raw, key.c_str());
if (!path.empty()) cur.mapKs = join_path(base, path);
} else if (key == "map_Bump" || key == "map_bump" || key == "bump" || key == "norm" || key ==
"map_normal") {
std::string path = extract_map_path_from_line(raw, key.c_str());
if (!path.empty()) cur.mapNormal = join_path(base, path);
}
// ignore d/Tr/illum/etc for now
}
commit();
}
// ---------- tangent generation ----------
static void compute_tangents(std::vector<Vertex> &verts, const std::vector<uint32_t> &idx)
{
@@ -316,9 +196,7 @@ namespace OX
for (size_t i = 0; i + 2 < idx.size(); i += 3) {
uint32_t i0 = idx[i], i1 = idx[i + 1], i2 = idx[i + 2];
const Vertex &v0 = verts[i0];
const Vertex &v1 = verts[i1];
const Vertex &v2 = verts[i2];
const Vertex &v0 = verts[i0], &v1 = verts[i1], &v2 = verts[i2];
float x1 = v1.px - v0.px, y1 = v1.py - v0.py, z1 = v1.pz - v0.pz;
float x2 = v2.px - v0.px, y2 = v2.py - v0.py, z2 = v2.pz - v0.pz;
@@ -327,8 +205,7 @@ namespace OX
float s2 = v2.u - v0.u, t2 = v2.v - v0.v;
float r = (s1 * t2 - s2 * t1);
if (std::fabs(r) < 1e-12f) r = 1.0f;
else r = 1.0f / r;
r = (std::fabs(r) < 1e-12f) ? 1.0f : 1.0f / r;
float tx = (t2 * x1 - t1 * x2) * r;
float ty = (t2 * y1 - t1 * y2) * r;
@@ -360,29 +237,27 @@ namespace OX
}
for (size_t i = 0; i < verts.size(); ++i) {
// Gram-Schmidt orthonormalization
float n[3] = {verts[i].nx, verts[i].ny, verts[i].nz};
float t[3] = {tan[i][0], tan[i][1], tan[i][2]};
float b[3] = {bit[i][0], bit[i][1], bit[i][2]};
// T = normalize(T - N * dot(N,T))
float ndott = v_dot(n, t);
float ndott = n[0] * t[0] + n[1] * t[1] + n[2] * t[2];
float T[3] = {t[0] - n[0] * ndott, t[1] - n[1] * ndott, t[2] - n[2] * ndott};
v_norm(T);
float Bl[3] = {b[0], b[1], b[2]};
// B normalized (were not deriving handedness here)
v_norm(b);
v_norm(T);
v_norm(Bl);
verts[i].tx = T[0];
verts[i].ty = T[1];
verts[i].tz = T[2];
verts[i].bx = b[0];
verts[i].by = b[1];
verts[i].bz = b[2];
verts[i].bx = Bl[0];
verts[i].by = Bl[1];
verts[i].bz = Bl[2];
}
}
// ---------- normal generation (if some normals missing) ----------
// ---------- normal generation ----------
static void compute_smooth_normals(std::vector<Vertex> &verts, const std::vector<uint32_t> &idx)
{
std::vector<std::array<float, 3> > acc(verts.size(), {0, 0, 0});
@@ -394,7 +269,7 @@ namespace OX
float e1[3], e2[3], n[3];
v_sub(p1, p0, e1);
v_sub(p2, p0, e2);
v_cross(e1, e2, n); // not normalized (area-weighted)
v_cross(e1, e2, n);
acc[i0][0] += n[0];
acc[i0][1] += n[1];
acc[i0][2] += n[2];
@@ -414,30 +289,10 @@ namespace OX
}
}
// ---------- OBJ parsing ----------
struct VKey
{
int v = -1, vt = -1, vn = -1, mat = -1;
bool operator==(const VKey &o) const noexcept
{
return v == o.v && vt == o.vt && vn == o.vn && mat == o.mat;
}
};
struct VKeyHash
{
size_t operator()(const VKey &k) const noexcept
{
return ((size_t) k.v * 73856093) ^ ((size_t) k.vt * 19349663) ^ ((size_t) k.vn * 83492791) ^ (size_t) (
k.mat + 1);
}
};
// ---------- OBJ index tuple parsing ----------
static bool parse_vertex_triplet(const std::string &tok, int vCount, int vtCount, int vnCount, int &v, int &vt,
int &vn)
{
// formats: v, v/vt, v//vn, v/vt/vn (OBJ indices are 1-based, negatives allowed)
v = vt = vn = -1;
size_t p1 = tok.find('/');
if (p1 == std::string::npos) {
@@ -450,14 +305,12 @@ namespace OX
v = fix_index(vi, vCount);
if (p2 == std::string::npos) {
// v/vt
std::string vtstr = tok.substr(p1 + 1);
if (!vtstr.empty()) {
int vti = std::stoi(vtstr);
vt = fix_index(vti, vtCount);
}
} else {
// v//vn or v/vt/vn
std::string vtstr = tok.substr(p1 + 1, p2 - (p1 + 1));
std::string vnstr = tok.substr(p2 + 1);
if (!vtstr.empty()) {
@@ -472,16 +325,101 @@ namespace OX
return v >= 0;
}
bool MeshLoaderOBJ::Load(const std::string &objPath, MeshLoadResult &out)
// ---------- MTL parsing (loads textures via AssetManager) ----------
static void parse_mtl_and_load_textures(const std::string &mtlFilePath,
std::vector<Material> &outMaterials,
std::unordered_map<std::string, int> &nameToIndex)
{
out = {}; // reset
std::ifstream f(mtlFilePath);
if (!f) {
Logger::LogWarning("OBJ: failed to open MTL: %s", mtlFilePath.c_str());
return;
}
std::string base = basedir(mtlFilePath);
Material cur;
bool haveCur = false;
auto commit = [&]()
{
if (!haveCur) return;
nameToIndex[cur.name] = (int) outMaterials.size();
outMaterials.push_back(cur);
};
std::string line;
while (std::getline(f, line)) {
std::string raw = line;
trim(line);
if (line.empty() || line[0] == '#') continue;
auto toks = split_ws(line);
if (toks.empty()) continue;
const std::string &key = toks[0];
if (key == "newmtl") {
commit();
cur = Material{};
haveCur = true;
std::string name = raw.substr(raw.find("newmtl") + 6);
trim(name);
cur.name = name;
} else if (key == "Kd" && toks.size() >= 4) {
cur.kd = {std::stof(toks[1]), std::stof(toks[2]), std::stof(toks[3])};
} else if (key == "Ks" && toks.size() >= 4) {
cur.ks = {std::stof(toks[1]), std::stof(toks[2]), std::stof(toks[3])};
} else if (key == "Ns" && toks.size() >= 2) {
cur.ns = std::stof(toks[1]);
} else if (key == "map_Kd" || key == "map_kd") {
std::string path = extract_map_path_from_line(raw, key.c_str());
if (!path.empty()) cur.mapKd = join_path(base, path);
} else if (key == "map_Ks" || key == "map_ks") {
std::string path = extract_map_path_from_line(raw, key.c_str());
if (!path.empty()) cur.mapKs = join_path(base, path);
} else if (key == "map_Bump" || key == "map_bump" || key == "bump" || key == "norm" || key ==
"map_normal") {
std::string path = extract_map_path_from_line(raw, key.c_str());
if (!path.empty()) cur.mapNormal = join_path(base, path);
}
// ignore other props (illum/d/Tr/etc)
}
commit();
// Todo: Get Textures from asset Manager
// ---- Resolve textures via AssetManager (paths may be absolute or virtual) ----
auto load_tex_into = [](GLTexture &dst, const std::string &path)
{
if (path.empty()) return;
const uint64_t tid = AssetManager::GetIdForPath(path);
if (!tid) return;
auto tex = AssetManager::GetTexture(tid);
if (!tex) return;
if constexpr (true) {
dst.SetFromGL(tex->GetID(), tex->GetWidth(), tex->GetHeight());
}
};
for (auto &m: outMaterials) {
load_tex_into(m.texKd, m.mapKd);
load_tex_into(m.texKs, m.mapKs);
load_tex_into(m.texNormal, m.mapNormal);
}
}
// --- internals ---
static bool load_obj_file(const std::string &objPath, std::shared_ptr<OX::ModelAsset> &out)
{
std::ifstream f(objPath);
if (!f) {
Logger::LogError("OBJ: failed to open %s", objPath.c_str());
OX::Logger::LogError("OBJ: failed to open %s", objPath.c_str());
return false;
}
// Construct target BEFORE any lambda uses it
if (!out) out = std::make_shared<OX::ModelAsset>();
out->clear();
const std::string base = basedir(objPath);
// temp arrays for raw indices
@@ -492,23 +430,41 @@ namespace OX
std::vector<std::array<float, 2> > T;
T.reserve(1024);
std::vector<Material> materials;
std::vector<OX::Material> materials;
std::unordered_map<std::string, int> mtlNameToIndex;
// final mesh building
struct VKey
{
int v = -1, vt = -1, vn = -1, mat = -1;
bool operator==(const VKey &o) const noexcept
{
return v == o.v && vt == o.vt && vn == o.vn && mat == o.mat;
}
};
struct VKeyHash
{
size_t operator()(const VKey &k) const noexcept
{
return ((size_t) k.v * 73856093) ^ ((size_t) k.vt * 19349663)
^ ((size_t) k.vn * 83492791) ^ (size_t) (k.mat + 1);
}
};
std::unordered_map<VKey, uint32_t, VKeyHash> dedup;
std::vector<Vertex> verts;
std::vector<OX::Vertex> verts;
std::vector<uint32_t> indices;
// submesh splitting by material
int currentMat = -1;
bool haveCurrentSubmesh = false;
SubMesh curSub{0, 0, -1};
OX::SubMesh curSub{0, 0, -1};
auto flushSubmesh = [&]()
{
if (haveCurrentSubmesh) {
out.submeshes.push_back(curSub);
out->submeshes.push_back(curSub);
haveCurrentSubmesh = false;
}
};
@@ -526,7 +482,7 @@ namespace OX
std::string line;
while (std::getline(f, line)) {
std::string raw = line; // keep raw for mtllib join if needed
std::string raw = line;
trim(line);
if (line.empty() || line[0] == '#') continue;
@@ -538,14 +494,12 @@ namespace OX
if (key == "v" && toks.size() >= 4) {
P.push_back({std::stof(toks[1]), std::stof(toks[2]), std::stof(toks[3])});
} else if (key == "vt" && toks.size() >= 3) {
// allow optional third component and ignore
T.push_back({std::stof(toks[1]), std::stof(toks[2])});
} else if (key == "vn" && toks.size() >= 4) {
N.push_back({std::stof(toks[1]), std::stof(toks[2]), std::stof(toks[3])});
} else if (key == "f" && toks.size() >= 4) {
beginSubmeshIfNeeded();
// fan-triangulate: f v0 v1 v2 v3 -> (v0,v1,v2),(v0,v2,v3)...
int v0 = -1, vt0 = -1, vn0 = -1;
if (!parse_vertex_triplet(toks[1], (int) P.size(), (int) T.size(), (int) N.size(), v0, vt0, vn0))
continue;
@@ -559,15 +513,13 @@ namespace OX
continue;
int idxs[3][3] = {{v0, vt0, vn0}, {v1, vt1, vn1}, {v2, vt2, vn2}};
for (int k = 0; k < 3; ++k) {
VKey keyv{idxs[k][0], idxs[k][1], idxs[k][2], currentMat};
auto it = dedup.find(keyv);
if (it != dedup.end()) {
indices.push_back(it->second);
} else {
Vertex V{};
OX::Vertex V{};
const auto &p = P[keyv.v];
V.px = p[0];
V.py = p[1];
@@ -579,103 +531,127 @@ namespace OX
V.ny = n[1];
V.nz = n[2];
} else {
V.nx = V.ny = 0;
V.nx = 0;
V.ny = 0;
V.nz = 1;
}
if (keyv.vt >= 0) {
const auto &t = T[keyv.vt];
V.u = t[0];
V.v = t[1];
} else { V.u = V.v = 0; }
} else {
V.u = 0;
V.v = 0;
}
V.tx = V.ty = V.tz = 0;
V.bx = V.by = V.bz = 0;
uint32_t newIndex = (uint32_t) verts.size();
uint32_t newIdx = (uint32_t) verts.size();
verts.push_back(V);
dedup.emplace(keyv, newIndex);
indices.push_back(newIndex);
dedup.emplace(keyv, newIdx);
indices.push_back(newIdx);
}
}
curSub.indexCount += 3;
}
} else if (key == "usemtl") {
// Keep full material name (can have spaces) by slicing raw
size_t at = raw.find("usemtl");
std::string mname = (at == std::string::npos)
? toks[1]
? (toks.size() >= 2 ? toks[1] : std::string())
: raw.substr(at + 6);
trim(mname);
if (mname.empty() && toks.size() >= 2) mname = toks[1];
auto it = mtlNameToIndex.find(mname);
if (it == mtlNameToIndex.end()) {
Material m;
OX::Material m;
m.name = mname;
mtlNameToIndex[mname] = (int) materials.size();
materials.push_back(m);
}
currentMat = mtlNameToIndex[mname];
} else if (key == "mtllib") {
// Preserve spaces in library filename(s)
size_t at = raw.find("mtllib");
std::string mtlFile = (at == std::string::npos) ? std::string() : raw.substr(at + 6);
trim(mtlFile);
if (mtlFile.empty() && toks.size() >= 2) {
// fallback: join tokens with spaces
for (size_t i = 1; i < toks.size(); ++i) {
if (i > 1) mtlFile += " ";
mtlFile += toks[i];
}
}
std::string mtlPath = join_path(base, mtlFile);
parse_mtl(mtlPath, materials, mtlNameToIndex);
const std::string mtlPath = join_path(base, mtlFile);
parse_mtl_and_load_textures(mtlPath, materials, mtlNameToIndex);
}
// ignore o/g/s/others
}
flushSubmesh();
// transfer results
out.vertices = std::move(verts);
out.indices = std::move(indices);
out.submeshes = std::move(std::vector<SubMesh>{}); // will rebuild below
out.materials = std::move(materials);
// Move parsed data into the already-constructed asset
out->vertices = std::move(verts);
out->indices = std::move(indices);
out->materials = std::move(materials);
// Rebuild submeshes with contiguous ranges per material (already done above but guard if no faces parsed)
if (curSub.indexCount > 0) {
// already flushed
} else if (!out.indices.empty()) {
// single submesh default
SubMesh s;
if (out->submeshes.empty() && !out->indices.empty()) {
OX::SubMesh s;
s.indexOffset = 0;
s.indexCount = (uint32_t) out.indices.size();
s.indexCount = (uint32_t) out->indices.size();
s.materialIndex = currentMat;
out.submeshes.push_back(s);
out->submeshes.push_back(s);
}
// If any vertex had 0,0,1 as “no normal” marker, compute smooth normals
// normals if needed
bool needNormals = false;
for (const auto &v: out.vertices) {
for (const auto &v: out->vertices) {
if (v.nx == 0 && v.ny == 0 && v.nz == 1) {
needNormals = true;
break;
}
}
if (needNormals) compute_smooth_normals(out.vertices, out.indices);
if (needNormals) compute_smooth_normals(out->vertices, out->indices);
// Tangents if we have UVs
// tangents if we have UVs
bool hasUV = false;
for (const auto &v: out.vertices) {
for (const auto &v: out->vertices) {
if (v.u != 0 || v.v != 0) {
hasUV = true;
break;
}
}
if (hasUV) compute_tangents(out.vertices, out.indices);
if (hasUV) compute_tangents(out->vertices, out->indices);
return true;
}
// --- public API ---
std::shared_ptr<OX::ModelAsset>
OX::MeshLoaderOBJ::LoadFromPath(const std::string &objPathOrVPath)
{
std::string abs = objPathOrVPath;
if (objPathOrVPath.rfind("res://", 0) == 0) {
const uint64_t id = OX::AssetManager::GetIdForPath(objPathOrVPath);
if (id) {
std::string resolved = OX::AssetManager::GetAbsolutePathById(id);
if (!resolved.empty()) abs = std::move(resolved);
}
}
std::shared_ptr<OX::ModelAsset> model;
if (!load_obj_file(abs, model)) return nullptr;
return model;
}
std::shared_ptr<OX::ModelAsset>
OX::MeshLoaderOBJ::LoadById(uint64_t objId)
{
if (!objId) return nullptr;
const std::string abs = OX::AssetManager::GetAbsolutePathById(objId);
if (abs.empty()) {
OX::Logger::LogError("OBJ: id=%llu not found in AssetManager", (unsigned long long) objId);
return nullptr;
}
std::shared_ptr<OX::ModelAsset> model;
if (!load_obj_file(abs, model)) return nullptr;
return model;
}
} // namespace OX

View File

@@ -1,25 +1,16 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include <array>
#include "systems/Scene/Components/MeshComponent.h" // for Vertex, SubMesh, Material
#include <cstdint>
#include "systems/assets/Model.h"
namespace OX
{
struct MeshLoadResult
{
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
std::vector<SubMesh> submeshes;
std::vector<Material> materials;
};
class MeshLoaderOBJ
{
public:
// Load OBJ + MTL. Handles relative texture paths.
static bool Load(const std::string &objPath, MeshLoadResult &out);
static std::shared_ptr<ModelAsset> LoadFromPath(const std::string &objPathOrVPath);
static std::shared_ptr<ModelAsset> LoadById(uint64_t objId);
};
} // namespace OX
}

View File

@@ -0,0 +1,77 @@
#pragma once
#include <cstdint>
#include <vector>
#include "Asset.h"
#include <array>
namespace OX
{
struct GLTexture
{
unsigned int id = 0;
int width = 0;
int height = 0;
int channels = 0;
std::string path;
void Destroy();
void SetFromGL(unsigned int id, int width, int height);
};
struct Material
{
std::string name;
std::array<float, 3> kd{1, 1, 1}; // diffuse
std::array<float, 3> ks{0, 0, 0}; // specular
float ns = 16.0f; // shininess
std::string mapKd; // diffuse map path
std::string mapKs; // specular map path
std::string mapNormal; // normal map path (bump/normal)
GLTexture texKd;
GLTexture texKs;
GLTexture texNormal;
};
struct Vertex
{
float px, py, pz; // position
float nx, ny, nz; // normal
float u, v; // uv
float tx, ty, tz; // tangent
float bx, by, bz; // bitangent
};
struct SubMesh
{
uint32_t indexOffset = 0;
uint32_t indexCount = 0;
int materialIndex = -1;
};
struct ModelAsset : public Asset
{
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
std::vector<SubMesh> submeshes;
std::vector<Material> materials;
std::string GetTypeName() const override { return "model"; }
void clear()
{
vertices.clear();
vertices.shrink_to_fit();
indices.clear();
indices.shrink_to_fit();
submeshes.clear();
submeshes.shrink_to_fit();
materials.clear();
materials.shrink_to_fit();
}
};
} // namespace OX

View File

@@ -9,18 +9,18 @@
#include <GL/glew.h>
#include "Texture2D.h"
namespace OX {
Texture2D::~Texture2D() {
namespace OX
{
Texture2D::~Texture2D()
{
if (m_TextureID)
glDeleteTextures(1, &m_TextureID);
}
void Texture2D::SetFromGL(uint32_t texID, int width, int height) {
void Texture2D::SetFromGL(uint32_t texID, int width, int height)
{
m_TextureID = texID;
m_Width = width;
m_Height = height;
}
}

View File

@@ -2,7 +2,7 @@
// Created by spenc on 5/21/2025.
//
#pragma once
#include "systems/Asset.h"
#include "Asset.h"
#include <string>
namespace OX {
@@ -12,7 +12,6 @@ namespace OX {
~Texture2D();
std::string GetTypeName() const override { return "texture2D"; }
bool LoadFromFile(const std::string& path) override { return false; } // Not used now
void SetFromGL(uint32_t texID, int width, int height);

View File

@@ -1,225 +1,557 @@
// File: src/FileBrowser.cpp
#include "FileBrowser.h"
#include <filesystem>
#include "systems/Profiler.h"
#include <algorithm>
#include <sstream>
#include <cmath>
#include <unordered_set>
#include <GL/glew.h>
#include <stb/stb_image.h>
#include <imgui.h>
#include <imgui_internal.h>
#include "systems/AssetManager.h" // ResourceTreeNode, AssetManager
#include "systems/assets/Asset.h"
#include "systems/assets/Texture2D.h"
namespace OX
{
FileBrowser::FileBrowser()
: _lastProgress(0.0f)
// ============================================================
// Small helpers
// ============================================================
static inline GLuint ImTexToGL(ImTextureID id)
{
Logger::LogVerbose("Editor::InspectorWindow");
return static_cast<GLuint>(reinterpret_cast<uintptr_t>(id));
}
FileBrowser::~FileBrowser() = default;
// Call this every frame to report your current loading progress (0.0 → 1.0).
// Passing in 0 resets it and hides the bar.
void FileBrowser::SetProgress(const float p)
static inline ImTextureID GLToImTex(GLuint tex)
{
if (p <= 0.0f) {
_lastProgress = 0.0f;
} else {
_lastProgress = std::max(_lastProgress, p);
return reinterpret_cast<ImTextureID>(static_cast<uintptr_t>(tex));
}
static std::vector<std::string> SplitPathSegments(const std::string &vpath)
{
std::vector<std::string> segs;
if (vpath.rfind("res://", 0) != 0) return segs;
segs.emplace_back("res://");
std::string rest = vpath.substr(6);
size_t pos = 0;
while (pos < rest.size()) {
size_t slash = rest.find('/', pos);
if (slash == std::string::npos) {
auto chunk = rest.substr(pos);
if (!chunk.empty()) segs.push_back(chunk);
break;
}
auto chunk = rest.substr(pos, slash - pos);
if (!chunk.empty()) segs.push_back(chunk);
pos = slash + 1;
}
return segs;
}
static std::string JoinBreadcrumb(const std::vector<std::string> &segs, size_t upto)
{
if (segs.empty() || upto >= segs.size()) return "res://";
if (upto == 0) return "res://";
std::ostringstream os;
os << "res://";
for (size_t i = 1; i <= upto; ++i) {
os << segs[i];
if (i + 1 <= upto) os << "/";
}
return os.str();
}
// ============================================================
// ctor / dtor
// ============================================================
FileBrowser::FileBrowser(const std::string &rootVPath)
: m_rootVPath(rootVPath.empty() ? std::string("res://") : rootVPath),
m_currentVPath(m_rootVPath)
{
// Default tile visuals
m_thumbSize = 112.0f; // inner image square
m_cellPadding = 10.0f; // inner padding in tile
m_maxThumbsPerFrame = 8;
RebuildListing_();
SetCurrentVPath(m_rootVPath);
}
FileBrowser::~FileBrowser()
{
for (auto &kv: m_thumbCache) {
if (kv.second) {
GLuint t = ImTexToGL(kv.second);
glDeleteTextures(1, &t);
}
}
m_thumbCache.clear();
}
// ============================================================
// Public API
// ============================================================
void FileBrowser::SetCurrentVPath(const std::string &vpath)
{
if (vpath.rfind("res://", 0) == 0) {
m_currentVPath = vpath;
RebuildListing_();
}
}
void FileBrowser::SetFilter(const std::string &f) { _filter = f; }
void FileBrowser::SetFileSelectedCallback(FileSelectedCallback cb) { _onFileSelected = std::move(cb); }
void FileBrowser::Draw(const char *title)
void FileBrowser::Draw(const char *windowTitle)
{
OX_PROFILE_FUNCTION();
ImGui::Begin(title);
DrawToolbar();
// ——— main content ———
auto root = AssetManager::GetFileTree();
std::function<std::shared_ptr<ResourceTreeNode>(std::shared_ptr<ResourceTreeNode>, const std::string &)> find =
[&](auto node, const std::string &path) -> std::shared_ptr<ResourceTreeNode>
{
if (node->path == path)
return node;
for (auto &c: node->children)
if (c->isDirectory)
if (auto f = find(c, path))
return f;
return nullptr;
};
auto node = find(root, _currentPath);
if (!node) {
ImGui::TextColored(ImGui::GetStyle().Colors[ImGuiCol_TextDisabled],
"Path not found: %s", _currentPath.c_str());
} else if (_gridMode) {
// ——— Grid view with nicer spacing ———
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(_cfg.padding, _cfg.padding));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(_cfg.padding, _cfg.padding));
DrawGridView(node);
ImGui::PopStyleVar(2);
} else {
DrawListView(node);
if (!windowTitle) windowTitle = "Assets";
if (!ImGui::Begin(windowTitle)) {
ImGui::End();
return;
}
DrawToolbar_();
DrawBreadcrumbs_();
DrawGrid_();
ImGui::End();
}
// ——— Combined toolbar & inline path ———
void FileBrowser::DrawToolbar()
// ============================================================
// Snapshot & traversal
// ============================================================
std::shared_ptr<ResourceTreeNode> FileBrowser::SnapshotRoot_() const
{
if (_currentPath != "res://") {
if (ImGui::Button("Back")) {
auto pos = _currentPath.find_last_of('/');
_currentPath = (pos != std::string::npos && pos > 6)
? _currentPath.substr(0, pos)
: "res://";
std::scoped_lock lk(AssetManager::s_TreeMutex);
return AssetManager::GetFileTree();
}
std::shared_ptr<ResourceTreeNode>
FileBrowser::FindNodeByVPath_(const std::shared_ptr<ResourceTreeNode> &root,
const std::string &vpath) const
{
if (!root) return nullptr;
if (vpath == "res://") return root;
auto segs = SplitPathSegments(vpath);
if (segs.empty()) return nullptr;
auto node = root;
for (size_t i = 1; i < segs.size(); ++i) {
const std::string &want = segs[i];
std::shared_ptr<ResourceTreeNode> next = nullptr;
for (auto &c: node->children) {
if (c && c->name == want && c->isDirectory) {
next = c;
break;
}
}
ImGui::SameLine();
if (!next) return nullptr;
node = next;
}
return node;
}
void FileBrowser::RebuildListing_()
{
m_entries.clear();
auto root = SnapshotRoot_();
auto node = FindNodeByVPath_(root, m_currentVPath);
if (!node) return;
// Add virtual ".." entry if not at root
if (m_currentVPath != "res://") {
Entry up;
up.name = "..";
up.vpath = m_currentVPath; // compute parent on click
up.isDirectory = true;
up.type = OX_AssetType::Unknown;
up.id = 0;
m_entries.push_back(std::move(up));
}
ImGui::Text("%s", _currentPath.c_str());
ImGui::SameLine();
// Folders first
for (auto &c: node->children) {
if (!c) continue;
if (c->isDirectory) {
Entry e;
e.name = c->name;
e.vpath = c->path;
e.isDirectory = true;
e.type = OX_AssetType::Unknown;
e.id = 0;
m_entries.push_back(std::move(e));
}
}
// Files
for (auto &c: node->children) {
if (!c) continue;
if (!c->isDirectory) {
Entry e;
e.name = c->name;
e.vpath = c->path;
e.isDirectory = false;
e.id = AssetManager::GetIdForPath(c->path);
e.type = e.id ? AssetManager::GetAssetTypeById(e.id) : OX_AssetType::Unknown;
m_entries.push_back(std::move(e));
}
}
float avail = ImGui::GetContentRegionAvail().x;
ImGui::PushItemWidth(avail * 0.5f);
//ImGui::InputTextWithHint("##filter", "Filter files...", &_filter);
ImGui::PopItemWidth();
ImGui::SameLine();
// Stable sort: folders alpha, then files alpha
auto key = [](const Entry &e) { return std::make_pair(!e.isDirectory, e.name); };
std::stable_sort(m_entries.begin(), m_entries.end(),
[&](const Entry &a, const Entry &b) { return key(a) < key(b); });
}
if (_gridMode) {
if (ImGui::Button("List View")) _gridMode = false;
} else {
if (ImGui::Button("Grid View")) _gridMode = true;
// ============================================================
// Thumbs
// ============================================================
ImTextureID FileBrowser::GetThumbFor_(uint64_t id, OX_AssetType type, const std::string & /*vpath*/)
{
if (type != OX_AssetType::Texture || id == 0) return 0;
auto it = m_thumbCache.find(id);
if (it != m_thumbCache.end()) return it->second;
return EnsureTextureThumb_(id);
}
ImTextureID FileBrowser::UploadGLTextureRGBA_(int w, int h, const unsigned char *rgba)
{
if (w <= 0 || h <= 0 || rgba == nullptr) return 0;
GLuint tex = 0;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba);
glBindTexture(GL_TEXTURE_2D, 0);
return GLToImTex(tex);
}
ImTextureID FileBrowser::EnsureTextureThumb_(uint64_t id)
{
std::string abs = AssetManager::GetAbsolutePathById(id);
if (abs.empty()) {
m_thumbCache[id] = 0;
return 0;
}
int w = 0, h = 0, comp = 0;
unsigned char *data = stbi_load(abs.c_str(), &w, &h, &comp, 4);
if (!data) {
m_thumbCache[id] = 0;
return 0;
}
ImTextureID tex = UploadGLTextureRGBA_(w, h, data);
stbi_image_free(data);
m_thumbCache[id] = tex;
return tex;
}
ImTextureID FileBrowser::ToImGuiID_(const std::shared_ptr<Texture2D> &tex) const
{
(void) tex;
return 0;
}
// ============================================================
// UI
// ============================================================
void FileBrowser::DrawToolbar_()
{
// Minimal toolbar: Home + Up
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6, 6));
if (ImGui::Button("Home")) {
SetCurrentVPath(m_rootVPath);
}
ImGui::SameLine();
if (ImGui::Button("Rescan")) {
AssetManager::Rescan();
if (ImGui::Button("Up")) {
if (m_currentVPath != "res://") {
auto segs = SplitPathSegments(m_currentVPath);
if (segs.size() > 1) {
std::string parent = "res://";
if (segs.size() > 2) {
for (size_t j = 1; j < segs.size() - 1; ++j) {
parent += segs[j];
if (j + 1 < segs.size() - 1) parent += "/";
}
}
SetCurrentVPath(parent);
}
}
}
size_t total = AssetManager::GetTotalTexturesToLoad();
size_t loaded = AssetManager::GetLoadedTexturesCount();
if (total > 0 && loaded < total) {
float progress = float(loaded) / float(total);
ImGui::SameLine();
ImGui::ProgressBar(progress, ImVec2(-1, 0));
}
ImGui::PopStyleVar();
ImGui::Separator();
}
// ——— Polished grid view ———
void FileBrowser::DrawGridView(const std::shared_ptr<ResourceTreeNode> &node)
void FileBrowser::DrawBreadcrumbs_()
{
OX_PROFILE_FUNCTION();
auto segs = SplitPathSegments(m_currentVPath);
if (segs.empty()) segs.push_back("res://");
const float cellW = _cfg.thumbnailSize + _cfg.padding * 2;
ImVec2 avail = ImGui::GetContentRegionAvail();
int cols = std::max(1, int(avail.x / cellW));
for (size_t i = 0; i < segs.size(); ++i) {
if (i > 0) ImGui::SameLine();
std::string label = (i == 0 ? "res://" : segs[i]);
if (ImGui::Button(label.c_str())) {
SetCurrentVPath(JoinBreadcrumb(segs, i));
}
if (i + 1 < segs.size()) {
ImGui::SameLine();
ImGui::TextUnformatted("/");
}
}
ImGui::Separator();
}
// Add a little padding around the child region
ImGui::BeginChild("GridRegion",
ImVec2(0, 0),
false,
ImGuiWindowFlags_AlwaysUseWindowPadding);
static void DrawCenteredTextInRect(ImDrawList *dl, const ImVec2 &min, const ImVec2 &max, const char *text)
{
ImVec2 size = ImGui::CalcTextSize(text);
ImVec2 center = ImVec2((min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f);
ImVec2 pos = ImVec2(center.x - size.x * 0.5f, center.y - size.y * 0.5f);
dl->AddText(pos, IM_COL32(220, 220, 220, 255), text);
}
ImGui::Columns(cols, nullptr, false);
void FileBrowser::DrawGrid_()
{
// Auto layout from font size (no sliders)
const float font = ImGui::GetFontSize(); // ~16 by default
m_thumbSize = font * 7.0f; // ~112 inner image
m_cellPadding = std::round(font * 0.6f); // ~10
const float spacing = std::round(font * 0.75f);
std::vector<std::shared_ptr<ResourceTreeNode> > children; {
std::lock_guard<std::mutex> lock(AssetManager::s_TreeMutex);
children = node->children;
// Cell size
const float cellW = m_thumbSize + m_cellPadding * 2.0f;
const float cellH = m_thumbSize + m_cellPadding * 2.0f + font * 2.0f; // leave 1 line for file name
// Compute columns with a minimum of 1
float availX = ImGui::GetContentRegionAvail().x;
if (availX <= 0.0f) availX = 1.0f;
int columns = (int) std::floor((availX + spacing) / (cellW + spacing));
if (columns < 1) columns = 1;
// Scrollable region
if (!ImGui::BeginChild("##fb_scroll_region", ImVec2(0, 0), false)) {
ImGui::EndChild();
return;
}
for (auto &c: children) {
if (!c) continue;
if (!_filter.empty() && !PassesFilter(c->name)) continue;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing));
if (ImGui::BeginTable("##fb_grid", columns,
ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_NoBordersInBody)) {
int generatedThisFrame = 0;
ImGui::PushID(c->path.c_str());
ImGui::BeginGroup();
for (size_t i = 0; i < m_entries.size(); ++i) {
const Entry &e = m_entries[i];
int col = (int) (i % columns);
if (col == 0)
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(col);
ImVec2 btnSize(cellW, _cfg.thumbnailSize + _cfg.labelHeight + _cfg.padding);
ImGui::InvisibleButton("cell", btnSize);
bool hovered = ImGui::IsItemHovered();
bool clicked = ImGui::IsItemClicked();
ImGui::PushID((int) i);
// background
ImVec2 mn = ImGui::GetItemRectMin();
ImVec2 mx = ImGui::GetItemRectMax();
ImU32 bg = hovered ? _cfg.highlightColor : _cfg.bgColor;
ImGui::GetWindowDrawList()
->AddRectFilled(mn, mx, bg, 4.0f);
ImVec2 start = ImGui::GetCursorScreenPos();
// thumbnail
ImGui::SetCursorScreenPos({mn.x + _cfg.padding, mn.y + _cfg.padding});
if (c->isDirectory) {
ImGui::Image(GetIconTexture(*c),
{_cfg.thumbnailSize, _cfg.thumbnailSize});
} else {
auto asset = AssetManager::Get(c->path);
if (asset && asset->GetTypeName() == "texture2D") {
auto tex = std::static_pointer_cast<Texture2D>(asset);
ImGui::Image((ImTextureID) (intptr_t) tex->GetID(),
{_cfg.thumbnailSize, _cfg.thumbnailSize},
{0, 1}, {1, 0});
} else {
ImGui::Dummy({_cfg.thumbnailSize, _cfg.thumbnailSize});
// The InvisibleButton is the draggable “item”. Dont submit any other ImGui widgets before the source.
ImGui::InvisibleButton("cell", ImVec2(cellW, cellH));
// DRAG SOURCE: must be called right after the item you want to drag (the InvisibleButton)
if (!e.isDirectory && e.id != 0) {
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID
| ImGuiDragDropFlags_SourceNoDisableHover
| ImGuiDragDropFlags_SourceNoHoldToOpenOthers)) {
OX_AssetDrag payload{};
payload.id = e.id;
payload.type = e.type;
ImGui::SetDragDropPayload(OX_ASSET_DRAG_TYPE, &payload, sizeof(payload), ImGuiCond_Once);
// Drag preview content
ImGui::Text("Asset #%llu", (unsigned long long) payload.id);
ImGui::Separator();
ImGui::Text("Type: %s", AssetManager::AssetTypeToString(payload.type));
ImGui::TextUnformatted(e.name.c_str());
if (e.type == OX_AssetType::Texture) {
ImTextureID prev = 0;
auto it = m_thumbCache.find(e.id);
if (it != m_thumbCache.end()) prev = it->second;
else prev = EnsureTextureThumb_(e.id);
if (prev) {
ImGui::Separator();
ImGui::Image(prev, ImVec2(128.0f, 128.0f));
}
}
ImGui::EndDragDropSource();
}
}
// Now its safe to query state on the same item
bool hovered = ImGui::IsItemHovered();
bool held = ImGui::IsItemActive();
bool clicked = ImGui::IsItemClicked();
bool doubleClicked = hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);
ImVec2 min = start;
ImVec2 max = ImVec2(start.x + cellW, start.y + cellH);
ImDrawList *dl = ImGui::GetWindowDrawList();
// Tile background
ImU32 bgCol = IM_COL32(36, 36, 38, 255);
if (hovered) bgCol = IM_COL32(42, 42, 46, 255);
dl->AddRectFilled(min, max, bgCol, 7.0f);
// Inner thumbnail rect
ImVec2 innerMin(min.x + m_cellPadding, min.y + m_cellPadding);
ImVec2 innerMax(innerMin.x + m_thumbSize, innerMin.y + m_thumbSize);
// If texture, draw image; else draw centered type text
bool drewImage = false;
if (!e.isDirectory && e.type == OX_AssetType::Texture && e.id != 0) {
ImTextureID img = 0;
auto it = m_thumbCache.find(e.id);
if (it != m_thumbCache.end()) img = it->second;
else if (generatedThisFrame < m_maxThumbsPerFrame) {
img = EnsureTextureThumb_(e.id);
++generatedThisFrame;
}
if (img) {
dl->AddImageRounded(img, innerMin, innerMax, ImVec2(0, 0), ImVec2(1, 1),
IM_COL32_WHITE, 6.0f /* rounding */);
dl->AddRect(innerMin, innerMax, IM_COL32(80, 80, 86, 255), 6.0f, 0, 1.0f);
drewImage = true;
}
}
if (!drewImage) {
dl->AddRectFilled(innerMin, innerMax, IM_COL32(50, 50, 54, 255), 5.0f);
dl->AddRect(innerMin, innerMax, IM_COL32(80, 80, 86, 255), 5.0f);
DrawCenteredTextInRect(dl, innerMin, innerMax,
e.isDirectory ? "Folder" : AssetManager::AssetTypeToString(e.type));
}
// File name (single line) with width-based end-ellipsis
ImVec2 labelPos(innerMin.x, innerMax.y + m_cellPadding * 0.5f);
ImGui::SetCursorScreenPos(labelPos);
const float textMaxW = (cellW - m_cellPadding * 2.0f);
auto EllipsizeEnd = [&](const std::string &s, float maxW) -> std::string
{
if (s.empty()) return s;
if (ImGui::CalcTextSize(s.c_str()).x <= maxW) return s;
static const char *ell = "...";
const float ellW = ImGui::CalcTextSize(ell).x;
// Binary trim for speed
int lo = 0, hi = (int) s.size();
int best = 0;
while (lo <= hi) {
int mid = (lo + hi) / 2;
std::string t = s.substr(0, mid);
float w = ImGui::CalcTextSize(t.c_str()).x + ellW;
if (w <= maxW) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
std::string out = s.substr(0, best);
out.append(ell);
return out;
};
std::string displayName = EllipsizeEnd(e.name, textMaxW);
ImGui::TextUnformatted(displayName.c_str());
// Selection / hover border
ImU32 borderCol = IM_COL32(75, 75, 80, 255);
if (m_selectedVPath == e.vpath) borderCol = IM_COL32(70, 160, 255, 255);
if (held) borderCol = IM_COL32(120, 120, 160, 255);
dl->AddRect(min, max, borderCol, 7.0f, 0, 1.0f);
// Tooltip on hover (full name, info, bigger preview for textures)
if (hovered) {
ImGui::BeginTooltip();
ImGui::TextUnformatted(e.name.c_str());
ImGui::Separator();
ImGui::Text("Type: %s", e.isDirectory ? "Folder" : AssetManager::AssetTypeToString(e.type));
ImGui::Text("Path: %s", e.vpath.c_str());
if (!e.isDirectory) {
ImGui::Text("ID: %llu", (unsigned long long) e.id);
std::string abs = AssetManager::GetAbsolutePathById(e.id);
if (!abs.empty()) ImGui::Text("File: %s", abs.c_str());
}
if (!e.isDirectory && e.type == OX_AssetType::Texture && e.id != 0) {
ImTextureID big = 0;
auto it2 = m_thumbCache.find(e.id);
if (it2 != m_thumbCache.end()) big = it2->second;
else big = EnsureTextureThumb_(e.id);
if (big) {
ImGui::Separator();
const float preview = 192.0f;
ImGui::Image(big, ImVec2(preview, preview));
}
}
ImGui::EndTooltip();
}
// Click/select/double-click navigation
if (clicked) {
m_selectedVPath = e.vpath;
m_selectedId = e.id;
}
if (doubleClicked) {
if (e.isDirectory) {
if (e.name == "..") {
if (m_currentVPath != "res://") {
auto segs = SplitPathSegments(m_currentVPath);
if (segs.size() > 1) {
std::string parent = "res://";
if (segs.size() > 2) {
for (size_t j = 1; j < segs.size() - 1; ++j) {
parent += segs[j];
if (j + 1 < segs.size() - 1) parent += "/";
}
}
SetCurrentVPath(parent);
}
}
} else {
SetCurrentVPath(e.vpath);
}
}
}
ImGui::PopID();
}
// label
ImGui::SetCursorScreenPos({mn.x + _cfg.padding, mx.y - _cfg.labelHeight});
ImGui::PushTextWrapPos(mx.x);
ImGui::TextUnformatted(c->name.c_str());
ImGui::PopTextWrapPos();
// click handling
if (clicked) {
if (c->isDirectory)
_currentPath = c->path;
else if (_onFileSelected)
_onFileSelected(c->path);
}
ImGui::EndGroup();
ImGui::NextColumn();
ImGui::PopID();
ImGui::EndTable();
}
ImGui::Columns(1);
ImGui::PopStyleVar(); // ItemSpacing
ImGui::EndChild();
}
// ——— Simple list view ———
void FileBrowser::DrawListView(const std::shared_ptr<ResourceTreeNode> &node)
// ============================================================
// Helpers
// ============================================================
bool FileBrowser::IsDirectoryType_(OX_AssetType t)
{
ImGui::BeginChild("ListRegion", ImVec2(0, 0), false);
for (auto &c: node->children) {
if (!_filter.empty() && !PassesFilter(c->name)) continue;
if (ImGui::Selectable(c->name.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick)) {
if (ImGui::IsMouseDoubleClicked(0)) {
if (c->isDirectory)
_currentPath = c->path;
else if (_onFileSelected)
_onFileSelected(c->path);
}
}
}
ImGui::EndChild();
(void) t;
return false;
}
bool FileBrowser::PassesFilter(const std::string &name) const
{
return _filter.empty() || (name.find(_filter) != std::string::npos);
}
ImTextureID FileBrowser::GetIconTexture(const ResourceTreeNode &node)
{
return 0;
}
} // namespace OX
}

View File

@@ -1,72 +1,103 @@
// File: src/FileBrowser.h
#pragma once
#include <functional>
#include <string>
#include <vector>
#include <cstdint>
#include <memory>
#include "systems/AssetManager.h" // for ResourceTreeNode
#include "systems/assets/Texture2D.h" // for Texture2D
#include "imgui.h"
#include <string>
#include <unordered_map>
#include <vector>
#include <imgui.h>
#include "systems/assets/Asset.h"
#include "systems/assets/Texture2D.h"
#include "systems/AssetManager.h" // for ResourceTreeNode, OX_AssetType, OX_AssetDrag, OX_ASSET_DRAG_TYPE
namespace OX
{
/// Configuration for look & feel
struct FileBrowserConfig
{
float thumbnailSize = 64.0f;
float padding = 8.0f;
float labelHeight = 20.0f;
ImU32 bgColor = IM_COL32(40, 40, 40, 255);
ImU32 highlightColor = IM_COL32(75, 75, 75, 255);
bool showGrid = true;
bool showThumbnails = true;
bool allowMultiple = false;
};
class FileBrowser
{
public:
using FileSelectedCallback = std::function<void(const std::string &path)>;
FileBrowser();
// rootVPath like "res://"
explicit FileBrowser(const std::string &rootVPath = "res://");
~FileBrowser();
/// Draw the entire browser window
void Draw(const char *title = "File Browser");
// Optional: jump to path (must be a virtual path like "res://Textures")
void SetCurrentVPath(const std::string &vpath);
/// Set a filter string (wildcards, substrings, etc.)
void SetFilter(const std::string &filter);
// Main UI
void Draw(const char *windowTitle = "Assets");
/// Called whenever the user clicks on a file (not directory)
void SetFileSelectedCallback(FileSelectedCallback cb);
// Selection API (simple)
const std::string &GetSelectedVPath() const { return m_selectedVPath; }
uint64_t GetSelectedId() const { return m_selectedId; }
/// Access to tweak colors/sizes
FileBrowserConfig &Config() { return _cfg; }
// Grid settings
void SetThumbSize(float px) { m_thumbSize = px; }
void SetCellPadding(float px) { m_cellPadding = px; }
private:
// helpers
void DrawToolbar();
// ---- Directory / tree traversal ----
std::shared_ptr<ResourceTreeNode> SnapshotRoot_() const;
void DrawGridView(const std::shared_ptr<ResourceTreeNode> &node);
std::shared_ptr<ResourceTreeNode> FindNodeByVPath_(const std::shared_ptr<ResourceTreeNode> &root,
const std::string &vpath) const;
void DrawListView(const std::shared_ptr<ResourceTreeNode> &node);
void RebuildListing_(); // build m_entries from current vpath
[[nodiscard]] bool PassesFilter(const std::string &name) const;
// ---- Icons / thumbnails ----
ImTextureID GetFolderIcon_();
void SetProgress(float p);
ImTextureID GetThumbFor_(uint64_t id, OX_AssetType type, const std::string &vpath);
ImTextureID GetIconTexture(const ResourceTreeNode &); // stub for folder/file icons
// Convert Texture2D* or ref to an ImGui ID (engine-specific hook point)
ImTextureID ToImGuiID_(const std::shared_ptr<Texture2D> &tex) const;
// state
FileBrowserConfig _cfg;
std::string _currentPath = "res://";
std::string _filter;
bool _gridMode = true;
FileSelectedCallback _onFileSelected;
float _lastProgress;
size_t _maxQueueSize;
// Upload raw RGBA into GL texture
ImTextureID UploadGLTextureRGBA_(int w, int h, const unsigned char *rgba);
// Try generate preview for a texture
ImTextureID EnsureTextureThumb_(uint64_t id);
// ---- UI pieces ----
void DrawToolbar_();
void DrawBreadcrumbs_();
void DrawGrid_();
// ---- Helpers ----
static bool IsDirectoryType_(OX_AssetType t);
private:
struct Entry
{
std::string name;
std::string vpath;
bool isDirectory = false;
OX_AssetType type = OX_AssetType::Unknown;
uint64_t id = 0; // zero for folders (not mapped by AssetManager)
};
// State
std::string m_rootVPath = "res://";
std::string m_currentVPath = "res://";
std::vector<Entry> m_entries;
// Selection
std::string m_selectedVPath;
uint64_t m_selectedId = 0;
// Icons / thumbs
ImTextureID m_folderIcon = 0;
std::unordered_map<uint64_t, ImTextureID> m_thumbCache; // id -> texture
// UI config
float m_thumbSize = 96.0f;
float m_cellPadding = 12.0f;
// Internal throttle (build thumbs lazily)
int m_maxThumbsPerFrame = 8;
};
} // namespace OX
}

View File

@@ -16,11 +16,13 @@
#include "systems/Scene/Components/CameraComponent.h"
#include "systems/Scene/Components/PointLightComponent.h"
#include "systems/AssetManager.h" // <-- for OX_AssetDrag + AssetManager::GetIdForPath
#include <imgui.h>
#include <imgui_internal.h>
#include <misc/cpp/imgui_stdlib.h> // std::string overloads for InputText
#include <misc/cpp/imgui_stdlib.h>
#include <algorithm>
#include <cinttypes>
namespace OX
{
@@ -39,18 +41,15 @@ namespace OX
ImGui::PushID(title);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8, 6));
// Draw the framed header
const bool open = ImGui::TreeNodeEx("##comp", flags, "%s", title);
if (openState) *openState = open;
// Right-click the header to open the context menu
bool remove = false;
if (ImGui::BeginPopupContextItem("comp_ctx")) {
if (ImGui::MenuItem("Remove component"))
remove = true;
ImGui::EndPopup();
}
if (toRemove) *toRemove = remove;
ImGui::PopStyleVar();
@@ -58,7 +57,6 @@ namespace OX
return open;
}
bool InspectorWindow::DrawVec3Row(const char *label, float v[3],
float reset, float speed)
{
@@ -70,17 +68,13 @@ namespace OX
ImGui::TextUnformatted(label);
ImGui::TableSetColumnIndex(1);
// Unique ID scope per row to avoid X/Y/Z collisions across rows
ImGui::PushID(label);
const float lineH = ImGui::GetFrameHeight();
const ImVec2 btnSz(lineH, lineH);
// Inner table lays out [Btn, Drag] x 3 without SameLine() overflow
if (ImGui::BeginTable("##vec", 6,
ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_NoPadInnerX)) {
// Button columns are fixed; drag columns stretch
ImGui::TableSetupColumn("bx", ImGuiTableColumnFlags_WidthFixed, lineH);
ImGui::TableSetupColumn("dx", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("by", ImGuiTableColumnFlags_WidthFixed, lineH);
@@ -144,7 +138,7 @@ namespace OX
ImGui::EndTable();
}
ImGui::PopID(); // end row id scope
ImGui::PopID();
return changed;
}
@@ -186,13 +180,12 @@ namespace OX
if (selId == 0) return false;
auto &scene = core.GetScene();
GameObject *alive = scene.get(selId); // lookup by ID only
GameObject *alive = scene.get(selId);
if (!alive) {
editor.ClearSelection();
return false;
}
// Refresh local buffers when selection changed
if (m_lastSelectedId != selId) {
SyncBuffersOnSelection(alive);
m_lastSelectedId = selId;
@@ -200,7 +193,6 @@ namespace OX
return true;
}
void InspectorWindow::SyncBuffersOnSelection(GameObject *go)
{
m_lastSelectedId = go ? go->id() : 0;
@@ -209,15 +201,8 @@ namespace OX
if (go) m_name = go->name();
else m_name.clear();
// mesh path (if any)
if (go) {
if (auto *mc = go->getComponent<MeshComponent>())
m_meshPath = mc->sourcePath();
else
m_meshPath.clear();
} else {
m_meshPath.clear();
}
// (legacy field) clear mesh path display; we no longer edit by path here
m_meshPath.clear();
}
void InspectorWindow::DrawSelectedObject(Core &core, Editor &editor)
@@ -225,7 +210,6 @@ namespace OX
GameObject *go = editor.GetSelected();
if (!go) return;
// Name + ID row (using a 2-col table for clean alignment)
if (ImGui::BeginTable("##go_header_tbl", 2,
ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_BordersInnerV)) {
ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthFixed, 90.0f);
@@ -261,7 +245,6 @@ namespace OX
ImGui::Separator();
ImGui::Dummy(ImVec2(0, 6));
if (go->getComponent<TransformComponent>()) {
DrawTransformComponent(go);
ImGui::Dummy(ImVec2(0, 4));
@@ -281,16 +264,13 @@ namespace OX
ImGui::Dummy(ImVec2(0, 4));
}
// Popup-only add
DrawAddComponentMenu(go);
}
// ─────────────────────────────────────────────────────────────────────────────
// Components (draw ONLY if component exists)
// Components
// ─────────────────────────────────────────────────────────────────────────────
void InspectorWindow::DrawTransformComponent(GameObject *go)
{
bool toRemove = false;
@@ -367,7 +347,7 @@ namespace OX
if (ImGui::Combo("##cam_proj", &proj, items, IM_ARRAYSIZE(items)))
cc->setProjection(static_cast<CameraComponent::Projection>(proj));
// FOV (persp) or Ortho Height (ortho)
// FOV / Ortho Height
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::AlignTextToFramePadding();
@@ -414,165 +394,180 @@ namespace OX
if (toRemove) (void) go->removeComponent<CameraComponent>();
}
void InspectorWindow::DrawMeshComponent(GameObject *go)
{
bool openDummy = true, toRemove = false;
if (ComponentHeader("Mesh", &openDummy, &toRemove)) {
auto *mc = go->getComponent<MeshComponent>();
// Sync UI path once if empty but component has a path
if (m_meshPath.empty() && !mc->sourcePath().empty())
m_meshPath = mc->sourcePath();
if (ImGui::BeginTable("##mesh_tbl", 2,
ImGuiTableFlags_SizingStretchProp |
ImGuiTableFlags_BordersInnerV)) {
ImGui::TableSetupColumn("Label", ImGuiTableColumnFlags_WidthFixed, 110.0f);
ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch);
// Path
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Path");
ImGui::TableSetColumnIndex(1);
ImGui::SetNextItemWidth(-1);
const bool pathCommitted =
ImGui::InputText("##mesh_path", &m_meshPath,
ImGuiInputTextFlags_AutoSelectAll |
ImGuiInputTextFlags_EnterReturnsTrue);
// Actions
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Actions");
ImGui::TableSetColumnIndex(1);
bool doLoad = false;
if (ImGui::Button("Load (Single Component)")) doLoad = true;
ImGui::SameLine();
if (ImGui::Button("Import as Children (Per Submesh)")) {
if (!m_meshPath.empty()) {
// Creates child objects Submesh_i [...] each with its own MeshComponent
if (!MeshComponent::ImportOBJ(go, m_meshPath, MeshImportMode::ChildrenPerSubmesh)) {
Logger::LogError("Children-per-submesh import failed for: %s", m_meshPath.c_str());
} else {
// keep the parents MeshComponent as-is; user can remove it if desired
}
} else {
Logger::LogWarning("Mesh path is empty (children import).");
}
}
ImGui::SameLine();
if (ImGui::Button("Reload")) {
if (!mc->sourcePath().empty()) {
mc->LoadOBJ(mc->sourcePath());
m_meshPath = mc->sourcePath(); // reflect normalization
} else {
Logger::LogWarning("No mesh path to reload.");
}
}
if (pathCommitted) doLoad = true;
if (doLoad) {
if (!m_meshPath.empty()) {
mc->LoadOBJ(m_meshPath);
m_meshPath = mc->sourcePath(); // reflect any normalization
} else {
Logger::LogWarning("Mesh path is empty.");
}
}
// Stats
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Stats");
ImGui::TableSetColumnIndex(1);
ImGui::Text("Verts: %zu Indices: %zu Submeshes: %zu Materials: %zu",
mc->vertices().size(), mc->indices().size(),
mc->submeshes().size(), mc->materials().size());
// Submesh / Material Browser
if (!mc->submeshes().empty()) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Submeshes");
ImGui::TableSetColumnIndex(1);
if (ImGui::BeginTable("##submesh_list", 5,
ImGuiTableFlags_Borders |
ImGuiTableFlags_RowBg |
ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("i", ImGuiTableColumnFlags_WidthFixed, 30.0f);
ImGui::TableSetupColumn("IndexOffset", ImGuiTableColumnFlags_WidthFixed, 90.0f);
ImGui::TableSetupColumn("IndexCount", ImGuiTableColumnFlags_WidthFixed, 85.0f);
ImGui::TableSetupColumn("Material", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, 160.0f);
ImGui::TableHeadersRow();
const auto &subs = mc->submeshes();
const auto &mats = mc->materials();
for (size_t i = 0; i < subs.size(); ++i) {
const auto &sm = subs[i];
const bool hasMat = (sm.materialIndex >= 0 && sm.materialIndex < (int) mats.size());
const char *matName = hasMat ? mats[sm.materialIndex].name.c_str() : "(none)";
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::Text("%zu", i);
ImGui::TableSetColumnIndex(1);
ImGui::Text("%u", sm.indexOffset);
ImGui::TableSetColumnIndex(2);
ImGui::Text("%u", sm.indexCount);
ImGui::TableSetColumnIndex(3);
ImGui::TextUnformatted(matName);
ImGui::TableSetColumnIndex(4);
ImGui::PushID((int) i);
if (ImGui::Button("Extract to Child")) {
// Make one child GO with just this submesh
if (!mc->sourcePath().empty()) {
std::string childName = "Submesh_" + std::to_string(i);
if (hasMat && mats[sm.materialIndex].name.size())
childName += " [" + mats[sm.materialIndex].name + "]";
GameObject *child = go->createChild(childName);
if (child) {
auto *childMc = child->AddComponent<MeshComponent>();
if (!childMc->LoadOBJSubmesh(mc->sourcePath(), (int) i)) {
Logger::LogError("Extract failed for submesh %zu", i);
}
}
} else {
Logger::LogWarning("Cannot extract: component has no sourcePath.");
}
}
ImGui::SameLine();
if (ImGui::Button("Select")) {
// Optional: you can set current selection in your editor to this submesh,
// or focus material inspector, etc. Placeholder hook:
Logger::LogInfo("Selected submesh %zu", i);
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
ImGui::EndTable();
}
if (openDummy) ImGui::TreePop();
bool open = true, toRemove = false;
if (!ComponentHeader("Mesh", &open, &toRemove)) {
if (toRemove) (void) go->removeComponent<MeshComponent>();
return;
}
auto *mc = go->getComponent<MeshComponent>();
if (!mc) {
if (open) ImGui::TreePop();
if (toRemove) (void) go->removeComponent<MeshComponent>();
return;
}
// Resolve current source asset id if your MeshComponent exposes it
uint64_t currentAssetId = 0;
#if defined(__cpp_concepts) || (defined(_MSC_VER) && _MSC_VER >= 1920)
if constexpr (requires(MeshComponent *c) { c->sourceAssetId(); }) {
currentAssetId = mc->sourceAssetId();
}
#endif
// --- Current asset (simple property row) ---
ImGui::SeparatorText("Asset"); {
ImGui::BeginGroup();
ImGui::TextUnformatted("Model");
ImGui::SameLine(140.0f);
if (currentAssetId != 0) {
const std::string vpath = AssetManager::GetVirtualPathById(currentAssetId);
ImGui::Text("ID: %llu", (unsigned long long) currentAssetId);
if (!vpath.empty()) {
ImGui::SameLine();
ImGui::TextDisabled("(%s)", vpath.c_str());
}
} else {
ImGui::TextDisabled("None");
}
ImGui::EndGroup();
// Actions (inline)
ImGui::SameLine();
if (ImGui::Button("Clear")) {
// Prefer a real Clear() in MeshComponent if available.
// Here we just leave visuals; your component should own the "no mesh" state.
currentAssetId = 0;
// e.g. mc->Clear(); // if you have it
}
ImGui::SameLine();
if (currentAssetId != 0 && ImGui::Button("Copy ID")) {
ImGui::SetClipboardText(std::to_string(currentAssetId).c_str());
}
}
// --- Drop zone (accepts only Model assets & resolvable ids) ---
ImGui::SeparatorText("Assign"); {
const float h = ImGui::GetFrameHeight() * 1.2f;
ImVec2 size(-1.f, h);
// Peek the payload to highlight only if a real Model id is hovered
bool highlight = false;
if (const ImGuiPayload *peek = ImGui::GetDragDropPayload()) {
if (peek->IsDataType(OX_ASSET_DRAG_TYPE) && peek->DataSize == sizeof(OX_AssetDrag)) {
const auto *p = static_cast<const OX_AssetDrag *>(peek->Data);
if (p && p->id != 0) {
OX_AssetType t = (p->type == OX_AssetType::Unknown)
? AssetManager::GetAssetTypeById(p->id)
: p->type;
const std::string abs = AssetManager::GetAbsolutePathById(p->id);
highlight = (t == OX_AssetType::Model && !abs.empty());
}
}
}
if (highlight) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.30f, 0.47f, 1.0f));
ImGui::Button("Drag & drop a Model asset here", size);
if (highlight) ImGui::PopStyleColor();
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload(
OX_ASSET_DRAG_TYPE, ImGuiDragDropFlags_AcceptBeforeDelivery)) {
if (payload && payload->DataSize == sizeof(OX_AssetDrag)) {
const auto *drag = static_cast<const OX_AssetDrag *>(payload->Data);
if (drag && drag->id != 0) {
const uint64_t id = drag->id;
const OX_AssetType type = AssetManager::GetAssetTypeById(id);
const std::string abspath = AssetManager::GetAbsolutePathById(id);
if (type != OX_AssetType::Model) {
Logger::LogWarning("Dropped asset %" PRIu64 " is not a Model.", id);
} else if (abspath.empty()) {
Logger::LogError("Dropped model id=%" PRIu64 " has no absolute path.", id);
} else {
if (!mc->LoadModel(id)) {
Logger::LogError("Failed to load model asset %" PRIu64, id);
} else {
currentAssetId = id;
// If MeshComponent tracks the source id internally, update it there.
// e.g. mc->setSourceAssetId(id);
}
}
}
}
}
ImGui::EndDragDropTarget();
}
}
// --- Stats ---
ImGui::SeparatorText("Stats");
ImGui::Text("Verts: %zu Indices: %zu Submeshes: %zu Materials: %zu",
mc->vertices().size(), mc->indices().size(),
mc->submeshes().size(), mc->materials().size());
// --- Submeshes (simple table, collapsible) ---
if (!mc->submeshes().empty() && ImGui::CollapsingHeader("Submeshes", ImGuiTreeNodeFlags_DefaultOpen)) {
if (ImGui::BeginTable("##submesh_list", 5,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("#", ImGuiTableColumnFlags_WidthFixed, 30.0f);
ImGui::TableSetupColumn("IndexOffset", ImGuiTableColumnFlags_WidthFixed, 90.0f);
ImGui::TableSetupColumn("IndexCount", ImGuiTableColumnFlags_WidthFixed, 85.0f);
ImGui::TableSetupColumn("Material", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Action", ImGuiTableColumnFlags_WidthFixed, 150.0f);
ImGui::TableHeadersRow();
const auto &subs = mc->submeshes();
const auto &mats = mc->materials();
for (size_t i = 0; i < subs.size(); ++i) {
const auto &sm = subs[i];
const bool hasMat = (sm.materialIndex >= 0 && sm.materialIndex < (int) mats.size());
const char *matName = hasMat ? mats[sm.materialIndex].name.c_str() : "(none)";
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::Text("%zu", i);
ImGui::TableSetColumnIndex(1);
ImGui::Text("%u", sm.indexOffset);
ImGui::TableSetColumnIndex(2);
ImGui::Text("%u", sm.indexCount);
ImGui::TableSetColumnIndex(3);
ImGui::TextUnformatted(matName);
ImGui::TableSetColumnIndex(4);
ImGui::PushID((int) i);
if (ImGui::Button("Extract")) {
if (currentAssetId != 0) {
std::string childName = "Submesh_" + std::to_string(i);
if (hasMat && mats[sm.materialIndex].name.size())
childName += " [" + mats[sm.materialIndex].name + "]";
if (GameObject *child = go->createChild(childName)) {
if (auto *childMc = child->AddComponent<MeshComponent>();
!childMc->LoadModelSubmesh(currentAssetId, (int) i)) {
Logger::LogError("Extract failed for submesh %zu", i);
}
}
} else {
Logger::LogWarning("Cannot extract: no model asset bound.");
}
}
ImGui::SameLine();
if (ImGui::Button("Select")) {
Logger::LogInfo("Selected submesh %zu", i);
}
ImGui::PopID();
}
ImGui::EndTable();
}
}
if (open) ImGui::TreePop();
if (toRemove) {
(void) go->removeComponent<MeshComponent>();
m_meshPath.clear();
@@ -631,7 +626,7 @@ namespace OX
if (ImGui::DragFloat("##pl_range", &R, 0.05f, 0.01f, 100000.0f))
pl->setRange(std::max(0.01f, R));
// Shadows (reserved)
// Shadows
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::AlignTextToFramePadding();
@@ -649,7 +644,6 @@ namespace OX
if (toRemove) (void) go->removeComponent<PointLightComponent>();
}
// ─────────────────────────────────────────────────────────────────────────────
// Popup-only add
// ─────────────────────────────────────────────────────────────────────────────
@@ -664,7 +658,6 @@ namespace OX
const bool hasCam = go->getComponent<CameraComponent>() != nullptr;
const bool hasPoint = go->getComponent<PointLightComponent>() != nullptr;
if (ImGui::MenuItem("TransformComponent", nullptr, false, !hasTr)) {
go->addComponent(std::make_unique<TransformComponent>());
ImGui::CloseCurrentPopup();