diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 67b0a66a1..89e4dec08 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -35,6 +35,13 @@ THE SOFTWARE. #include #include +// Assimp re-import path (loadFromAssimpFile) — drops aiProcess_Triangulate +// so source n-gons survive into EditableSubMesh::faces. Quad migration #326, +// chunk 3. +#include +#include +#include + void triangulateFaces(EditableSubMesh& sub) { sub.triangles.clear(); @@ -139,6 +146,128 @@ bool EditableMesh::loadFromMesh(const Ogre::MeshPtr& meshPtr) return true; } +bool EditableMesh::loadFromAssimpFile(const std::string& path) +{ + if (path.empty()) return false; + + // Spin up an independent Assimp::Importer so we don't disturb the + // existing AssimpToOgreImporter pipeline. Drop aiProcess_Triangulate + // so source quads survive into aiMesh::mFaces. Keep the rest of the + // post-processing aligned with the rendering importer so vertex + // attributes don't drift between the two views. + Assimp::Importer importer; + const unsigned int flags = + aiProcess_JoinIdenticalVertices | + aiProcess_GenSmoothNormals | + aiProcess_ValidateDataStructure | + aiProcess_LimitBoneWeights | + aiProcess_GlobalScale; + const aiScene* scene = importer.ReadFile(path, flags); + if (!scene || !scene->mRootNode || scene->mNumMeshes == 0) { + Ogre::LogManager::getSingleton().logMessage( + "EditableMesh::loadFromAssimpFile: re-import failed for '" + path + + "' — " + std::string(importer.GetErrorString())); + return false; + } + + m_subMeshes.clear(); + m_subMeshes.reserve(scene->mNumMeshes); + + for (unsigned m = 0; m < scene->mNumMeshes; ++m) { + const aiMesh* aim = scene->mMeshes[m]; + if (!aim || aim->mNumVertices == 0) continue; + + EditableSubMesh sub; + sub.usesSharedVertices = false; + // Material name is left empty here — the live Ogre::Mesh in the + // scene already carries the right material per submesh, and + // EditModeController doesn't write material assignments back + // through this path. + sub.materialName.clear(); + + // Vertices. + sub.vertices.resize(aim->mNumVertices); + const bool hasNormals = aim->HasNormals(); + const bool hasUVs = aim->HasTextureCoords(0); + const bool hasColors = aim->HasVertexColors(0); + for (unsigned i = 0; i < aim->mNumVertices; ++i) { + EditableVertex& ev = sub.vertices[i]; + const aiVector3D& p = aim->mVertices[i]; + ev.position = Ogre::Vector3(p.x, p.y, p.z); + if (hasNormals) { + const aiVector3D& n = aim->mNormals[i]; + ev.normal = Ogre::Vector3(n.x, n.y, n.z); + ev.hasNormal = true; + } + if (hasUVs) { + const aiVector3D& t = aim->mTextureCoords[0][i]; + ev.uv = Ogre::Vector2(t.x, t.y); + ev.hasUV = true; + } + if (hasColors) { + const aiColor4D& c = aim->mColors[0][i]; + ev.color = Ogre::ColourValue(c.r, c.g, c.b, c.a); + ev.hasColor = true; + } + } + + // Bone weights, if any. + if (aim->mNumBones > 0) { + for (unsigned b = 0; b < aim->mNumBones; ++b) { + const aiBone* bone = aim->mBones[b]; + if (!bone) continue; + for (unsigned w = 0; w < bone->mNumWeights; ++w) { + const aiVertexWeight& vw = bone->mWeights[w]; + if (vw.mVertexId >= sub.vertices.size()) continue; + EditableBoneAssignment eba; + eba.boneIndex = static_cast(b); + eba.weight = vw.mWeight; + sub.vertices[vw.mVertexId].boneAssignments.push_back(eba); + } + } + } + + // Faces — this is the whole point of this method. Without + // aiProcess_Triangulate, aiMesh::mFaces retains the original + // polygon structure (3 / 4 / N indices per face). Build + // `EditableFace` directly; chunks 1+2 take care of GPU upload. + sub.faces.reserve(aim->mNumFaces); + bool sawNGon = false; + for (unsigned f = 0; f < aim->mNumFaces; ++f) { + const aiFace& face = aim->mFaces[f]; + if (face.mNumIndices < 3) continue; // points / lines — skip + EditableFace ef; + ef.indices.reserve(face.mNumIndices); + bool inRange = true; + for (unsigned k = 0; k < face.mNumIndices; ++k) { + if (face.mIndices[k] >= aim->mNumVertices) { + inRange = false; + break; + } + ef.indices.push_back(face.mIndices[k]); + } + if (!inRange) continue; + if (face.mNumIndices > 3) sawNGon = true; + sub.faces.push_back(std::move(ef)); + } + + // Always populate `triangles` as the fan-triangulated mirror so + // legacy consumers (the GPU upload path before chunk 2's defensive + // resync, the normal-recalc path on triangle-only submeshes, + // every existing topology op) keep working unchanged. + triangulateFaces(sub); + + // Honour the chunk-1 invariant: leave `faces` empty when every + // face was a triangle, so triangle-only assets don't surface as + // n-gon submeshes downstream. + if (!sawNGon) sub.faces.clear(); + + m_subMeshes.push_back(std::move(sub)); + } + + return !m_subMeshes.empty(); +} + void EditableMesh::collapseToSingleSubmeshAndWeld(float tolerance) { if (m_subMeshes.size() <= 1) { diff --git a/src/EditableMesh.h b/src/EditableMesh.h index 91cd7e359..c1a4bbc31 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -234,6 +234,39 @@ class EditableMesh */ bool loadFromMesh(const Ogre::MeshPtr& mesh); + /** + * @brief Re-import an asset directly via Assimp, preserving n-gons. + * + * Spins up a fresh `Assimp::Importer` and re-reads the source file + * with the triangulation post-process disabled, so source quads + * survive into `EditableSubMesh::faces` instead of being collapsed + * to triangles. The associated Ogre::Mesh in the live scene + * continues to use the triangulated index buffer for rendering; + * this path only feeds the editing-time representation. + * + * Skips skeleton, animation, material, and tangent processing — + * Edit Mode operates on positions, normals, UVs, vertex colors, + * and bone weights only. Materials are taken from the existing + * Ogre::Mesh by submesh order in `loadFromEntity` / similar paths. + * + * Vertices are read out of `aiMesh::mVertices` / `mNormals` / + * `mTextureCoords[0]` / `mColors[0]` / `mBones[].mWeights`. Faces + * are read from `aiMesh::mFaces` and stored in + * `EditableSubMesh::faces` (n-gon canonical), with `triangles` + * fan-triangulated to maintain the chunk-1 invariant. + * + * Cost: a second Assimp parse of the same file. Order of magnitude + * 10–100ms for typical assets; acceptable as a one-time cost on + * entering Edit Mode. Big assets (50MB+ FBX) may be noticeable. + * + * @param path The path the asset was originally imported from. + * Should be the value cached on `Ogre::Mesh` via + * `getUserObjectBindings().getUserAny("qtme.source_path")`. + * @return true on success; false if the file is missing, can't be + * parsed, or contains no mesh data. + */ + bool loadFromAssimpFile(const std::string& path); + /** * @brief Merge vertices at (approximately) coincident positions within * each submesh. diff --git a/src/EditableMesh_test.cpp b/src/EditableMesh_test.cpp index bd5f2acc1..f26e1b286 100644 --- a/src/EditableMesh_test.cpp +++ b/src/EditableMesh_test.cpp @@ -9,6 +9,9 @@ The MIT License */ #include +#include +#include +#include #include "EditableMesh.h" #include "EditModeController.h" #include "TestHelpers.h" @@ -912,3 +915,135 @@ TEST(EditableMeshStandalone, RecalculateNormalsResyncsTrianglesFromFaces) { EXPECT_NEAR(v.normal.y, 0.0f, 1e-4f); } } + +// =========================================================================== +// loadFromAssimpFile (chunk 3) — n-gon-aware re-import +// =========================================================================== + +namespace { +// Write a minimal OBJ to a temp file with the given face line. Returns +// the path on success, empty on failure. The OBJ format keeps quads +// intact through Assimp's reader when aiProcess_Triangulate is off. +QString writeObj(const QString& baseName, + const QString& vertexLines, + const QString& faceLines) +{ + const QString path = QDir::tempPath() + "/" + baseName + ".obj"; + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return {}; + QTextStream out(&f); + out << "# auto-generated by EditableMesh_test\n"; + out << vertexLines; + out << faceLines; + f.close(); + return path; +} +} // namespace + +TEST(EditableMeshStandalone, LoadFromAssimpFileEmptyPathFails) { + EditableMesh mesh; + EXPECT_FALSE(mesh.loadFromAssimpFile("")); + EXPECT_EQ(mesh.subMeshCount(), 0u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileMissingFileFails) { + EditableMesh mesh; + EXPECT_FALSE(mesh.loadFromAssimpFile( + "/this/path/does/not/exist.obj")); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFilePreservesQuadFromObj) { + // OBJ with a single quad face on 4 vertices. Without + // aiProcess_Triangulate, Assimp should yield aiMesh::mFaces[0] + // with mNumIndices == 4, which loadFromAssimpFile records as a + // single 4-vertex EditableFace. + const QString path = writeObj("editmesh_quad", + "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\n", + "f 1 2 3 4\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh mesh; + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + const auto& sub = mesh.subMeshes()[0]; + EXPECT_EQ(sub.vertices.size(), 4u); + ASSERT_EQ(sub.faces.size(), 1u) + << "OBJ quad must round-trip as a single 4-vertex EditableFace"; + EXPECT_EQ(sub.faces[0].indices.size(), 4u); + // triangles is the fan-triangulation (chunk 1 invariant) + EXPECT_EQ(sub.triangles.size(), 2u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileTriangleOnlyLeavesFacesEmpty) { + // Triangle-only OBJ should follow the chunk-1 invariant: faces + // empty, triangles canonical. + const QString path = writeObj("editmesh_tri", + "v 0 0 0\nv 1 0 0\nv 0 1 0\n", + "f 1 2 3\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh mesh; + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + const auto& sub = mesh.subMeshes()[0]; + EXPECT_EQ(sub.vertices.size(), 3u); + EXPECT_TRUE(sub.faces.empty()) + << "triangle-only mesh keeps faces empty (legacy invariant)"; + EXPECT_EQ(sub.triangles.size(), 1u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileMixedTriAndQuadKeepsBoth) { + // OBJ with one tri and one quad — the submesh should be quad-aware + // (faces non-empty) and triangles should mirror the fan. + const QString path = writeObj("editmesh_mix", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 2 0 0\nv 2 1 0\nv 1 1 0\n", + "f 1 2 3\nf 2 4 5 6\n"); + ASSERT_FALSE(path.isEmpty()); + + EditableMesh mesh; + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + const auto& sub = mesh.subMeshes()[0]; + EXPECT_EQ(sub.vertices.size(), 6u); + ASSERT_EQ(sub.faces.size(), 2u); + // Tri = 3 vertices, quad = 4. + bool sawTri = false, sawQuad = false; + for (const auto& f : sub.faces) { + if (f.indices.size() == 3) sawTri = true; + if (f.indices.size() == 4) sawQuad = true; + } + EXPECT_TRUE(sawTri); + EXPECT_TRUE(sawQuad); + // 1 tri + 2 fan tris from the quad = 3 entries. + EXPECT_EQ(sub.triangles.size(), 3u); +} + +TEST(EditableMeshStandalone, LoadFromAssimpFileReplacesPreviousContents) { + // Loading into a non-empty EditableMesh should replace the + // existing submeshes — like buildFromEditableMesh does. + EditableMesh mesh; + EditableSubMesh stale; + EditableVertex v; + stale.vertices = {v, v, v}; + EditableTriangle t; + t.indices[0] = 0; t.indices[1] = 1; t.indices[2] = 2; + stale.triangles.push_back(t); + mesh.subMeshes().push_back(std::move(stale)); + ASSERT_EQ(mesh.subMeshCount(), 1u); + + const QString path = writeObj("editmesh_replace", + "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\n", + "f 1 2 3 4\n"); + ASSERT_FALSE(path.isEmpty()); + ASSERT_TRUE(mesh.loadFromAssimpFile(path.toStdString())); + QFile::remove(path); + + ASSERT_EQ(mesh.subMeshCount(), 1u); + EXPECT_EQ(mesh.subMeshes()[0].vertices.size(), 4u); +} diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 39feca91d..c4c9f90d6 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -997,9 +997,21 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad // DirectX .x is natively left-handed — skip ConvertToLeftHanded // to avoid double-flipping geometry and UVs. bool convertLH = (file.suffix().compare("x", Qt::CaseInsensitive) != 0); - Ogre::MeshPtr mesh = importer.loadModel(file.filePath().toStdString(), convertLH, additionalFlags); + const std::string sourcePath = file.filePath().toStdString(); + Ogre::MeshPtr mesh = importer.loadModel(sourcePath, convertLH, additionalFlags); // Read coordinate system from metadata immediately — valid for both mesh and animation-only files. if (outUpAxis) *outUpAxis = importer.getSceneUpAxis(); + if (mesh) { + // Cache the source file path so EditModeController can + // re-import the asset through the n-gon-aware + // EditableMesh::loadFromAssimpFile path. Quad-bearing + // assets keep their polygon structure when entering + // Edit Mode; without this cache only the triangulated + // Ogre buffer is available and quads are lost. + // (Quad migration #326, chunk 3.) + mesh->getUserObjectBindings().setUserAny( + "qtme.source_path", Ogre::Any(sourcePath)); + } if (!mesh) { // Animation-only file: skeleton/animations were loaded, but there is no mesh. // Collect into the caller-provided list; callers that want UI notifications