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
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0)
cmake_policy(SET CMP0005 NEW)
cmake_policy(SET CMP0048 NEW) # manages project version

project(QtMeshEditor VERSION 3.0.1 LANGUAGES C CXX)
project(QtMeshEditor VERSION 3.1.0 LANGUAGES C CXX)
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")

set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Available on the [GitHub Actions Marketplace](https://github.com/marketplace/act
**Versioning**

- **Always follow the latest GitHub release** — use the Marketplace floating tag `fernandotonon/QtMeshEditor@v1` (same pattern as the [Marketplace example](https://github.com/marketplace/actions/qtmesheditor)). The composite action defaults to `image-tag: latest`, so the Docker CLI tracks the newest published `ghcr.io/fernandotonon/qtmesh` image.
- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.0.0**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`.
- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.1.0**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`.

Pinned workflow template (action + `ghcr.io` image aligned):

Expand All @@ -51,10 +51,10 @@ jobs:
- uses: actions/checkout@v4

- name: Run QtMesh scan
uses: fernandotonon/QtMeshEditor@3.0.1
uses: fernandotonon/QtMeshEditor@3.1.0
with:
command: scan
image-tag: "3.0.0"
image-tag: "3.1.0"
env:
QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }}
```
Expand All @@ -79,39 +79,39 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/

```yaml
# Validate a specific mesh
- uses: fernandotonon/QtMeshEditor@3.0.1
- uses: fernandotonon/QtMeshEditor@3.1.0
with:
command: validate
input-file: ./models/character.fbx
image-tag: "3.0.0"
image-tag: "3.1.0"

# Convert FBX → glTF
- uses: fernandotonon/QtMeshEditor@3.0.1
- uses: fernandotonon/QtMeshEditor@3.1.0
with:
command: convert
input-file: ./models/character.fbx
output-file: ./output/character.gltf2
image-tag: "3.0.0"
image-tag: "3.1.0"

# Resample Mixamo animations (200+ keyframes → 30)
- uses: fernandotonon/QtMeshEditor@3.0.1
- uses: fernandotonon/QtMeshEditor@3.1.0
with:
command: anim
input-file: ./animations/dance.fbx
output-file: ./output/dance_optimized.fbx
options: --resample 30
image-tag: "3.0.0"
image-tag: "3.1.0"

# Get mesh info as JSON
- uses: fernandotonon/QtMeshEditor@3.0.1
- uses: fernandotonon/QtMeshEditor@3.1.0
id: info
with:
command: info
input-file: ./models/character.fbx
options: --json
image-tag: "3.0.0"
image-tag: "3.1.0"

# Docker (alternative — :latest tracks newest image; pin :3.0.0 to match semver action ref)
# Docker (alternative — :latest tracks newest image; pin :3.1.0 to match semver action ref)
docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error
```

Expand Down
185 changes: 176 additions & 9 deletions src/MeshImporterExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@
#include <QDir>
#include <QRegularExpression>
#include <set>
#include <limits>
#include <cmath>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <algorithm>

Expand Down Expand Up @@ -322,7 +325,8 @@
const Ogre::SubMesh* subMesh,
const Ogre::Entity* entity,
unsigned int subIndex,
const std::map<std::string, unsigned int, std::less<>>& matIndexMap)
const std::map<std::string, unsigned int, std::less<>>& matIndexMap,
bool preferNgonFaces = true)
{
aiM->mPrimitiveTypes = aiPrimitiveType_TRIANGLE;
aiM->mNumVertices = static_cast<unsigned int>(vData->vertexCount);
Expand Down Expand Up @@ -414,7 +418,7 @@
// that have never carried n-gon data — fall back to reading the
// triangle index buffer directly.
std::vector<std::vector<unsigned int>> ngonFaces;
const bool hasNgon = readNgonFacesFromMesh(
const bool hasNgon = preferNgonFaces && readNgonFacesFromMesh(
entity->getMesh().get(), subIndex, ngonFaces);
if (hasNgon)
{
Expand Down Expand Up @@ -468,6 +472,121 @@
}
}

static std::string sceneNgonFacesMetaKey(unsigned int subIndex)
{
return std::string("qtme.faces.") + std::to_string(subIndex);
}

static std::string sceneNgonFaceMetaKey(unsigned int subIndex, unsigned int faceIndex)
{
return sceneNgonFacesMetaKey(subIndex) + ".face." + std::to_string(faceIndex);
}

static aiString encodeSceneNgonFaceMetadata(const std::vector<unsigned int>& face)
{
std::string encoded;
for (size_t vertexIndex = 0; vertexIndex < face.size(); ++vertexIndex)

Check warning on line 488 in src/MeshImporterExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this raw for-loop to a range for-loop or an "std::for_each".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ4f7MMWBQlVnXrestKI&open=AZ4f7MMWBQlVnXrestKI&pullRequest=503
{
if (!encoded.empty())
encoded.push_back(',');
encoded += std::to_string(face[vertexIndex]);
}
return aiString(encoded);
}

static bool decodeSceneNgonFaceMetadata(const aiString& encoded, std::vector<unsigned int>& outFace)
{
outFace.clear();
const std::string value = encoded.C_Str();
if (value.empty())
return false;

size_t start = 0;
while (start < value.size())
{
const size_t end = value.find(',', start);
const std::string token = value.substr(start, end == std::string::npos ? std::string::npos : end - start);
if (token.empty())
return false;
errno = 0;
char* tail = nullptr;
const unsigned long parsed = std::strtoul(token.c_str(), &tail, 10);
if (!tail || *tail != '\0' || errno == ERANGE ||
parsed > std::numeric_limits<unsigned int>::max())
return false;
outFace.push_back(static_cast<unsigned int>(parsed));
if (end == std::string::npos)
break;
start = end + 1;
}

return outFace.size() >= 3;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

static bool remapNgonFaces(const std::vector<unsigned int>& remap,
std::vector<std::vector<unsigned int>>& faces)
{
if (remap.empty())
return true;

for (auto& face : faces)
{
for (auto& idx : face)
{
if (idx >= remap.size() || remap[idx] == UINT_MAX)
return false;
idx = remap[idx];
}
}
return true;
}

static bool decodeSceneNgonFacesMetadata(const aiMetadata* metadata,
unsigned int subIndex,
std::vector<std::vector<unsigned int>>& outFaces)
{
outFaces.clear();
if (!metadata)
return false;

const std::string prefix = sceneNgonFacesMetaKey(subIndex) + ".face.";
std::map<unsigned int, std::vector<unsigned int>> orderedFaces;
for (unsigned int i = 0; i < metadata->mNumProperties; ++i)
{
const std::string key = metadata->mKeys[i].C_Str();
if (key.rfind(prefix, 0) != 0)
continue;

const std::string suffix = key.substr(prefix.size());
if (suffix.empty())
return false;
errno = 0;
char* tail = nullptr;
const unsigned long faceIndex = std::strtoul(suffix.c_str(), &tail, 10);
if (!tail || *tail != '\0' || errno == ERANGE ||
faceIndex > std::numeric_limits<unsigned int>::max())
return false;

aiString encoded;
if (!metadata->Get(i, encoded))
return false;

std::vector<unsigned int> face;
if (!decodeSceneNgonFaceMetadata(encoded, face))
return false;
orderedFaces[static_cast<unsigned int>(faceIndex)] = std::move(face);
}

if (orderedFaces.empty())
return false;

outFaces.reserve(orderedFaces.size());
for (auto& [faceIndex, face] : orderedFaces)
outFaces.push_back(std::move(face));

return true;
}

// Assign bone weights from Ogre bone assignments to an aiMesh
static void assignBoneWeights(
aiMesh* aiM,
Expand Down Expand Up @@ -517,9 +636,10 @@
// weights. Required when exporting LOD-reduced geometry (full vertex buffer but
// only a fraction of triangles), which otherwise causes expensive post-processing
// (aiProcess_JoinIdenticalVertices, aiProcess_OptimizeMeshes) on import.
static void compactAiMesh(aiMesh* aiM)
static std::vector<unsigned int> compactAiMesh(aiMesh* aiM)

Check failure on line 639 in src/MeshImporterExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ4f7MMWBQlVnXrestKJ&open=AZ4f7MMWBQlVnXrestKJ&pullRequest=503
{
if (!aiM || aiM->mNumVertices == 0 || aiM->mNumFaces == 0) return;
if (!aiM || aiM->mNumVertices == 0 || aiM->mNumFaces == 0)
return {};

std::vector<bool> used(aiM->mNumVertices, false);
for (unsigned int f = 0; f < aiM->mNumFaces; ++f)
Expand All @@ -532,7 +652,8 @@
for (unsigned int i = 0; i < aiM->mNumVertices; ++i)
if (used[i]) remap[i] = newCount++;

if (newCount == aiM->mNumVertices) return; // nothing to compact
if (newCount == aiM->mNumVertices)
return remap; // nothing to compact

for (unsigned int i = 0; i < aiM->mNumVertices; ++i) {
if (!used[i]) continue;
Expand Down Expand Up @@ -565,6 +686,8 @@
}
bone->mNumWeights = kept;
}

return remap;
}

// Convert an Ogre skeleton animation to an aiAnimation
Expand Down Expand Up @@ -2989,6 +3112,7 @@

// Create the scene node's aiNode
aiNode* entityNode;
aiNode* meshOwnerNode = nullptr;
if (hasSkeleton)
{
entityNode = new aiNode(std::string(sn->getName()));
Expand All @@ -3010,6 +3134,7 @@
meshNode->mMeshes = new unsigned int[numSub];
for (unsigned int si = 0; si < numSub; ++si)
meshNode->mMeshes[si] = globalMeshIdx + si;
meshOwnerNode = meshNode;

entityNode->mNumChildren = static_cast<unsigned int>(rootBoneNodes.size()) + 1;
entityNode->mChildren = new aiNode*[entityNode->mNumChildren];
Expand All @@ -3025,6 +3150,7 @@
entityNode->mMeshes = new unsigned int[numSub];
for (unsigned int si = 0; si < numSub; ++si)
entityNode->mMeshes[si] = globalMeshIdx + si;
meshOwnerNode = entityNode;
}

Ogre::Matrix4 nodeTransform;
Expand All @@ -3043,12 +3169,30 @@

auto* aiM = new aiMesh();
scene->mMeshes[globalMeshIdx + si] = aiM;
readSubmeshGeometry(aiM, vData, subMesh, entity, si, matIndexMap);
readSubmeshGeometry(aiM, vData, subMesh, entity, si, matIndexMap, false);

if (hasSkeleton)
assignBoneWeights(aiM, subMesh, mesh, skeleton, boneHandleToName);

compactAiMesh(aiM);
const std::vector<unsigned int> remap = compactAiMesh(aiM);

std::vector<std::vector<unsigned int>> ngonFaces;
if (meshOwnerNode
&& readNgonFacesFromMesh(mesh.get(), si, ngonFaces)
&& !ngonFaces.empty()
&& remapNgonFaces(remap, ngonFaces))
{
if (!meshOwnerNode->mMetaData)

Check failure on line 3185 in src/MeshImporterExporter.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=AZ4f7MMWBQlVnXrestKK&open=AZ4f7MMWBQlVnXrestKK&pullRequest=503
meshOwnerNode->mMetaData = new aiMetadata();

Check failure on line 3186 in src/MeshImporterExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the use of "new" with an operation that automatically manages the memory.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ4f7MMWBQlVnXrestKM&open=AZ4f7MMWBQlVnXrestKM&pullRequest=503
for (size_t faceIndex = 0; faceIndex < ngonFaces.size(); ++faceIndex)

Check failure on line 3187 in src/MeshImporterExporter.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=AZ4f7MMWBQlVnXrestKL&open=AZ4f7MMWBQlVnXrestKL&pullRequest=503
{
if (ngonFaces[faceIndex].size() < 3)
continue;
meshOwnerNode->mMetaData->Add(
sceneNgonFaceMetaKey(si, static_cast<unsigned int>(faceIndex)),
encodeSceneNgonFaceMetadata(ngonFaces[faceIndex]));
}
}
}

// --- Animations ---
Expand Down Expand Up @@ -3171,8 +3315,10 @@
Assimp::Importer assimpImporter;
assimpImporter.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false);

// Preserve the vertex/submesh indexing encoded in qtme.faces metadata.
// Topology-rewriting post-process steps like JoinIdenticalVertices and
// OptimizeMeshes can invalidate those indices before we restore them.
unsigned int flags = aiProcess_CalcTangentSpace |
aiProcess_JoinIdenticalVertices |
aiProcess_Triangulate |
aiProcess_RemoveComponent |
aiProcess_GenSmoothNormals |
Expand All @@ -3182,7 +3328,6 @@
aiProcess_ImproveCacheLocality |
aiProcess_FixInfacingNormals |
aiProcess_PopulateArmatureData |
aiProcess_OptimizeMeshes |
aiProcess_GlobalScale;

const aiScene* scene = assimpImporter.ReadFile(file.filePath().toStdString(), flags);
Expand Down Expand Up @@ -3517,6 +3662,28 @@
meshName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME,
materialProcessor);

std::vector<EditableSubMesh> importedNgonFaces(ogreMesh->getNumSubMeshes());
bool haveImportedNgons = false;
const unsigned int importedSubCount =
std::min<unsigned int>(node->mNumMeshes, ogreMesh->getNumSubMeshes());

Check warning on line 3668 in src/MeshImporterExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

implicit conversion loses integer precision: 'size_t' (aka 'unsigned long') to 'const unsigned int'

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ4f3Qt6igYQ0g_g5ZGV&open=AZ4f3Qt6igYQ0g_g5ZGV&pullRequest=503
for (unsigned int subIdx = 0; subIdx < importedSubCount; ++subIdx)
{
std::vector<std::vector<unsigned int>> faces;
if (!decodeSceneNgonFacesMetadata(node->mMetaData, subIdx, faces))
continue;

importedNgonFaces[subIdx].faces.reserve(faces.size());
for (auto& poly : faces)
{
EditableFace face;
face.indices = std::move(poly);
importedNgonFaces[subIdx].faces.push_back(std::move(face));
}
haveImportedNgons = haveImportedNgons || !importedNgonFaces[subIdx].faces.empty();
}
if (haveImportedNgons)
writeNgonFacesToMesh(ogreMesh.get(), importedNgonFaces);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Create scene node with decomposed world transform
Ogre::SceneNode* sn = manager->addSceneNode(nodeName);

Expand Down
Loading
Loading