diff --git a/CMakeLists.txt b/CMakeLists.txt index 512a251bf..5c7092fb7 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}\"") diff --git a/README.md b/README.md index afb3da4a7..402b71b34 100755 --- a/README.md +++ b/README.md @@ -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): @@ -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 }} ``` @@ -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 ``` diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 084938d3a..a69841cdc 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -40,7 +40,10 @@ THE SOFTWARE. #include #include #include +#include #include +#include +#include #include #include @@ -322,7 +325,8 @@ static void readSubmeshGeometry( const Ogre::SubMesh* subMesh, const Ogre::Entity* entity, unsigned int subIndex, - const std::map>& matIndexMap) + const std::map>& matIndexMap, + bool preferNgonFaces = true) { aiM->mPrimitiveTypes = aiPrimitiveType_TRIANGLE; aiM->mNumVertices = static_cast(vData->vertexCount); @@ -414,7 +418,7 @@ static void readSubmeshGeometry( // that have never carried n-gon data — fall back to reading the // triangle index buffer directly. std::vector> ngonFaces; - const bool hasNgon = readNgonFacesFromMesh( + const bool hasNgon = preferNgonFaces && readNgonFacesFromMesh( entity->getMesh().get(), subIndex, ngonFaces); if (hasNgon) { @@ -468,6 +472,121 @@ static void readSubmeshGeometry( } } +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& face) +{ + std::string encoded; + for (size_t vertexIndex = 0; vertexIndex < face.size(); ++vertexIndex) + { + if (!encoded.empty()) + encoded.push_back(','); + encoded += std::to_string(face[vertexIndex]); + } + return aiString(encoded); +} + +static bool decodeSceneNgonFaceMetadata(const aiString& encoded, std::vector& 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::max()) + return false; + outFace.push_back(static_cast(parsed)); + if (end == std::string::npos) + break; + start = end + 1; + } + + return outFace.size() >= 3; +} + +static bool remapNgonFaces(const std::vector& remap, + std::vector>& 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>& outFaces) +{ + outFaces.clear(); + if (!metadata) + return false; + + const std::string prefix = sceneNgonFacesMetaKey(subIndex) + ".face."; + std::map> 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::max()) + return false; + + aiString encoded; + if (!metadata->Get(i, encoded)) + return false; + + std::vector face; + if (!decodeSceneNgonFaceMetadata(encoded, face)) + return false; + orderedFaces[static_cast(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, @@ -517,9 +636,10 @@ static void assignBoneWeights( // 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 compactAiMesh(aiMesh* aiM) { - if (!aiM || aiM->mNumVertices == 0 || aiM->mNumFaces == 0) return; + if (!aiM || aiM->mNumVertices == 0 || aiM->mNumFaces == 0) + return {}; std::vector used(aiM->mNumVertices, false); for (unsigned int f = 0; f < aiM->mNumFaces; ++f) @@ -532,7 +652,8 @@ static void compactAiMesh(aiMesh* aiM) 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; @@ -565,6 +686,8 @@ static void compactAiMesh(aiMesh* aiM) } bone->mNumWeights = kept; } + + return remap; } // Convert an Ogre skeleton animation to an aiAnimation @@ -2989,6 +3112,7 @@ static aiScene* buildSceneAiScene() // Create the scene node's aiNode aiNode* entityNode; + aiNode* meshOwnerNode = nullptr; if (hasSkeleton) { entityNode = new aiNode(std::string(sn->getName())); @@ -3010,6 +3134,7 @@ static aiScene* buildSceneAiScene() 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(rootBoneNodes.size()) + 1; entityNode->mChildren = new aiNode*[entityNode->mNumChildren]; @@ -3025,6 +3150,7 @@ static aiScene* buildSceneAiScene() entityNode->mMeshes = new unsigned int[numSub]; for (unsigned int si = 0; si < numSub; ++si) entityNode->mMeshes[si] = globalMeshIdx + si; + meshOwnerNode = entityNode; } Ogre::Matrix4 nodeTransform; @@ -3043,12 +3169,30 @@ static aiScene* buildSceneAiScene() 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 remap = compactAiMesh(aiM); + + std::vector> ngonFaces; + if (meshOwnerNode + && readNgonFacesFromMesh(mesh.get(), si, ngonFaces) + && !ngonFaces.empty() + && remapNgonFaces(remap, ngonFaces)) + { + if (!meshOwnerNode->mMetaData) + meshOwnerNode->mMetaData = new aiMetadata(); + for (size_t faceIndex = 0; faceIndex < ngonFaces.size(); ++faceIndex) + { + if (ngonFaces[faceIndex].size() < 3) + continue; + meshOwnerNode->mMetaData->Add( + sceneNgonFaceMetaKey(si, static_cast(faceIndex)), + encodeSceneNgonFaceMetadata(ngonFaces[faceIndex])); + } + } } // --- Animations --- @@ -3171,8 +3315,10 @@ bool MeshImporterExporter::sceneImporter(const QString &_uri) 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 | @@ -3182,7 +3328,6 @@ bool MeshImporterExporter::sceneImporter(const QString &_uri) aiProcess_ImproveCacheLocality | aiProcess_FixInfacingNormals | aiProcess_PopulateArmatureData | - aiProcess_OptimizeMeshes | aiProcess_GlobalScale; const aiScene* scene = assimpImporter.ReadFile(file.filePath().toStdString(), flags); @@ -3517,6 +3662,28 @@ bool MeshImporterExporter::sceneImporter(const QString &_uri) meshName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, materialProcessor); + std::vector importedNgonFaces(ogreMesh->getNumSubMeshes()); + bool haveImportedNgons = false; + const unsigned int importedSubCount = + std::min(node->mNumMeshes, ogreMesh->getNumSubMeshes()); + for (unsigned int subIdx = 0; subIdx < importedSubCount; ++subIdx) + { + std::vector> 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); + // Create scene node with decomposed world transform Ogre::SceneNode* sn = manager->addSceneNode(nodeName); diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 1c60d92a1..dd9fa1fb8 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -13,13 +13,17 @@ #include #include #include +#include #include #include #include +#include +#include #include #include #include "Manager.h" #include "MeshImporterExporter.h" +#include "EditableMesh.h" #include "SelectionSet.h" #include "OgreXML/OgreXMLSkeletonSerializer.h" #include @@ -666,6 +670,89 @@ class SceneSaveLoadTest : public ::testing::Test { } }; +namespace { +QString writeQuadObjForScene(const QTemporaryDir& dir, const QString& fileName) +{ + if (!dir.isValid()) + return {}; + const QString path = dir.filePath(fileName); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return {}; + QTextStream out(&f); + out << "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n"; + f.close(); + return path; +} + +Ogre::MeshPtr createQuadMeshWithUnusedSharedVertex(const std::string& name) +{ + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + mesh->sharedVertexData = new Ogre::VertexData(); + auto* decl = mesh->sharedVertexData->vertexDeclaration; + + size_t offset = 0; + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, offset, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, offset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + + mesh->sharedVertexData->vertexCount = 5; + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), mesh->sharedVertexData->vertexCount, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + const float verts[] = { + 0,0,0, 0,0,1, 0.0f,0.0f, + 5,5,5, 0,0,1, 0.5f,0.5f, // intentionally unused to force compaction remap + 1,0,0, 0,0,1, 1.0f,0.0f, + 1,1,0, 0,0,1, 1.0f,1.0f, + 0,1,0, 0,0,1, 0.0f,1.0f, + }; + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 6, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + const uint16_t idx[] = {0, 2, 3, 0, 3, 4}; + ibuf->writeData(0, sizeof(idx), idx); + sub->useSharedVertices = true; + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 6; + + std::vector subs(1); + subs[0].faces.push_back(EditableFace{{0, 2, 3, 4}}); + writeNgonFacesToMesh(mesh.get(), subs); + + mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,6,6,1)); + mesh->_setBoundingSphereRadius(9.0); + mesh->load(); + return mesh; +} + +void expectEntityHasSingleQuadBinding(Ogre::Entity* entity, + const std::vector& expectedFace) +{ + ASSERT_NE(entity, nullptr); + + std::vector> faces; + ASSERT_TRUE(readNgonFacesFromMesh(entity->getMesh().get(), 0, faces)); + ASSERT_EQ(faces.size(), 1u); + EXPECT_EQ(faces[0], expectedFace); + + EditableMesh mesh; + ASSERT_TRUE(mesh.loadFromEntity(entity)); + ASSERT_EQ(mesh.subMeshes().size(), 1u); + ASSERT_EQ(mesh.subMeshes()[0].faces.size(), 1u); + EXPECT_EQ(mesh.subMeshes()[0].faces[0].indices, expectedFace); + EXPECT_EQ(mesh.subMeshes()[0].triangles.size(), 2u); +} +} // namespace + TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) { auto* manager = Manager::getSingleton(); @@ -728,6 +815,96 @@ TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) { EXPECT_TRUE(foundNode2) << "Second node with position (-1,0,5) not found"; } +TEST_F(SceneSaveLoadTest, RoundTrip_QuadMesh_PreservesNgonFaceBinding_Gltf) +{ + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + const QString objPath = writeQuadObjForScene(tmpDir, "scene_ngon_roundtrip.obj"); + ASSERT_FALSE(objPath.isEmpty()); + + MeshImporterExporter::importer(QStringList{objPath}); + + auto* manager = Manager::getSingleton(); + ASSERT_EQ(manager->getSceneNodes().size(), 1); + auto* node = manager->getSceneNodes().front(); + ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + auto* entity = manager->getSceneMgr()->getEntity(node->getName()); + + std::vector> facesBefore; + ASSERT_TRUE(readNgonFacesFromMesh(entity->getMesh().get(), 0, facesBefore)); + ASSERT_EQ(facesBefore.size(), 1u); + EXPECT_EQ(facesBefore[0].size(), 4u); + + const QString sceneFile = tmpDir.filePath("ngon.scene.gltf"); + ASSERT_EQ(MeshImporterExporter::sceneExporter(sceneFile), 0); + + ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); + ASSERT_EQ(manager->getSceneNodes().size(), 1); + + node = manager->getSceneNodes().front(); + ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + entity = manager->getSceneMgr()->getEntity(node->getName()); + expectEntityHasSingleQuadBinding(entity, facesBefore[0]); +} + +TEST_F(SceneSaveLoadTest, RoundTrip_QuadMesh_PreservesNgonFaceBinding_Glb) +{ + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + const QString objPath = writeQuadObjForScene(tmpDir, "scene_ngon_roundtrip_glb.obj"); + ASSERT_FALSE(objPath.isEmpty()); + + MeshImporterExporter::importer(QStringList{objPath}); + + auto* manager = Manager::getSingleton(); + ASSERT_EQ(manager->getSceneNodes().size(), 1); + auto* node = manager->getSceneNodes().front(); + ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + auto* entity = manager->getSceneMgr()->getEntity(node->getName()); + + std::vector> facesBefore; + ASSERT_TRUE(readNgonFacesFromMesh(entity->getMesh().get(), 0, facesBefore)); + ASSERT_EQ(facesBefore.size(), 1u); + + const QString sceneFile = tmpDir.filePath("ngon.scene.glb"); + ASSERT_EQ(MeshImporterExporter::sceneExporter(sceneFile), 0); + + ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); + ASSERT_EQ(manager->getSceneNodes().size(), 1); + + node = manager->getSceneNodes().front(); + ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + entity = manager->getSceneMgr()->getEntity(node->getName()); + expectEntityHasSingleQuadBinding(entity, facesBefore[0]); +} + +TEST_F(SceneSaveLoadTest, RoundTrip_QuadMeshWithUnusedSharedVertex_RemapPreservesNgonFaceBinding) +{ + auto* manager = Manager::getSingleton(); + Ogre::MeshPtr mesh = createQuadMeshWithUnusedSharedVertex("SceneQuadUnusedSharedVertex"); + ASSERT_TRUE(mesh); + + Ogre::SceneNode* node = manager->addSceneNode("SceneQuadUnusedSharedVertex"); + ASSERT_NE(node, nullptr); + Ogre::Entity* entity = manager->createEntity(node, mesh); + ASSERT_NE(entity, nullptr); + + expectEntityHasSingleQuadBinding(entity, {0, 2, 3, 4}); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + const QString sceneFile = tmpDir.filePath("ngon_compacted.scene.gltf"); + ASSERT_EQ(MeshImporterExporter::sceneExporter(sceneFile), 0); + + ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); + ASSERT_EQ(manager->getSceneNodes().size(), 1); + + node = manager->getSceneNodes().front(); + ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + entity = manager->getSceneMgr()->getEntity(node->getName()); + expectEntityHasSingleQuadBinding(entity, {0, 1, 2, 3}); +} + // Slice F3 export-side PBR slot dispatch: // buildAiMaterialFromOgre routed every TUS that wasn't named "normal_map" // to aiTextureType_DIFFUSE — so on re-import roughness/metallic/ao/emissive diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index 9e4590046..18eea2989 100644 --- a/website/src/hooks/useQtmeshActionRef.js +++ b/website/src/hooks/useQtmeshActionRef.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest'; -const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.0.1'; +const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.1.0'; const CACHE_KEY = 'qtmesh.actionRef.cache.v1'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000;