diff --git a/src/EditorUI.cpp b/src/EditorUI.cpp index aafcd76..f173f94 100644 --- a/src/EditorUI.cpp +++ b/src/EditorUI.cpp @@ -97,7 +97,7 @@ FileCategory FileBrowser::getFileCategory(const fs::directory_entry& entry) cons // Model files if (ext == ".fbx" || ext == ".obj" || ext == ".gltf" || ext == ".glb" || ext == ".dae" || ext == ".blend" || ext == ".3ds" || ext == ".ply" || - ext == ".stl" || ext == ".x" || ext == ".md5mesh") { + ext == ".stl" || ext == ".x" || ext == ".md5mesh" || ext == ".rmesh") { return FileCategory::Model; } diff --git a/src/Engine.cpp b/src/Engine.cpp index d28bf43..e707247 100644 --- a/src/Engine.cpp +++ b/src/Engine.cpp @@ -87,6 +87,42 @@ SceneObject* Engine::getSelectedObject() { return (it != sceneObjects.end()) ? &(*it) : nullptr; } +glm::vec3 Engine::getSelectionCenterWorld(bool worldSpace) const { + if (selectedObjectIds.empty()) return glm::vec3(0.0f); + glm::vec3 acc(0.0f); + int count = 0; + auto findObj = [&](int id) -> const SceneObject* { + auto it = std::find_if(sceneObjects.begin(), sceneObjects.end(), [id](const SceneObject& o){ return o.id == id; }); + return it == sceneObjects.end() ? nullptr : &(*it); + }; + for (int id : selectedObjectIds) { + const SceneObject* o = findObj(id); + if (!o) continue; + acc += worldSpace ? o->position : glm::vec3(0.0f); + count++; + } + if (count == 0) return glm::vec3(0.0f); + return acc / (float)count; +} + +void Engine::setPrimarySelection(int id, bool additive) { + if (!additive) { + selectedObjectIds.clear(); + } + if (id >= 0) { + selectedObjectIds.push_back(id); + selectedObjectId = id; + } else { + selectedObjectIds.clear(); + selectedObjectId = -1; + } +} + +void Engine::clearSelection() { + selectedObjectIds.clear(); + selectedObjectId = -1; +} + Camera Engine::makeCameraFromObject(const SceneObject& obj) const { Camera cam; cam.position = obj.position; @@ -120,7 +156,7 @@ void Engine::DecomposeMatrix(const glm::mat4& matrix, glm::vec3& pos, glm::vec3& void Engine::recordState(const char* /*reason*/) { SceneSnapshot snap; snap.objects = sceneObjects; - snap.selectedId = selectedObjectId; + snap.selectedIds = selectedObjectIds; snap.nextId = nextObjectId; undoStack.push_back(std::move(snap)); @@ -135,7 +171,7 @@ void Engine::undo() { SceneSnapshot current; current.objects = sceneObjects; - current.selectedId = selectedObjectId; + current.selectedIds = selectedObjectIds; current.nextId = nextObjectId; SceneSnapshot snap = undoStack.back(); @@ -143,7 +179,8 @@ void Engine::undo() { redoStack.push_back(std::move(current)); sceneObjects = std::move(snap.objects); - selectedObjectId = snap.selectedId; + selectedObjectIds = snap.selectedIds; + selectedObjectId = selectedObjectIds.empty() ? -1 : selectedObjectIds.back(); nextObjectId = snap.nextId; projectManager.currentProject.hasUnsavedChanges = true; } @@ -153,7 +190,7 @@ void Engine::redo() { SceneSnapshot current; current.objects = sceneObjects; - current.selectedId = selectedObjectId; + current.selectedIds = selectedObjectIds; current.nextId = nextObjectId; SceneSnapshot snap = redoStack.back(); @@ -161,7 +198,8 @@ void Engine::redo() { undoStack.push_back(std::move(current)); sceneObjects = std::move(snap.objects); - selectedObjectId = snap.selectedId; + selectedObjectIds = snap.selectedIds; + selectedObjectId = selectedObjectIds.empty() ? -1 : selectedObjectIds.back(); nextObjectId = snap.nextId; projectManager.currentProject.hasUnsavedChanges = true; } @@ -301,6 +339,7 @@ void Engine::run() { if (showHierarchy) renderHierarchyPanel(); if (showInspector) renderInspectorPanel(); if (showFileBrowser) renderFileBrowserPanel(); + if (showMeshBuilder) renderMeshBuilderPanel(); if (showConsole) renderConsolePanel(); if (showEnvironmentWindow) renderEnvironmentWindow(); if (showCameraWindow) renderCameraWindow(); @@ -372,7 +411,7 @@ void Engine::importOBJToScene(const std::string& filepath, const std::string& ob obj.meshId = meshId; sceneObjects.push_back(obj); - selectedObjectId = id; + setPrimarySelection(id); if (projectManager.currentProject.isLoaded) { projectManager.currentProject.hasUnsavedChanges = true; @@ -407,7 +446,7 @@ void Engine::importModelToScene(const std::string& filepath, const std::string& obj.meshId = result.meshIndex; sceneObjects.push_back(obj); - selectedObjectId = id; + setPrimarySelection(id); if (projectManager.currentProject.isLoaded) { projectManager.currentProject.hasUnsavedChanges = true; @@ -422,6 +461,61 @@ void Engine::importModelToScene(const std::string& filepath, const std::string& ); } +void Engine::convertModelToRawMesh(const std::string& filepath) { + auto& modelLoader = getModelLoader(); + fs::path inPath(filepath); + fs::path outPath = inPath; + outPath.replace_extension(".rmesh"); + + std::string error; + if (modelLoader.exportRawMesh(filepath, outPath.string(), error)) { + addConsoleMessage("Converted to raw mesh: " + outPath.string(), ConsoleMessageType::Success); + fileBrowser.needsRefresh = true; + } else { + addConsoleMessage("Raw mesh export failed: " + error, ConsoleMessageType::Error); + } +} + +bool Engine::ensureMeshEditTarget(SceneObject* obj) { + if (!obj) return false; + fs::path ext = fs::path(obj->meshPath).extension(); + std::string extLower = ext.string(); + std::transform(extLower.begin(), extLower.end(), extLower.begin(), ::tolower); + if (extLower != ".rmesh") return false; + + if (meshEditLoaded && meshEditPath == obj->meshPath) { + if (meshEditSelectedVertices.empty() && !meshEditAsset.positions.empty()) { + meshEditSelectedVertices.push_back(0); + } + return true; + } + + std::string err; + if (!getModelLoader().loadRawMesh(obj->meshPath, meshEditAsset, err)) { + addConsoleMessage("Mesh edit load failed: " + err, ConsoleMessageType::Error); + meshEditLoaded = false; + return false; + } + meshEditLoaded = true; + meshEditPath = obj->meshPath; + meshEditSelectedVertices.clear(); + meshEditSelectedEdges.clear(); + meshEditSelectedFaces.clear(); + if (!meshEditAsset.positions.empty()) meshEditSelectedVertices.push_back(0); + return true; +} + +bool Engine::syncMeshEditToGPU(SceneObject* obj) { + if (!obj || !meshEditLoaded) return false; + std::string err; + if (!getModelLoader().updateRawMesh(obj->meshId, meshEditAsset, err)) { + addConsoleMessage("Mesh GPU sync failed: " + err, ConsoleMessageType::Error); + return false; + } + projectManager.currentProject.hasUnsavedChanges = true; + return true; +} + void Engine::loadMaterialFromFile(SceneObject& obj) { if (obj.materialPath.empty()) return; try { @@ -648,7 +742,7 @@ void Engine::createNewProject(const char* name, const char* location) { } sceneObjects.clear(); - selectedObjectId = -1; + clearSelection(); nextObjectId = 0; addObject(ObjectType::Cube, "Cube"); @@ -671,7 +765,7 @@ void Engine::createNewProject(const char* name, const char* location) { void Engine::loadRecentScenes() { sceneObjects.clear(); - selectedObjectId = -1; + clearSelection(); nextObjectId = 0; undoStack.clear(); redoStack.clear(); @@ -722,7 +816,7 @@ void Engine::loadScene(const std::string& sceneName) { projectManager.currentProject.currentSceneName = sceneName; projectManager.currentProject.hasUnsavedChanges = false; projectManager.currentProject.saveProjectFile(); - selectedObjectId = -1; + clearSelection(); bool hasDirLight = std::any_of(sceneObjects.begin(), sceneObjects.end(), [](const SceneObject& o) { return o.type == ObjectType::DirectionalLight; }); @@ -744,7 +838,7 @@ void Engine::createNewScene(const std::string& sceneName) { } sceneObjects.clear(); - selectedObjectId = -1; + clearSelection(); nextObjectId = 0; undoStack.clear(); redoStack.clear(); @@ -787,7 +881,7 @@ void Engine::addObject(ObjectType type, const std::string& baseName) { sceneObjects.back().camera.type = SceneCameraType::Player; sceneObjects.back().camera.fov = 60.0f; } - selectedObjectId = id; + setPrimarySelection(id); if (projectManager.currentProject.isLoaded) { projectManager.currentProject.hasUnsavedChanges = true; } @@ -820,7 +914,7 @@ void Engine::duplicateSelected() { newObj.postFx = it->postFx; sceneObjects.push_back(newObj); - selectedObjectId = id; + setPrimarySelection(id); if (projectManager.currentProject.isLoaded) { projectManager.currentProject.hasUnsavedChanges = true; } @@ -836,7 +930,7 @@ void Engine::deleteSelected() { if (it != sceneObjects.end()) { logToConsole("Deleted object"); sceneObjects.erase(it, sceneObjects.end()); - selectedObjectId = -1; + clearSelection(); if (projectManager.currentProject.isLoaded) { projectManager.currentProject.hasUnsavedChanges = true; } diff --git a/src/Engine.h b/src/Engine.h index dc5a9ef..5c6f00f 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -6,6 +6,7 @@ #include "Rendering.h" #include "ProjectManager.h" #include "EditorUI.h" +#include "MeshBuilder.h" #include "../include/Window/Window.h" void window_size_callback(GLFWwindow* window, int width, int height); @@ -35,14 +36,15 @@ private: bool inspectedMaterialValid = false; struct SceneSnapshot { std::vector objects; - int selectedId = -1; + std::vector selectedIds; int nextId = 0; }; std::vector undoStack; std::vector redoStack; std::vector sceneObjects; - int selectedObjectId = -1; + int selectedObjectId = -1; // primary selection (last) + std::vector selectedObjectIds; // multi-select int nextObjectId = 0; // Gizmo state @@ -59,6 +61,7 @@ private: bool showFileBrowser = true; bool showConsole = true; bool showProjectBrowser = true; // Now merged into file browser + bool showMeshBuilder = false; bool firstFrame = true; std::vector consoleLog; int draggedObjectId = -1; @@ -86,13 +89,31 @@ private: bool isPaused = false; bool showViewOutput = true; int previewCameraId = -1; + MeshBuilder meshBuilder; + char meshBuilderPath[260] = ""; + char meshBuilderFaceInput[128] = ""; + bool meshEditMode = false; + bool meshEditLoaded = false; + std::string meshEditPath; + RawMeshAsset meshEditAsset; + std::vector meshEditSelectedVertices; + std::vector meshEditSelectedEdges; // indices into generated edge list + std::vector meshEditSelectedFaces; // indices into mesh faces + enum class MeshEditSelectionMode { Vertex = 0, Edge = 1, Face = 2 }; + MeshEditSelectionMode meshEditSelectionMode = MeshEditSelectionMode::Vertex; // Private methods SceneObject* getSelectedObject(); + glm::vec3 getSelectionCenterWorld(bool worldSpace) const; + void setPrimarySelection(int id, bool additive = false); + void clearSelection(); static void DecomposeMatrix(const glm::mat4& matrix, glm::vec3& pos, glm::vec3& rot, glm::vec3& scale); void importOBJToScene(const std::string& filepath, const std::string& objectName); void importModelToScene(const std::string& filepath, const std::string& objectName); // Assimp import + void convertModelToRawMesh(const std::string& filepath); + bool ensureMeshEditTarget(SceneObject* obj); + bool syncMeshEditToGPU(SceneObject* obj); void handleKeyboardShortcuts(); void OpenProjectPath(const std::string& path); @@ -106,6 +127,7 @@ private: void renderHierarchyPanel(); void renderObjectNode(SceneObject& obj, const std::string& filter); void renderFileBrowserPanel(); + void renderMeshBuilderPanel(); void renderInspectorPanel(); void renderConsolePanel(); void renderViewport(); diff --git a/src/EnginePanels.cpp b/src/EnginePanels.cpp index ff13bed..c98cfdc 100644 --- a/src/EnginePanels.cpp +++ b/src/EnginePanels.cpp @@ -2,8 +2,11 @@ #include "ModelLoader.h" #include #include +#include #include #include +#include +#include #include #ifdef _WIN32 @@ -167,8 +170,6 @@ namespace FileIcons { // Draw an audio icon (speaker/waveform) void DrawAudioIcon(ImDrawList* drawList, ImVec2 pos, float size, ImU32 color) { - float padding = size * 0.15f; - // Speaker body float spkW = size * 0.25f; float spkH = size * 0.3f; @@ -298,7 +299,6 @@ namespace FileIcons { void Engine::renderFileBrowserPanel() { ImGui::Begin("Project", &showFileBrowser); ImGuiStyle& style = ImGui::GetStyle(); - ImVec4 accent = style.Colors[ImGuiCol_CheckMark]; ImVec4 toolbarBg = style.Colors[ImGuiCol_MenuBarBg]; toolbarBg.x = std::min(toolbarBg.x + 0.02f, 1.0f); toolbarBg.y = std::min(toolbarBg.y + 0.02f, 1.0f); @@ -606,6 +606,9 @@ void Engine::renderFileBrowserPanel() { importModelToScene(entry.path().string(), ""); } } + if (ImGui::MenuItem("Convert to Raw Mesh")) { + convertModelToRawMesh(entry.path().string()); + } } if (fileBrowser.getFileCategory(entry) == FileCategory::Material) { if (ImGui::MenuItem("Apply to Selected")) { @@ -689,6 +692,9 @@ void Engine::renderFileBrowserPanel() { } if (fileBrowser.isModelFile(entry)) { bool isObj = fileBrowser.isOBJFile(entry); + std::string ext = entry.path().extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + bool isRaw = ext == ".rmesh"; if (ImGui::MenuItem("Import to Scene")) { std::string defaultName = entry.path().stem().string(); if (isObj) { @@ -708,6 +714,9 @@ void Engine::renderFileBrowserPanel() { importModelToScene(entry.path().string(), ""); } } + if (!isRaw && ImGui::MenuItem("Convert to Raw Mesh")) { + convertModelToRawMesh(entry.path().string()); + } } if (fileBrowser.getFileCategory(entry) == FileCategory::Material) { if (ImGui::MenuItem("Apply to Selected")) { @@ -761,6 +770,146 @@ void Engine::renderFileBrowserPanel() { ImGui::End(); } +void Engine::renderMeshBuilderPanel() { + ImGui::Begin("Mesh Builder", &showMeshBuilder); + + auto recalcBounds = [this]() { + if (!meshBuilder.hasMesh || meshBuilder.mesh.positions.empty()) return; + meshBuilder.mesh.boundsMin = glm::vec3(FLT_MAX); + meshBuilder.mesh.boundsMax = glm::vec3(-FLT_MAX); + for (const auto& p : meshBuilder.mesh.positions) { + meshBuilder.mesh.boundsMin.x = std::min(meshBuilder.mesh.boundsMin.x, p.x); + meshBuilder.mesh.boundsMin.y = std::min(meshBuilder.mesh.boundsMin.y, p.y); + meshBuilder.mesh.boundsMin.z = std::min(meshBuilder.mesh.boundsMin.z, p.z); + meshBuilder.mesh.boundsMax.x = std::max(meshBuilder.mesh.boundsMax.x, p.x); + meshBuilder.mesh.boundsMax.y = std::max(meshBuilder.mesh.boundsMax.y, p.y); + meshBuilder.mesh.boundsMax.z = std::max(meshBuilder.mesh.boundsMax.z, p.z); + } + }; + + ImGui::InputText("Mesh Path", meshBuilderPath, sizeof(meshBuilderPath)); + ImGui::SameLine(); + if (ImGui::Button("Load")) { + std::string err; + if (!meshBuilder.load(meshBuilderPath, err)) { + addConsoleMessage("MeshBuilder load failed: " + err, ConsoleMessageType::Error); + } else { + addConsoleMessage("Loaded raw mesh: " + meshBuilder.loadedPath, ConsoleMessageType::Success); + } + } + ImGui::SameLine(); + if (ImGui::Button("Save")) { + std::string err; + std::string path = strlen(meshBuilderPath) ? meshBuilderPath : meshBuilder.loadedPath; + if (!meshBuilder.save(path, err)) { + addConsoleMessage("MeshBuilder save failed: " + err, ConsoleMessageType::Error); + } else { + addConsoleMessage("Saved raw mesh: " + meshBuilder.loadedPath, ConsoleMessageType::Success); + strncpy(meshBuilderPath, meshBuilder.loadedPath.c_str(), sizeof(meshBuilderPath) - 1); + meshBuilderPath[sizeof(meshBuilderPath) - 1] = '\0'; + } + } + + if (ImGui::Button("Load Selected File")) { + if (!fileBrowser.selectedFile.empty() && fs::path(fileBrowser.selectedFile).extension() == ".rmesh") { + strncpy(meshBuilderPath, fileBrowser.selectedFile.string().c_str(), sizeof(meshBuilderPath) - 1); + meshBuilderPath[sizeof(meshBuilderPath) - 1] = '\0'; + std::string err; + if (!meshBuilder.load(meshBuilderPath, err)) { + addConsoleMessage("MeshBuilder load failed: " + err, ConsoleMessageType::Error); + } else { + addConsoleMessage("Loaded raw mesh: " + meshBuilder.loadedPath, ConsoleMessageType::Success); + } + } else { + addConsoleMessage("Select a .rmesh file in the browser to load", ConsoleMessageType::Warning); + } + } + ImGui::SameLine(); + if (ImGui::Button("Recompute Normals")) { + meshBuilder.recomputeNormals(); + } + + ImGui::Separator(); + + if (!meshBuilder.hasMesh) { + ImGui::TextDisabled("No mesh loaded."); + ImGui::End(); + return; + } + + ImGui::Text("Vertices: %zu", meshBuilder.mesh.positions.size()); + ImGui::Text("Faces: %zu", meshBuilder.mesh.faces.size()); + ImGui::Text("Bounds Min: (%.3f, %.3f, %.3f)", meshBuilder.mesh.boundsMin.x, meshBuilder.mesh.boundsMin.y, meshBuilder.mesh.boundsMin.z); + ImGui::Text("Bounds Max: (%.3f, %.3f, %.3f)", meshBuilder.mesh.boundsMax.x, meshBuilder.mesh.boundsMax.y, meshBuilder.mesh.boundsMax.z); + if (meshBuilder.dirty) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1,0.7f,0.2f,1),"*modified"); + } + + ImGui::SeparatorText("Vertices"); + ImGui::SetNextItemWidth(100); + ImGui::InputInt("Selected", &meshBuilder.selectedVertex); + if (meshBuilder.selectedVertex < 0 || meshBuilder.selectedVertex >= (int)meshBuilder.mesh.positions.size()) { + meshBuilder.selectedVertex = meshBuilder.mesh.positions.empty() ? -1 : 0; + } + + if (meshBuilder.selectedVertex >= 0 && meshBuilder.selectedVertex < (int)meshBuilder.mesh.positions.size()) { + glm::vec3& pos = meshBuilder.mesh.positions[meshBuilder.selectedVertex]; + float edit[3] = { pos.x, pos.y, pos.z }; + if (ImGui::InputFloat3("Position", edit, "%.4f")) { + pos = glm::vec3(edit[0], edit[1], edit[2]); + recalcBounds(); + meshBuilder.recomputeNormals(); + meshBuilder.dirty = true; + } + if (meshBuilder.mesh.hasUVs && meshBuilder.selectedVertex < (int)meshBuilder.mesh.uvs.size()) { + glm::vec2& uv = meshBuilder.mesh.uvs[meshBuilder.selectedVertex]; + float uvEdit[2] = { uv.x, uv.y }; + if (ImGui::InputFloat2("UV", uvEdit, "%.4f")) { + uv = glm::vec2(uvEdit[0], uvEdit[1]); + meshBuilder.dirty = true; + } + } + } + + ImGui::SeparatorText("Add Face / Fill"); + ImGui::InputTextWithHint("Indices", "e.g. 0,1,2 or 0 1 2 3", meshBuilderFaceInput, sizeof(meshBuilderFaceInput)); + ImGui::SameLine(); + if (ImGui::Button("Add Face")) { + std::vector indices; + std::string token; + std::stringstream ss(meshBuilderFaceInput); + while (std::getline(ss, token, ',')) { + std::stringstream inner(token); + std::string sub; + while (inner >> sub) { + try { + uint32_t idx = static_cast(std::stoul(sub)); + indices.push_back(idx); + } catch (...) {} + } + } + if (indices.empty()) { + addConsoleMessage("Enter vertex indices separated by commas or spaces", ConsoleMessageType::Warning); + } else { + std::string err; + if (!meshBuilder.addFace(indices, err)) { + addConsoleMessage("Add face failed: " + err, ConsoleMessageType::Error); + } else { + addConsoleMessage("Added face with " + std::to_string(indices.size()) + " verts", ConsoleMessageType::Success); + } + } + } + + ImGui::SeparatorText("Faces (first 16)"); + int maxFaces = std::min(16, meshBuilder.mesh.faces.size()); + for (int i = 0; i < maxFaces; i++) { + const auto& f = meshBuilder.mesh.faces[i]; + ImGui::Text("%d: %u, %u, %u", i, f.x, f.y, f.z); + } + + ImGui::End(); +} void Engine::renderLauncher() { ImGuiIO& io = ImGui::GetIO(); @@ -1109,7 +1258,7 @@ void Engine::renderMainMenuBar() { } projectManager.currentProject = Project(); sceneObjects.clear(); - selectedObjectId = -1; + clearSelection(); showLauncher = true; } ImGui::Separator(); @@ -1131,6 +1280,7 @@ void Engine::renderMainMenuBar() { ImGui::MenuItem("File Browser", nullptr, &showFileBrowser); ImGui::MenuItem("Console", nullptr, &showConsole); ImGui::MenuItem("Project Manager", nullptr, &showProjectBrowser); + ImGui::MenuItem("Mesh Builder", nullptr, &showMeshBuilder); ImGui::MenuItem("Environment", nullptr, &showEnvironmentWindow); ImGui::MenuItem("Camera", nullptr, &showCameraWindow); ImGui::MenuItem("View Output", nullptr, &showViewOutput); @@ -1219,7 +1369,6 @@ void Engine::renderHierarchyPanel() { headerBg.x = std::min(headerBg.x + 0.02f, 1.0f); headerBg.y = std::min(headerBg.y + 0.02f, 1.0f); headerBg.z = std::min(headerBg.z + 0.02f, 1.0f); - ImVec4 headerText = style.Colors[ImGuiCol_CheckMark]; ImVec4 listBg = style.Colors[ImGuiCol_WindowBg]; listBg.x = std::min(listBg.x + 0.01f, 1.0f); listBg.y = std::min(listBg.y + 0.01f, 1.0f); @@ -1311,7 +1460,7 @@ void Engine::renderObjectNode(SceneObject& obj, const std::string& filter) { } bool hasChildren = !obj.childIds.empty(); - bool isSelected = (selectedObjectId == obj.id); + bool isSelected = std::find(selectedObjectIds.begin(), selectedObjectIds.end(), obj.id) != selectedObjectIds.end(); ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanAvailWidth; if (isSelected) flags |= ImGuiTreeNodeFlags_Selected; @@ -1335,7 +1484,8 @@ void Engine::renderObjectNode(SceneObject& obj, const std::string& filter) { bool nodeOpen = ImGui::TreeNodeEx((void*)(intptr_t)obj.id, flags, "%s %s", icon, obj.name.c_str()); if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) { - selectedObjectId = obj.id; + bool additive = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift; + setPrimarySelection(obj.id, additive); } if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_None)) { @@ -1356,11 +1506,11 @@ void Engine::renderObjectNode(SceneObject& obj, const std::string& filter) { if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem("Duplicate")) { - selectedObjectId = obj.id; + setPrimarySelection(obj.id); duplicateSelected(); } if (ImGui::MenuItem("Delete")) { - selectedObjectId = obj.id; + setPrimarySelection(obj.id); deleteSelected(); } ImGui::Separator(); @@ -1591,7 +1741,7 @@ void Engine::renderInspectorPanel() { ImGui::PopStyleColor(); }; - if (selectedObjectId == -1) { + if (selectedObjectIds.empty()) { if (browserHasMaterial) { renderMaterialAssetPanel("Material Asset", true); } else { @@ -1601,8 +1751,9 @@ void Engine::renderInspectorPanel() { return; } + int primaryId = selectedObjectId; auto it = std::find_if(sceneObjects.begin(), sceneObjects.end(), - [this](const SceneObject& obj) { return obj.id == selectedObjectId; }); + [primaryId](const SceneObject& obj) { return obj.id == primaryId; }); if (it == sceneObjects.end()) { ImGui::TextDisabled("Object not found"); @@ -1612,6 +1763,11 @@ void Engine::renderInspectorPanel() { SceneObject& obj = *it; + if (selectedObjectIds.size() > 1) { + ImGui::Text("Multiple objects selected: %zu", selectedObjectIds.size()); + ImGui::Separator(); + } + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.2f, 0.4f, 0.6f, 1.0f)); if (ImGui::CollapsingHeader("Object Info", ImGuiTreeNodeFlags_DefaultOpen)) { @@ -2474,7 +2630,6 @@ void Engine::renderViewport() { ImGuizmo::Enable(true); ImGuizmo::SetOrthographic(false); ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList()); - ImGuizmo::SetRect( imageMin.x, imageMin.y, @@ -2482,141 +2637,429 @@ void Engine::renderViewport() { imageMax.y - imageMin.y ); + auto compose = [](const SceneObject& o) { + glm::mat4 m(1.0f); + m = glm::translate(m, o.position); + m = glm::rotate(m, glm::radians(o.rotation.x), glm::vec3(1, 0, 0)); + m = glm::rotate(m, glm::radians(o.rotation.y), glm::vec3(0, 1, 0)); + m = glm::rotate(m, glm::radians(o.rotation.z), glm::vec3(0, 0, 1)); + m = glm::scale(m, o.scale); + return m; + }; + + bool meshModeActive = meshEditMode && ensureMeshEditTarget(selectedObj); + + glm::vec3 pivotPos = selectedObj->position; + if (!meshModeActive && selectedObjectIds.size() > 1 && mCurrentGizmoMode == ImGuizmo::WORLD) { + pivotPos = getSelectionCenterWorld(true); + } + glm::mat4 modelMatrix(1.0f); - modelMatrix = glm::translate(modelMatrix, selectedObj->position); + modelMatrix = glm::translate(modelMatrix, pivotPos); modelMatrix = glm::rotate(modelMatrix, glm::radians(selectedObj->rotation.x), glm::vec3(1, 0, 0)); modelMatrix = glm::rotate(modelMatrix, glm::radians(selectedObj->rotation.y), glm::vec3(0, 1, 0)); modelMatrix = glm::rotate(modelMatrix, glm::radians(selectedObj->rotation.z), glm::vec3(0, 0, 1)); modelMatrix = glm::scale(modelMatrix, selectedObj->scale); + glm::mat4 originalModel = modelMatrix; - float* snapPtr = nullptr; - float snapRot[3] = { rotationSnapValue, rotationSnapValue, rotationSnapValue }; - - if (useSnap) { - if (mCurrentGizmoOperation == ImGuizmo::ROTATE) { - snapPtr = snapRot; - } else { - snapPtr = snapValue; - } - } - - glm::vec3 gizmoBoundsMin(-0.5f); - glm::vec3 gizmoBoundsMax(0.5f); - - switch (selectedObj->type) { - case ObjectType::Cube: - gizmoBoundsMin = glm::vec3(-0.5f); - gizmoBoundsMax = glm::vec3(0.5f); - break; - case ObjectType::Sphere: - gizmoBoundsMin = glm::vec3(-0.5f); - gizmoBoundsMax = glm::vec3(0.5f); - break; - case ObjectType::Capsule: - gizmoBoundsMin = glm::vec3(-0.35f, -0.9f, -0.35f); - gizmoBoundsMax = glm::vec3(0.35f, 0.9f, 0.35f); - break; - case ObjectType::OBJMesh: { - const auto* info = g_objLoader.getMeshInfo(selectedObj->meshId); - if (info && info->boundsMin.x < info->boundsMax.x) { - gizmoBoundsMin = info->boundsMin; - gizmoBoundsMax = info->boundsMax; - } - break; - } - case ObjectType::Model: { - const auto* info = getModelLoader().getMeshInfo(selectedObj->meshId); - if (info && info->boundsMin.x < info->boundsMax.x) { - gizmoBoundsMin = info->boundsMin; - gizmoBoundsMax = info->boundsMax; - } - break; - } - case ObjectType::Camera: - gizmoBoundsMin = glm::vec3(-0.3f); - gizmoBoundsMax = glm::vec3(0.3f); - break; - case ObjectType::DirectionalLight: - case ObjectType::PointLight: - case ObjectType::SpotLight: - case ObjectType::AreaLight: - gizmoBoundsMin = glm::vec3(-0.3f); - gizmoBoundsMax = glm::vec3(0.3f); - break; - case ObjectType::PostFXNode: - gizmoBoundsMin = glm::vec3(-0.25f); - gizmoBoundsMax = glm::vec3(0.25f); - break; - } - - float bounds[6] = { - gizmoBoundsMin.x, gizmoBoundsMin.y, gizmoBoundsMin.z, - gizmoBoundsMax.x, gizmoBoundsMax.y, gizmoBoundsMax.z - }; - float boundsSnap[3] = { snapValue[0], snapValue[1], snapValue[2] }; - const float* boundsPtr = (mCurrentGizmoOperation == ImGuizmo::BOUNDS) ? bounds : nullptr; - const float* boundsSnapPtr = (useSnap && mCurrentGizmoOperation == ImGuizmo::BOUNDS) ? boundsSnap : nullptr; - - ImGuizmo::Manipulate( - glm::value_ptr(view), - glm::value_ptr(proj), - mCurrentGizmoOperation, - mCurrentGizmoMode, - glm::value_ptr(modelMatrix), - nullptr, - snapPtr, - boundsPtr, - boundsSnapPtr - ); - - std::array corners = { - glm::vec3(gizmoBoundsMin.x, gizmoBoundsMin.y, gizmoBoundsMin.z), - glm::vec3(gizmoBoundsMax.x, gizmoBoundsMin.y, gizmoBoundsMin.z), - glm::vec3(gizmoBoundsMax.x, gizmoBoundsMax.y, gizmoBoundsMin.z), - glm::vec3(gizmoBoundsMin.x, gizmoBoundsMax.y, gizmoBoundsMin.z), - glm::vec3(gizmoBoundsMin.x, gizmoBoundsMin.y, gizmoBoundsMax.z), - glm::vec3(gizmoBoundsMax.x, gizmoBoundsMin.y, gizmoBoundsMax.z), - glm::vec3(gizmoBoundsMax.x, gizmoBoundsMax.y, gizmoBoundsMax.z), - glm::vec3(gizmoBoundsMin.x, gizmoBoundsMax.y, gizmoBoundsMax.z), - }; - - std::array projected{}; - bool allProjected = true; - for (size_t i = 0; i < corners.size(); ++i) { - glm::vec3 world = glm::vec3(modelMatrix * glm::vec4(corners[i], 1.0f)); - auto p = projectToScreen(world); - if (!p.has_value()) { allProjected = false; break; } - projected[i] = *p; - } - - if (allProjected) { - ImDrawList* dl = ImGui::GetWindowDrawList(); - ImU32 col = ImGui::GetColorU32(ImVec4(1.0f, 0.93f, 0.35f, 0.45f)); - const int edges[12][2] = { - {0,1},{1,2},{2,3},{3,0}, - {4,5},{5,6},{6,7},{7,4}, - {0,4},{1,5},{2,6},{3,7} + if (meshModeActive && !meshEditAsset.positions.empty()) { + // Build helper edge list (dedup) for edge/face modes + std::vector edges; + edges.reserve(meshEditAsset.faces.size() * 3); + std::unordered_set edgeSet; + auto edgeKey = [](uint32_t a, uint32_t b) { + return (static_cast(std::min(a,b)) << 32) | static_cast(std::max(a,b)); }; - for (auto& e : edges) { - dl->AddLine(projected[e[0]], projected[e[1]], col, 2.0f); + for (size_t fi = 0; fi < meshEditAsset.faces.size(); ++fi) { + const auto& f = meshEditAsset.faces[fi]; + uint32_t tri[3] = { f.x, f.y, f.z }; + for (int e = 0; e < 3; ++e) { + uint32_t a = tri[e]; + uint32_t b = tri[(e+1)%3]; + uint64_t key = edgeKey(a,b); + if (edgeSet.insert(key).second) { + edges.push_back(glm::u32vec2(std::min(a,b), std::max(a,b))); + } + } } - } - if (ImGuizmo::IsUsing()) { - if (!gizmoHistoryCaptured) { - recordState("gizmo"); - gizmoHistoryCaptured = true; + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImU32 vertCol = ImGui::GetColorU32(ImVec4(0.35f, 0.75f, 1.0f, 0.9f)); + ImU32 selCol = ImGui::GetColorU32(ImVec4(1.0f, 0.6f, 0.2f, 1.0f)); + ImU32 edgeCol = ImGui::GetColorU32(ImVec4(0.6f, 0.9f, 1.0f, 0.6f)); + ImU32 faceCol = ImGui::GetColorU32(ImVec4(1.0f, 0.8f, 0.4f, 0.7f)); + + float selectRadius = 10.0f; + ImVec2 mouse = ImGui::GetIO().MousePos; + bool clicked = mouseOverViewportImage && ImGui::IsMouseClicked(0); + bool additiveClick = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift; + float bestDist = selectRadius; + int clickedIndex = -1; + + glm::mat4 invModel = glm::inverse(modelMatrix); + + if (meshEditSelectionMode == MeshEditSelectionMode::Vertex) { + const size_t maxDraw = std::min(meshEditAsset.positions.size(), 2000); + for (size_t i = 0; i < maxDraw; ++i) { + glm::vec3 world = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[i], 1.0f)); + auto screen = projectToScreen(world); + if (!screen) continue; + bool sel = std::find(meshEditSelectedVertices.begin(), meshEditSelectedVertices.end(), (int)i) != meshEditSelectedVertices.end(); + float radius = sel ? 6.5f : 5.0f; + dl->AddCircleFilled(*screen, radius, sel ? selCol : vertCol); + + if (clicked) { + float dx = screen->x - mouse.x; + float dy = screen->y - mouse.y; + float dist = std::sqrt(dx*dx + dy*dy); + if (dist < bestDist) { + bestDist = dist; + clickedIndex = static_cast(i); + } + } + } + + if (clickedIndex >= 0) { + if (additiveClick) { + auto itSel = std::find(meshEditSelectedVertices.begin(), meshEditSelectedVertices.end(), clickedIndex); + if (itSel == meshEditSelectedVertices.end()) { + meshEditSelectedVertices.push_back(clickedIndex); + } else { + meshEditSelectedVertices.erase(itSel); + } + } else { + meshEditSelectedVertices.clear(); + meshEditSelectedVertices.push_back(clickedIndex); + } + meshEditSelectedEdges.clear(); + meshEditSelectedFaces.clear(); + } + + if (meshEditSelectedVertices.empty()) { + meshEditSelectedVertices.push_back(0); + } + } else if (meshEditSelectionMode == MeshEditSelectionMode::Edge) { + for (size_t ei = 0; ei < edges.size(); ++ei) { + const auto& e = edges[ei]; + glm::vec3 a = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[e.x], 1.0f)); + glm::vec3 b = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[e.y], 1.0f)); + auto sa = projectToScreen(a); + auto sb = projectToScreen(b); + if (!sa || !sb) continue; + ImVec2 mid = ImVec2((sa->x + sb->x) * 0.5f, (sa->y + sb->y) * 0.5f); + bool sel = std::find(meshEditSelectedEdges.begin(), meshEditSelectedEdges.end(), (int)ei) != meshEditSelectedEdges.end(); + dl->AddLine(*sa, *sb, edgeCol, sel ? 3.0f : 2.0f); + dl->AddCircleFilled(mid, sel ? 6.0f : 4.0f, sel ? selCol : edgeCol); + + if (clicked) { + float dx = mid.x - mouse.x; + float dy = mid.y - mouse.y; + float dist = std::sqrt(dx*dx + dy*dy); + if (dist < bestDist) { + bestDist = dist; + clickedIndex = static_cast(ei); + } + } + } + if (clickedIndex >= 0) { + if (additiveClick) { + auto itSel = std::find(meshEditSelectedEdges.begin(), meshEditSelectedEdges.end(), clickedIndex); + if (itSel == meshEditSelectedEdges.end()) { + meshEditSelectedEdges.push_back(clickedIndex); + } else { + meshEditSelectedEdges.erase(itSel); + } + } else { + meshEditSelectedEdges.clear(); + meshEditSelectedEdges.push_back(clickedIndex); + } + meshEditSelectedVertices.clear(); + meshEditSelectedFaces.clear(); + } + if (meshEditSelectedEdges.empty() && !edges.empty()) { + meshEditSelectedEdges.push_back(0); + } + } else if (meshEditSelectionMode == MeshEditSelectionMode::Face) { + for (size_t fi = 0; fi < meshEditAsset.faces.size(); ++fi) { + const auto& f = meshEditAsset.faces[fi]; + glm::vec3 a = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[f.x], 1.0f)); + glm::vec3 b = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[f.y], 1.0f)); + glm::vec3 c = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[f.z], 1.0f)); + glm::vec3 centroid = (a + b + c) / 3.0f; + auto sc = projectToScreen(centroid); + if (!sc) continue; + bool sel = std::find(meshEditSelectedFaces.begin(), meshEditSelectedFaces.end(), (int)fi) != meshEditSelectedFaces.end(); + dl->AddCircleFilled(*sc, sel ? 7.0f : 5.0f, sel ? selCol : faceCol); + + if (clicked) { + float dx = sc->x - mouse.x; + float dy = sc->y - mouse.y; + float dist = std::sqrt(dx*dx + dy*dy); + if (dist < bestDist) { + bestDist = dist; + clickedIndex = static_cast(fi); + } + } + } + if (clickedIndex >= 0) { + if (additiveClick) { + auto itSel = std::find(meshEditSelectedFaces.begin(), meshEditSelectedFaces.end(), clickedIndex); + if (itSel == meshEditSelectedFaces.end()) { + meshEditSelectedFaces.push_back(clickedIndex); + } else { + meshEditSelectedFaces.erase(itSel); + } + } else { + meshEditSelectedFaces.clear(); + meshEditSelectedFaces.push_back(clickedIndex); + } + meshEditSelectedVertices.clear(); + meshEditSelectedEdges.clear(); + } + if (meshEditSelectedFaces.empty() && !meshEditAsset.faces.empty()) { + meshEditSelectedFaces.push_back(0); + } } - float t[3], r[3], s[3]; - ImGuizmo::DecomposeMatrixToComponents(glm::value_ptr(modelMatrix), t, r, s); - selectedObj->position = glm::vec3(t[0], t[1], t[2]); - selectedObj->rotation = glm::vec3(r[0], r[1], r[2]); - selectedObj->scale = glm::vec3(s[0], s[1], s[2]); + // Compute affected vertices from selection + std::vector affectedVerts = meshEditSelectedVertices; + auto pushUnique = [&](int idx) { + if (idx < 0) return; + if (std::find(affectedVerts.begin(), affectedVerts.end(), idx) == affectedVerts.end()) { + affectedVerts.push_back(idx); + } + }; + if (meshEditSelectionMode == MeshEditSelectionMode::Edge) { + for (int ei : meshEditSelectedEdges) { + if (ei < 0 || ei >= (int)edges.size()) continue; + pushUnique(edges[ei].x); + pushUnique(edges[ei].y); + } + } else if (meshEditSelectionMode == MeshEditSelectionMode::Face) { + for (int fi : meshEditSelectedFaces) { + if (fi < 0 || fi >= (int)meshEditAsset.faces.size()) continue; + const auto& f = meshEditAsset.faces[fi]; + pushUnique(f.x); + pushUnique(f.y); + pushUnique(f.z); + } + } + if (affectedVerts.empty() && !meshEditAsset.positions.empty()) { + affectedVerts.push_back(0); + } - projectManager.currentProject.hasUnsavedChanges = true; + glm::vec3 pivotWorld(0.0f); + for (int idx : affectedVerts) { + glm::vec3 wp = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[idx], 1.0f)); + pivotWorld += wp; + } + pivotWorld /= (float)affectedVerts.size(); + + glm::mat4 gizmoMat = glm::translate(glm::mat4(1.0f), pivotWorld); + + ImGuizmo::Manipulate( + glm::value_ptr(view), + glm::value_ptr(proj), + ImGuizmo::TRANSLATE, + ImGuizmo::WORLD, + glm::value_ptr(gizmoMat) + ); + + static bool meshEditHistoryCaptured = false; + if (ImGuizmo::IsUsing()) { + if (!meshEditHistoryCaptured) { + recordState("meshEdit"); + meshEditHistoryCaptured = true; + } + glm::vec3 deltaWorld = glm::vec3(gizmoMat[3]) - pivotWorld; + for (int idx : affectedVerts) { + glm::vec3 wp = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[idx], 1.0f)); + wp += deltaWorld; + glm::vec3 newLocal = glm::vec3(invModel * glm::vec4(wp, 1.0f)); + meshEditAsset.positions[idx] = newLocal; + } + + // Recompute bounds + meshEditAsset.boundsMin = glm::vec3(FLT_MAX); + meshEditAsset.boundsMax = glm::vec3(-FLT_MAX); + for (const auto& p : meshEditAsset.positions) { + meshEditAsset.boundsMin.x = std::min(meshEditAsset.boundsMin.x, p.x); + meshEditAsset.boundsMin.y = std::min(meshEditAsset.boundsMin.y, p.y); + meshEditAsset.boundsMin.z = std::min(meshEditAsset.boundsMin.z, p.z); + meshEditAsset.boundsMax.x = std::max(meshEditAsset.boundsMax.x, p.x); + meshEditAsset.boundsMax.y = std::max(meshEditAsset.boundsMax.y, p.y); + meshEditAsset.boundsMax.z = std::max(meshEditAsset.boundsMax.z, p.z); + } + + // Recompute normals + meshEditAsset.normals.assign(meshEditAsset.positions.size(), glm::vec3(0.0f)); + for (const auto& f : meshEditAsset.faces) { + if (f.x >= meshEditAsset.positions.size() || f.y >= meshEditAsset.positions.size() || f.z >= meshEditAsset.positions.size()) continue; + const glm::vec3& a = meshEditAsset.positions[f.x]; + const glm::vec3& b = meshEditAsset.positions[f.y]; + const glm::vec3& c = meshEditAsset.positions[f.z]; + glm::vec3 n = glm::normalize(glm::cross(b - a, c - a)); + meshEditAsset.normals[f.x] += n; + meshEditAsset.normals[f.y] += n; + meshEditAsset.normals[f.z] += n; + } + for (auto& n : meshEditAsset.normals) { + if (glm::length(n) > 1e-6f) n = glm::normalize(n); + } + meshEditAsset.hasNormals = true; + + syncMeshEditToGPU(selectedObj); + } else { + meshEditHistoryCaptured = false; + } } else { - gizmoHistoryCaptured = false; + // Object transform mode + float* snapPtr = nullptr; + float snapRot[3] = { rotationSnapValue, rotationSnapValue, rotationSnapValue }; + + if (useSnap) { + if (mCurrentGizmoOperation == ImGuizmo::ROTATE) { + snapPtr = snapRot; + } else { + snapPtr = snapValue; + } + } + + glm::vec3 gizmoBoundsMin(-0.5f); + glm::vec3 gizmoBoundsMax(0.5f); + + switch (selectedObj->type) { + case ObjectType::Cube: + gizmoBoundsMin = glm::vec3(-0.5f); + gizmoBoundsMax = glm::vec3(0.5f); + break; + case ObjectType::Sphere: + gizmoBoundsMin = glm::vec3(-0.5f); + gizmoBoundsMax = glm::vec3(0.5f); + break; + case ObjectType::Capsule: + gizmoBoundsMin = glm::vec3(-0.35f, -0.9f, -0.35f); + gizmoBoundsMax = glm::vec3(0.35f, 0.9f, 0.35f); + break; + case ObjectType::OBJMesh: { + const auto* info = g_objLoader.getMeshInfo(selectedObj->meshId); + if (info && info->boundsMin.x < info->boundsMax.x) { + gizmoBoundsMin = info->boundsMin; + gizmoBoundsMax = info->boundsMax; + } + break; + } + case ObjectType::Model: { + const auto* info = getModelLoader().getMeshInfo(selectedObj->meshId); + if (info && info->boundsMin.x < info->boundsMax.x) { + gizmoBoundsMin = info->boundsMin; + gizmoBoundsMax = info->boundsMax; + } + break; + } + case ObjectType::Camera: + gizmoBoundsMin = glm::vec3(-0.3f); + gizmoBoundsMax = glm::vec3(0.3f); + break; + case ObjectType::DirectionalLight: + case ObjectType::PointLight: + case ObjectType::SpotLight: + case ObjectType::AreaLight: + gizmoBoundsMin = glm::vec3(-0.3f); + gizmoBoundsMax = glm::vec3(0.3f); + break; + case ObjectType::PostFXNode: + gizmoBoundsMin = glm::vec3(-0.25f); + gizmoBoundsMax = glm::vec3(0.25f); + break; + } + + float bounds[6] = { + gizmoBoundsMin.x, gizmoBoundsMin.y, gizmoBoundsMin.z, + gizmoBoundsMax.x, gizmoBoundsMax.y, gizmoBoundsMax.z + }; + float boundsSnap[3] = { snapValue[0], snapValue[1], snapValue[2] }; + const float* boundsPtr = (mCurrentGizmoOperation == ImGuizmo::BOUNDS) ? bounds : nullptr; + const float* boundsSnapPtr = (useSnap && mCurrentGizmoOperation == ImGuizmo::BOUNDS) ? boundsSnap : nullptr; + + ImGuizmo::Manipulate( + glm::value_ptr(view), + glm::value_ptr(proj), + mCurrentGizmoOperation, + mCurrentGizmoMode, + glm::value_ptr(modelMatrix), + nullptr, + snapPtr, + boundsPtr, + boundsSnapPtr + ); + + std::array corners = { + glm::vec3(gizmoBoundsMin.x, gizmoBoundsMin.y, gizmoBoundsMin.z), + glm::vec3(gizmoBoundsMax.x, gizmoBoundsMin.y, gizmoBoundsMin.z), + glm::vec3(gizmoBoundsMax.x, gizmoBoundsMax.y, gizmoBoundsMin.z), + glm::vec3(gizmoBoundsMin.x, gizmoBoundsMax.y, gizmoBoundsMin.z), + glm::vec3(gizmoBoundsMin.x, gizmoBoundsMin.y, gizmoBoundsMax.z), + glm::vec3(gizmoBoundsMax.x, gizmoBoundsMin.y, gizmoBoundsMax.z), + glm::vec3(gizmoBoundsMax.x, gizmoBoundsMax.y, gizmoBoundsMax.z), + glm::vec3(gizmoBoundsMin.x, gizmoBoundsMax.y, gizmoBoundsMax.z), + }; + + std::array projected{}; + bool allProjected = true; + for (size_t i = 0; i < corners.size(); ++i) { + glm::vec3 world = glm::vec3(modelMatrix * glm::vec4(corners[i], 1.0f)); + auto p = projectToScreen(world); + if (!p.has_value()) { allProjected = false; break; } + projected[i] = *p; + } + + if (allProjected) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImU32 col = ImGui::GetColorU32(ImVec4(1.0f, 0.93f, 0.35f, 0.45f)); + const int edges[12][2] = { + {0,1},{1,2},{2,3},{3,0}, + {4,5},{5,6},{6,7},{7,4}, + {0,4},{1,5},{2,6},{3,7} + }; + for (auto& e : edges) { + dl->AddLine(projected[e[0]], projected[e[1]], col, 2.0f); + } + } + + if (ImGuizmo::IsUsing()) { + if (!gizmoHistoryCaptured) { + recordState("gizmo"); + gizmoHistoryCaptured = true; + } + glm::mat4 delta = modelMatrix * glm::inverse(originalModel); + + auto applyDelta = [&](SceneObject& o) { + glm::mat4 m = compose(o); + glm::mat4 newM = delta * m; + glm::vec3 t, r, s; + DecomposeMatrix(newM, t, r, s); + o.position = t; + o.rotation = glm::degrees(r); + o.scale = s; + }; + + if (selectedObjectIds.size() <= 1) { + applyDelta(*selectedObj); + } else { + for (int id : selectedObjectIds) { + auto itObj = std::find_if(sceneObjects.begin(), sceneObjects.end(), + [id](const SceneObject& o){ return o.id == id; }); + if (itObj != sceneObjects.end()) { + applyDelta(*itObj); + } + } + } + + projectManager.currentProject.hasUnsavedChanges = true; + } else { + gizmoHistoryCaptured = false; + } } } @@ -2727,6 +3170,40 @@ void Engine::renderViewport() { ImGui::SameLine(0.0f, toolbarSpacing); gizmoButton("Scale", ImGuizmo::SCALE, "Scale"); ImGui::SameLine(0.0f, toolbarSpacing); + bool canMeshEdit = false; + if (selectedObj) { + std::string ext = fs::path(selectedObj->meshPath).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + canMeshEdit = ext == ".rmesh"; + } + ImGui::BeginDisabled(!canMeshEdit); + if (GizmoToolbar::ModeButton("Mesh Edit", meshEditMode, gizmoButtonSize, baseCol, accentCol, textCol)) { + meshEditMode = !meshEditMode; + if (!meshEditMode) { + meshEditLoaded = false; + meshEditPath.clear(); + meshEditSelectedVertices.clear(); + meshEditSelectedEdges.clear(); + meshEditSelectedFaces.clear(); + } + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Toggle mesh vertex edit mode"); + ImGui::EndDisabled(); + if (meshEditMode) { + ImGui::SameLine(0.0f, toolbarSpacing); + if (GizmoToolbar::ModeButton("Verts", meshEditSelectionMode == MeshEditSelectionMode::Vertex, ImVec2(50,24), baseCol, accentCol, textCol)) { + meshEditSelectionMode = MeshEditSelectionMode::Vertex; + } + ImGui::SameLine(0.0f, toolbarSpacing * 0.6f); + if (GizmoToolbar::ModeButton("Edges", meshEditSelectionMode == MeshEditSelectionMode::Edge, ImVec2(50,24), baseCol, accentCol, textCol)) { + meshEditSelectionMode = MeshEditSelectionMode::Edge; + } + ImGui::SameLine(0.0f, toolbarSpacing * 0.6f); + if (GizmoToolbar::ModeButton("Faces", meshEditSelectionMode == MeshEditSelectionMode::Face, ImVec2(50,24), baseCol, accentCol, textCol)) { + meshEditSelectionMode = MeshEditSelectionMode::Face; + } + } + ImGui::SameLine(0.0f, toolbarSpacing); gizmoButton("Rect", ImGuizmo::BOUNDS, "Rect scale"); ImGui::SameLine(0.0f, toolbarSpacing); gizmoButton("Uni", ImGuizmo::UNIVERSAL, "Universal"); @@ -2952,9 +3429,10 @@ void Engine::renderViewport() { viewportController.setFocused(true); if (hitId != -1) { - selectedObjectId = hitId; + bool additive = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift; + setPrimarySelection(hitId, additive); } else { - selectedObjectId = -1; + clearSelection(); } } diff --git a/src/MeshBuilder.cpp b/src/MeshBuilder.cpp new file mode 100644 index 0000000..4c8b30f --- /dev/null +++ b/src/MeshBuilder.cpp @@ -0,0 +1,105 @@ +#include "MeshBuilder.h" + +void MeshBuilder::clear() { + mesh = RawMeshAsset(); + hasMesh = false; + dirty = false; + selectedVertex = -1; + loadedPath.clear(); +} + +bool MeshBuilder::load(const std::string& path, std::string& error) { + auto& loader = getModelLoader(); + RawMeshAsset loaded; + if (!loader.loadRawMesh(path, loaded, error)) { + return false; + } + mesh = std::move(loaded); + hasMesh = true; + dirty = false; + loadedPath = path; + selectedVertex = mesh.positions.empty() ? -1 : 0; + return true; +} + +bool MeshBuilder::save(const std::string& path, std::string& error) { + if (!hasMesh) { + error = "No mesh loaded"; + return false; + } + auto& loader = getModelLoader(); + if (!loader.saveRawMesh(mesh, path, error)) { + return false; + } + loadedPath = path; + dirty = false; + return true; +} + +void MeshBuilder::recomputeNormals() { + if (mesh.positions.empty()) return; + mesh.normals.assign(mesh.positions.size(), glm::vec3(0.0f)); + + for (const auto& face : mesh.faces) { + if (face.x >= mesh.positions.size() || + face.y >= mesh.positions.size() || + face.z >= mesh.positions.size()) continue; + const glm::vec3& a = mesh.positions[face.x]; + const glm::vec3& b = mesh.positions[face.y]; + const glm::vec3& c = mesh.positions[face.z]; + glm::vec3 n = glm::normalize(glm::cross(b - a, c - a)); + mesh.normals[face.x] += n; + mesh.normals[face.y] += n; + mesh.normals[face.z] += n; + } + + for (auto& n : mesh.normals) { + if (glm::length(n) > 1e-6f) { + n = glm::normalize(n); + } + } + mesh.hasNormals = true; + dirty = true; +} + +bool MeshBuilder::addFace(const std::vector& indices, std::string& error) { + if (indices.size() < 3) { + error = "Need at least 3 vertices to form a face"; + return false; + } + for (uint32_t idx : indices) { + if (idx >= mesh.positions.size()) { + error = "Vertex index out of range"; + return false; + } + } + + auto addTri = [&](uint32_t a, uint32_t b, uint32_t c) { + mesh.faces.push_back(glm::u32vec3(a, b, c)); + }; + + if (indices.size() == 3) { + addTri(indices[0], indices[1], indices[2]); + } else { + // Fan triangulation for n-gon + for (size_t i = 1; i + 1 < indices.size(); i++) { + addTri(indices[0], indices[i], indices[i + 1]); + } + } + + // Update bounds + mesh.boundsMin = glm::vec3(FLT_MAX); + mesh.boundsMax = glm::vec3(-FLT_MAX); + for (const auto& p : mesh.positions) { + mesh.boundsMin.x = std::min(mesh.boundsMin.x, p.x); + mesh.boundsMin.y = std::min(mesh.boundsMin.y, p.y); + mesh.boundsMin.z = std::min(mesh.boundsMin.z, p.z); + mesh.boundsMax.x = std::max(mesh.boundsMax.x, p.x); + mesh.boundsMax.y = std::max(mesh.boundsMax.y, p.y); + mesh.boundsMax.z = std::max(mesh.boundsMax.z, p.z); + } + + recomputeNormals(); + dirty = true; + return true; +} diff --git a/src/MeshBuilder.h b/src/MeshBuilder.h new file mode 100644 index 0000000..ca25a56 --- /dev/null +++ b/src/MeshBuilder.h @@ -0,0 +1,22 @@ +#pragma once + +#include "Common.h" +#include "ModelLoader.h" + +// Lightweight mesh editing state used by the MeshBuilder panel. +class MeshBuilder { +public: + bool hasMesh = false; + RawMeshAsset mesh; + std::string loadedPath; + bool dirty = false; + int selectedVertex = -1; + + bool load(const std::string& path, std::string& error); + bool save(const std::string& path, std::string& error); + void clear(); + void recomputeNormals(); + + // Add a new face defined by vertex indices (3 = triangle, 4 = quad fan). + bool addFace(const std::vector& indices, std::string& error); +}; diff --git a/src/ModelLoader.cpp b/src/ModelLoader.cpp index d544a72..f40fd6b 100644 --- a/src/ModelLoader.cpp +++ b/src/ModelLoader.cpp @@ -1,12 +1,17 @@ #include "ModelLoader.h" #include #include +#include +#include +#include ModelLoader& ModelLoader::getInstance() { static ModelLoader instance; return instance; } +static void collectRawMeshData(aiNode* node, const aiScene* scene, const aiMatrix4x4& parentTransform, RawMeshAsset& out); + ModelLoader& getModelLoader() { return ModelLoader::getInstance(); } @@ -38,6 +43,7 @@ std::vector ModelLoader::getSupportedFormats() { {".bvh", "Biovision BVH", true}, {".csm", "CharacterStudio Motion", true}, {".irrmesh", "Irrlicht Mesh", false}, + {".rmesh", "Modularity Raw Mesh", false}, {".irr", "Irrlicht Scene", false}, {".mdl", "Quake MDL", true}, {".md2", "Quake II MD2", true}, @@ -77,6 +83,8 @@ bool ModelLoader::isSupported(const std::string& filepath) const { ModelLoadResult ModelLoader::loadModel(const std::string& filepath) { ModelLoadResult result; + std::string extLower = fs::path(filepath).extension().string(); + std::transform(extLower.begin(), extLower.end(), extLower.begin(), ::tolower); // Check if already loaded for (size_t i = 0; i < loadedMeshes.size(); i++) { @@ -97,6 +105,87 @@ ModelLoadResult ModelLoader::loadModel(const std::string& filepath) { result.errorMessage = "Unsupported file format: " + fs::path(filepath).extension().string(); return result; } + + // Handle native raw mesh import without Assimp + if (extLower == ".rmesh") { + RawMeshAsset raw; + std::string error; + if (!loadRawMesh(filepath, raw, error)) { + result.errorMessage = error; + return result; + } + + // Build interleaved triangle list for GPU upload + std::vector vertices; + vertices.reserve(raw.faces.size() * 3 * 8); + std::vector triPositions; + triPositions.reserve(raw.faces.size() * 3); + + auto getPos = [&](uint32_t idx) -> const glm::vec3& { return raw.positions[idx]; }; + auto getNorm = [&](uint32_t idx) -> glm::vec3 { + if (idx < raw.normals.size()) return raw.normals[idx]; + return glm::vec3(0.0f); + }; + auto getUV = [&](uint32_t idx) -> glm::vec2 { + if (idx < raw.uvs.size()) return raw.uvs[idx]; + return glm::vec2(0.0f); + }; + + for (const auto& face : raw.faces) { + const uint32_t idx[3] = { face.x, face.y, face.z }; + glm::vec3 faceNormal(0.0f); + if (!raw.hasNormals) { + const glm::vec3& a = getPos(idx[0]); + const glm::vec3& b = getPos(idx[1]); + const glm::vec3& c = getPos(idx[2]); + faceNormal = glm::normalize(glm::cross(b - a, c - a)); + } + for (int i = 0; i < 3; i++) { + glm::vec3 pos = getPos(idx[i]); + glm::vec3 n = raw.hasNormals ? getNorm(idx[i]) : faceNormal; + glm::vec2 uv = raw.hasUVs ? getUV(idx[i]) : glm::vec2(0.0f); + + triPositions.push_back(pos); + vertices.push_back(pos.x); + vertices.push_back(pos.y); + vertices.push_back(pos.z); + vertices.push_back(n.x); + vertices.push_back(n.y); + vertices.push_back(n.z); + vertices.push_back(uv.x); + vertices.push_back(uv.y); + } + } + + if (vertices.empty()) { + result.errorMessage = "No triangles found in raw mesh"; + return result; + } + + OBJLoader::LoadedMesh loaded; + loaded.path = filepath; + loaded.name = fs::path(filepath).stem().string(); + loaded.mesh = std::make_unique(vertices.data(), vertices.size() * sizeof(float)); + loaded.vertexCount = static_cast(vertices.size() / 8); + loaded.faceCount = static_cast(raw.faces.size()); + loaded.hasNormals = raw.hasNormals; + loaded.hasTexCoords = raw.hasUVs; + loaded.boundsMin = raw.boundsMin; + loaded.boundsMax = raw.boundsMax; + loaded.triangleVertices = std::move(triPositions); + + loadedMeshes.push_back(std::move(loaded)); + + result.success = true; + result.meshIndex = static_cast(loadedMeshes.size() - 1); + result.vertexCount = static_cast(raw.positions.size()); + result.faceCount = static_cast(raw.faces.size()); + result.meshCount = 1; + result.hasNormals = raw.hasNormals; + result.hasTexCoords = raw.hasUVs; + result.meshNames.push_back(loadedMeshes.back().name); + return result; + } // Configure import flags unsigned int importFlags = @@ -173,6 +262,269 @@ ModelLoadResult ModelLoader::loadModel(const std::string& filepath) { return result; } +bool ModelLoader::exportRawMesh(const std::string& inputFile, const std::string& outputFile, std::string& errorMsg) { + fs::path inPath(inputFile); + if (!fs::exists(inPath)) { + errorMsg = "File not found: " + inputFile; + return false; + } + if (!isSupported(inputFile)) { + errorMsg = "Unsupported file format for raw export"; + return false; + } + + Assimp::Importer localImporter; + unsigned int importFlags = + aiProcess_Triangulate | + aiProcess_JoinIdenticalVertices | + aiProcess_GenSmoothNormals | + aiProcess_FlipUVs; + + const aiScene* scene = localImporter.ReadFile(inputFile, importFlags); + if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) { + errorMsg = "Assimp error: " + std::string(localImporter.GetErrorString()); + return false; + } + + RawMeshAsset raw; + collectRawMeshData(scene->mRootNode, scene, aiMatrix4x4(), raw); + + if (raw.positions.empty() || raw.faces.empty()) { + errorMsg = "No geometry found to export"; + return false; + } + + fs::path outPath(outputFile.empty() ? inPath : fs::path(outputFile)); + if (outPath.extension().empty()) { + outPath.replace_extension(".rmesh"); + } + + if (!saveRawMesh(raw, outPath.string(), errorMsg)) { + return false; + } + + std::cerr << "[ModelLoader] Exported raw mesh to " << outPath << " (" + << raw.positions.size() << " verts, " << raw.faces.size() << " faces)" << std::endl; + return true; +} + +bool ModelLoader::loadRawMesh(const std::string& filepath, RawMeshAsset& out, std::string& errorMsg) { + out = RawMeshAsset(); + + std::ifstream in(filepath, std::ios::binary); + if (!in) { + errorMsg = "Unable to open raw mesh: " + filepath; + return false; + } + + struct Header { + char magic[6]; + uint32_t version; + uint32_t vertexCount; + uint32_t faceCount; + } header{}; + + in.read(reinterpret_cast(&header), sizeof(header)); + if (std::strncmp(header.magic, "RMESH", 5) != 0) { + errorMsg = "Invalid raw mesh header"; + return false; + } + if (header.version != 1) { + errorMsg = "Unsupported raw mesh version"; + return false; + } + + if (header.vertexCount == 0 || header.faceCount == 0) { + errorMsg = "Raw mesh contains no geometry"; + return false; + } + + in.read(reinterpret_cast(&out.boundsMin.x), sizeof(float) * 3); + in.read(reinterpret_cast(&out.boundsMax.x), sizeof(float) * 3); + + out.positions.resize(header.vertexCount); + out.normals.resize(header.vertexCount); + out.uvs.resize(header.vertexCount); + out.faces.resize(header.faceCount); + + in.read(reinterpret_cast(out.positions.data()), sizeof(glm::vec3) * out.positions.size()); + in.read(reinterpret_cast(out.normals.data()), sizeof(glm::vec3) * out.normals.size()); + in.read(reinterpret_cast(out.uvs.data()), sizeof(glm::vec2) * out.uvs.size()); + in.read(reinterpret_cast(out.faces.data()), sizeof(glm::u32vec3) * out.faces.size()); + + if (!in.good()) { + errorMsg = "Unexpected EOF while reading raw mesh"; + return false; + } + + auto validIndex = [&](uint32_t idx) { return idx < out.positions.size(); }; + for (const auto& face : out.faces) { + if (!validIndex(face.x) || !validIndex(face.y) || !validIndex(face.z)) { + errorMsg = "Raw mesh contains invalid face indices"; + return false; + } + } + + out.hasNormals = false; + for (const auto& n : out.normals) { + if (glm::length(n) > 1e-4f) { out.hasNormals = true; break; } + } + + out.hasUVs = false; + for (const auto& uv : out.uvs) { + if (std::abs(uv.x) > 1e-6f || std::abs(uv.y) > 1e-6f) { out.hasUVs = true; break; } + } + + if (!out.hasNormals) { + out.normals.assign(out.positions.size(), glm::vec3(0.0f)); + std::vector accum(out.positions.size(), glm::vec3(0.0f)); + + for (const auto& face : out.faces) { + const glm::vec3& a = out.positions[face.x]; + const glm::vec3& b = out.positions[face.y]; + const glm::vec3& c = out.positions[face.z]; + glm::vec3 n = glm::normalize(glm::cross(b - a, c - a)); + accum[face.x] += n; + accum[face.y] += n; + accum[face.z] += n; + } + for (size_t i = 0; i < accum.size(); i++) { + if (glm::length(accum[i]) > 1e-6f) { + out.normals[i] = glm::normalize(accum[i]); + } + } + out.hasNormals = true; + } + + // Recompute bounds if file stored invalid values + if (!std::isfinite(out.boundsMin.x) || !std::isfinite(out.boundsMax.x)) { + out.boundsMin = glm::vec3(FLT_MAX); + out.boundsMax = glm::vec3(-FLT_MAX); + for (const auto& p : out.positions) { + out.boundsMin.x = std::min(out.boundsMin.x, p.x); + out.boundsMin.y = std::min(out.boundsMin.y, p.y); + out.boundsMin.z = std::min(out.boundsMin.z, p.z); + out.boundsMax.x = std::max(out.boundsMax.x, p.x); + out.boundsMax.y = std::max(out.boundsMax.y, p.y); + out.boundsMax.z = std::max(out.boundsMax.z, p.z); + } + } + + return true; +} + +bool ModelLoader::saveRawMesh(const RawMeshAsset& asset, const std::string& filepath, std::string& errorMsg) { + if (asset.positions.empty() || asset.faces.empty()) { + errorMsg = "Raw mesh is empty"; + return false; + } + + fs::path outPath(filepath); + if (outPath.extension().empty()) { + outPath.replace_extension(".rmesh"); + } + + struct Header { + char magic[6] = {'R','M','E','S','H','\0'}; + uint32_t version = 1; + uint32_t vertexCount = 0; + uint32_t faceCount = 0; + } header; + + header.vertexCount = static_cast(asset.positions.size()); + header.faceCount = static_cast(asset.faces.size()); + + std::ofstream out(outPath, std::ios::binary); + if (!out) { + errorMsg = "Unable to open file for writing: " + outPath.string(); + return false; + } + + out.write(reinterpret_cast(&header), sizeof(header)); + out.write(reinterpret_cast(&asset.boundsMin.x), sizeof(float) * 3); + out.write(reinterpret_cast(&asset.boundsMax.x), sizeof(float) * 3); + out.write(reinterpret_cast(asset.positions.data()), sizeof(glm::vec3) * asset.positions.size()); + out.write(reinterpret_cast(asset.normals.data()), sizeof(glm::vec3) * asset.normals.size()); + out.write(reinterpret_cast(asset.uvs.data()), sizeof(glm::vec2) * asset.uvs.size()); + out.write(reinterpret_cast(asset.faces.data()), sizeof(glm::u32vec3) * asset.faces.size()); + + if (!out.good()) { + errorMsg = "Failed while writing raw mesh file"; + return false; + } + + return true; +} + +bool ModelLoader::updateRawMesh(int meshIndex, const RawMeshAsset& asset, std::string& errorMsg) { + if (meshIndex < 0 || meshIndex >= static_cast(loadedMeshes.size())) { + errorMsg = "Invalid mesh index"; + return false; + } + if (asset.positions.empty() || asset.faces.empty()) { + errorMsg = "Raw mesh is empty"; + return false; + } + + std::vector vertices; + vertices.reserve(asset.faces.size() * 3 * 8); + std::vector triPositions; + triPositions.reserve(asset.faces.size() * 3); + + auto getPos = [&](uint32_t idx) -> const glm::vec3& { return asset.positions[idx]; }; + auto getNorm = [&](uint32_t idx) -> glm::vec3 { + if (idx < asset.normals.size()) return asset.normals[idx]; + return glm::vec3(0.0f); + }; + auto getUV = [&](uint32_t idx) -> glm::vec2 { + if (idx < asset.uvs.size()) return asset.uvs[idx]; + return glm::vec2(0.0f); + }; + + for (const auto& face : asset.faces) { + const uint32_t idx[3] = { face.x, face.y, face.z }; + glm::vec3 faceNormal(0.0f); + if (!asset.hasNormals) { + const glm::vec3& a = getPos(idx[0]); + const glm::vec3& b = getPos(idx[1]); + const glm::vec3& c = getPos(idx[2]); + faceNormal = glm::normalize(glm::cross(b - a, c - a)); + } + for (int i = 0; i < 3; i++) { + glm::vec3 pos = getPos(idx[i]); + glm::vec3 n = asset.hasNormals ? getNorm(idx[i]) : faceNormal; + glm::vec2 uv = asset.hasUVs ? getUV(idx[i]) : glm::vec2(0.0f); + + triPositions.push_back(pos); + vertices.push_back(pos.x); + vertices.push_back(pos.y); + vertices.push_back(pos.z); + vertices.push_back(n.x); + vertices.push_back(n.y); + vertices.push_back(n.z); + vertices.push_back(uv.x); + vertices.push_back(uv.y); + } + } + + if (vertices.empty()) { + errorMsg = "No vertices generated for GPU upload"; + return false; + } + + OBJLoader::LoadedMesh& loaded = loadedMeshes[meshIndex]; + loaded.mesh = std::make_unique(vertices.data(), vertices.size() * sizeof(float)); + loaded.vertexCount = static_cast(vertices.size() / 8); + loaded.faceCount = static_cast(asset.faces.size()); + loaded.hasNormals = asset.hasNormals; + loaded.hasTexCoords = asset.hasUVs; + loaded.boundsMin = asset.boundsMin; + loaded.boundsMax = asset.boundsMax; + loaded.triangleVertices = std::move(triPositions); + + return true; +} + static glm::mat4 aiToGlm(const aiMatrix4x4& m) { return glm::mat4( m.a1, m.b1, m.c1, m.d1, @@ -182,6 +534,61 @@ static glm::mat4 aiToGlm(const aiMatrix4x4& m) { ); } +static void collectRawMeshData(aiNode* node, const aiScene* scene, const aiMatrix4x4& parentTransform, RawMeshAsset& out) { + aiMatrix4x4 current = parentTransform * node->mTransformation; + glm::mat4 gTransform = aiToGlm(current); + glm::mat3 normalMat = glm::transpose(glm::inverse(glm::mat3(gTransform))); + + for (unsigned int i = 0; i < node->mNumMeshes; i++) { + aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; + size_t baseIndex = out.positions.size(); + + // Vertices + for (unsigned int v = 0; v < mesh->mNumVertices; v++) { + glm::vec3 pos(mesh->mVertices[v].x, mesh->mVertices[v].y, mesh->mVertices[v].z); + glm::vec4 transformed = gTransform * glm::vec4(pos, 1.0f); + glm::vec3 finalPos = glm::vec3(transformed) / (transformed.w == 0.0f ? 1.0f : transformed.w); + out.positions.push_back(finalPos); + + out.boundsMin.x = std::min(out.boundsMin.x, finalPos.x); + out.boundsMin.y = std::min(out.boundsMin.y, finalPos.y); + out.boundsMin.z = std::min(out.boundsMin.z, finalPos.z); + out.boundsMax.x = std::max(out.boundsMax.x, finalPos.x); + out.boundsMax.y = std::max(out.boundsMax.y, finalPos.y); + out.boundsMax.z = std::max(out.boundsMax.z, finalPos.z); + + glm::vec3 normal(0.0f); + if (mesh->mNormals) { + normal = glm::normalize(normalMat * glm::vec3(mesh->mNormals[v].x, mesh->mNormals[v].y, mesh->mNormals[v].z)); + } + out.normals.push_back(normal); + + glm::vec2 uv(0.0f); + if (mesh->mTextureCoords[0]) { + uv.x = mesh->mTextureCoords[0][v].x; + uv.y = mesh->mTextureCoords[0][v].y; + } + out.uvs.push_back(uv); + } + + // Faces (triangles) + for (unsigned int f = 0; f < mesh->mNumFaces; f++) { + const aiFace& face = mesh->mFaces[f]; + if (face.mNumIndices != 3) continue; + glm::u32vec3 tri( + static_cast(baseIndex + face.mIndices[0]), + static_cast(baseIndex + face.mIndices[1]), + static_cast(baseIndex + face.mIndices[2]) + ); + out.faces.push_back(tri); + } + } + + for (unsigned int c = 0; c < node->mNumChildren; c++) { + collectRawMeshData(node->mChildren[c], scene, current, out); + } +} + void ModelLoader::processNode(aiNode* node, const aiScene* scene, const aiMatrix4x4& parentTransform, std::vector& vertices, std::vector& triPositions, glm::vec3& boundsMin, glm::vec3& boundsMax) { aiMatrix4x4 currentTransform = parentTransform * node->mTransformation; // Process all meshes in this node diff --git a/src/ModelLoader.h b/src/ModelLoader.h index 318c09a..3802ae3 100644 --- a/src/ModelLoader.h +++ b/src/ModelLoader.h @@ -2,6 +2,7 @@ #include "Common.h" #include "Rendering.h" +#include #include #include @@ -28,6 +29,18 @@ struct ModelLoadResult { std::vector meshNames; }; +// Raw mesh asset for editable geometry (.rmesh) +struct RawMeshAsset { + std::vector positions; + std::vector normals; + std::vector uvs; + std::vector faces; + glm::vec3 boundsMin = glm::vec3(FLT_MAX); + glm::vec3 boundsMax = glm::vec3(-FLT_MAX); + bool hasNormals = false; + bool hasUVs = false; +}; + class ModelLoader { public: // Singleton access @@ -48,6 +61,18 @@ public: // Check if file extension is supported bool isSupported(const std::string& filepath) const; + // Export a model file into a raw editable mesh asset (.rmesh) + bool exportRawMesh(const std::string& inputFile, const std::string& outputFile, std::string& errorMsg); + + // Load a raw mesh asset from disk + bool loadRawMesh(const std::string& filepath, RawMeshAsset& out, std::string& errorMsg); + + // Save a raw mesh asset to disk + bool saveRawMesh(const RawMeshAsset& asset, const std::string& filepath, std::string& errorMsg); + + // Update an already-loaded raw mesh in GPU memory + bool updateRawMesh(int meshIndex, const RawMeshAsset& asset, std::string& errorMsg); + // Get list of supported formats static std::vector getSupportedFormats();