Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/EditableMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
#include <cmath>
#include <cstring>

// Assimp re-import path (loadFromAssimpFile) — drops aiProcess_Triangulate
// so source n-gons survive into EditableSubMesh::faces. Quad migration #326,
// chunk 3.
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

void triangulateFaces(EditableSubMesh& sub)
{
sub.triangles.clear();
Expand Down Expand Up @@ -139,6 +146,128 @@
return true;
}

bool EditableMesh::loadFromAssimpFile(const std::string& path)

Check failure on line 149 in src/EditableMesh.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 56 to the 25 allowed.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ3UuCZ67plVigxpnb61&open=AZ3UuCZ67plVigxpnb61&pullRequest=331
{
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;

Check failure on line 218 in src/EditableMesh.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ3UuCZ67plVigxpnb62&open=AZ3UuCZ67plVigxpnb62&pullRequest=331
for (unsigned w = 0; w < bone->mNumWeights; ++w) {

Check failure on line 219 in src/EditableMesh.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ3UuCZ67plVigxpnb63&open=AZ3UuCZ67plVigxpnb63&pullRequest=331
const aiVertexWeight& vw = bone->mWeights[w];
if (vw.mVertexId >= sub.vertices.size()) continue;
EditableBoneAssignment eba;
eba.boneIndex = static_cast<unsigned short>(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) {

Check failure on line 243 in src/EditableMesh.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ3UuCZ67plVigxpnb64&open=AZ3UuCZ67plVigxpnb64&pullRequest=331
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) {
Expand Down
33 changes: 33 additions & 0 deletions src/EditableMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@
* Supports: vertex positions, normals, UVs, vertex colors, bone weights.
* Handles multi-submesh entities and shared vertex data.
*/
class EditableMesh

Check warning on line 201 in src/EditableMesh.h

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Class has 36 methods, which is greater than the 35 authorized. Split it into smaller classes.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ3UuCez7plVigxpnb65&open=AZ3UuCez7plVigxpnb65&pullRequest=331
{
public:
EditableMesh() = default;
Expand Down Expand Up @@ -234,6 +234,39 @@
*/
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.
Expand Down
135 changes: 135 additions & 0 deletions src/EditableMesh_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ The MIT License
*/

#include <gtest/gtest.h>
#include <QDir>
#include <QFile>
#include <QTextStream>
#include "EditableMesh.h"
#include "EditModeController.h"
#include "TestHelpers.h"
Expand Down Expand Up @@ -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);
}
14 changes: 13 additions & 1 deletion src/MeshImporterExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading