diff --git a/CMakeLists.txt b/CMakeLists.txt index ec0015d4..8ef04fe4 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.9.1 LANGUAGES C CXX) +project(QtMeshEditor VERSION 3.9.2 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 f73041ef..02325cf5 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,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.9.1**). 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.9.2**). 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): @@ -53,10 +53,10 @@ jobs: - uses: actions/checkout@v4 - name: Run QtMesh scan - uses: fernandotonon/QtMeshEditor@3.9.1 + uses: fernandotonon/QtMeshEditor@3.9.2 with: command: scan - image-tag: "3.9.1" + image-tag: "3.9.2" env: QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }} ``` @@ -81,37 +81,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/ ```yaml # Validate a specific mesh -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: validate input-file: ./models/character.fbx - image-tag: "3.9.1" + image-tag: "3.9.2" # Convert FBX → glTF -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: convert input-file: ./models/character.fbx output-file: ./output/character.gltf2 - image-tag: "3.9.1" + image-tag: "3.9.2" # Resample Mixamo animations (200+ keyframes → 30) -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: anim input-file: ./animations/dance.fbx output-file: ./output/dance_optimized.fbx options: --resample 30 - image-tag: "3.9.1" + image-tag: "3.9.2" # Get mesh info as JSON -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 id: info with: command: info input-file: ./models/character.fbx options: --json - image-tag: "3.9.1" + image-tag: "3.9.2" # Docker (alternative — :latest tracks newest image; pin :3.4.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 cf3042df..3ab54b54 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1669,13 +1669,44 @@ QString cloudProjectCacheRoot(const QString& localPath) return normalized.left(cloudIdx + marker.size() + slugEnd); } -void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile) +QStringList MeshImporterExporter::textureSearchRootsForImportFile(const QString& localPath) { - const QFileInfo fileInfo(localMainFile); - registerImportResourceDirectory(fileInfo.absolutePath()); + QStringList roots; + const QFileInfo fileInfo(localPath); + if (!fileInfo.exists()) + return roots; + + roots << fileInfo.absolutePath(); const QString cloudRoot = cloudProjectCacheRoot(fileInfo.absoluteFilePath()); if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath()) - registerImportResourceDirectory(cloudRoot); + roots << cloudRoot; + return roots; +} + +QStringList MeshImporterExporter::textureSearchRootsForEntity(const Ogre::Entity* entity) +{ + if (!entity) + return {}; + const Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) + return {}; + const Ogre::Any& any = mesh->getUserObjectBindings().getUserAny("qtme.source_path"); + if (!any.has_value()) + return {}; + try { + const std::string sourcePath = Ogre::any_cast(any); + if (sourcePath.empty()) + return {}; + return textureSearchRootsForImportFile(QString::fromStdString(sourcePath)); + } catch (const Ogre::Exception&) { + return {}; + } +} + +void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile) +{ + for (const QString& root : textureSearchRootsForImportFile(localMainFile)) + registerImportResourceDirectory(root); } /** @return true if at least one declared material exists in the manager for this group. */ diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index 521b60f3..0c79e3f1 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -91,6 +91,12 @@ class MeshImporterExporter /// Register cloud cache paths before importing a downloaded project file. static void prepareCloudCachedImport(const QString& localMainFile); + /// Directories to search for sidecar textures after import (file dir + cloud cache root). + static QStringList textureSearchRootsForImportFile(const QString& localPath); + + /// Texture search roots for an entity from its mesh `qtme.source_path` binding. + static QStringList textureSearchRootsForEntity(const Ogre::Entity* entity); + /// Recompile RTSS materials and force SubEntity technique refresh (post-import). static void rebindEntityMaterials(Ogre::Entity* entity, const QStringList& textureSearchRoots = {}); diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index f8530fd9..cb4dcd3a 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -159,6 +159,103 @@ class TexturePaintMaskActionCommand : public QUndoCommand bool m_skipFirstRedo = true; }; +Ogre::TexturePtr findTextureAcrossGroups(const std::string& name) +{ + auto texPtr = Ogre::TextureManager::getSingleton().getByName(name); + if (texPtr) + return texPtr; + auto it = Ogre::TextureManager::getSingleton().getResourceIterator(); + while (it.hasMoreElements()) { + const Ogre::ResourcePtr r = it.getNext(); + if (r && r->getName() == name) + return Ogre::static_pointer_cast(r); + } + return {}; +} + +bool copyQImageToPaintBuffer(TexturePaintBuffer& buffer, const QImage& source) +{ + QImage qimg = source; + if (qimg.isNull()) + return false; + if (qimg.format() != QImage::Format_RGBA8888) + qimg = qimg.convertToFormat(QImage::Format_RGBA8888); + const int w = qimg.width(); + const int h = qimg.height(); + if (w <= 0 || h <= 0) + return false; + buffer.resize(w, h); + for (int y = 0; y < h; ++y) { + std::memcpy(buffer.data().data() + static_cast(y) * static_cast(w) * 4u, + qimg.constScanLine(y), + static_cast(w) * 4u); + } + buffer.clearDirty(); + return true; +} + +bool loadPaintBufferFromImageBytes(TexturePaintBuffer& buffer, + const uint8_t* data, + std::size_t size) +{ + QImage qimg; + if (!qimg.loadFromData(data, static_cast(size))) + return false; + return copyQImageToPaintBuffer(buffer, qimg); +} + +bool loadPaintBufferFromDiskPath(TexturePaintBuffer& buffer, const QString& path) +{ + if (path.isEmpty() || !QFileInfo::exists(path)) + return false; + return copyQImageToPaintBuffer(buffer, QImage(path)); +} + +// CPU-side sources first — same order as MaterialEditorQML::previewUrlFromOgreTexture. +// GPU readback (convertToImage / blitToMemory) is unreliable for imported FBX textures. +bool loadPaintBufferFromNonGpuSources(TexturePaintBuffer& buffer, + const Ogre::TexturePtr& texPtr, + const QString& texName) +{ + if (texName.isEmpty()) + return false; + + if (texPtr) { + const QString origin = QString::fromStdString(texPtr->getOrigin()); + if (!origin.isEmpty() && loadPaintBufferFromDiskPath(buffer, origin)) + return true; + + const QString group = QString::fromStdString(texPtr->getGroup()); + if (!group.isEmpty()) { + if (loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + texName)) + return true; + if (!origin.isEmpty() + && loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + origin)) { + return true; + } + } + } + + const std::vector bytes = + EmbeddedTextureCache::retrieve(texName.toStdString()); + if (!bytes.empty() + && loadPaintBufferFromImageBytes(buffer, bytes.data(), bytes.size())) { + return true; + } + + const QString baseName = QFileInfo(texName).fileName(); + if (baseName != texName) { + const std::vector baseBytes = + EmbeddedTextureCache::retrieve(baseName.toStdString()); + if (!baseBytes.empty() + && loadPaintBufferFromImageBytes(buffer, baseBytes.data(), baseBytes.size())) { + return true; + } + } + + return loadPaintBufferFromDiskPath(buffer, texName); +} + } // namespace TexturePaintController* TexturePaintController::instance() @@ -539,9 +636,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) Ogre::TexturePtr originalTex; if (!existingTex.isEmpty()) { try { - originalTex = Ogre::TextureManager::getSingleton().getByName( - existingTex.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + originalTex = findTextureAcrossGroups(existingTex.toStdString()); } catch (...) {} } m_originalTexture = originalTex; @@ -551,16 +646,22 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) // come from inline FBX embeds (no disk file), legacy on-disk // files, or auto-generated render targets. Each strategy // succeeds for a different source. - // + Ogre::TexturePtr existing = originalTex; + if (!existing) { + try { + existing = findTextureAcrossGroups(existingTex.toStdString()); + } catch (...) {} + } + + // 0. CPU-side: embedded FBX bytes, on-disk origin, resource-group path. + if (loadPaintBufferFromNonGpuSources(m_buffer, existing, existingTex)) { + loadedExisting = true; + loadError.clear(); + } + // 1. TextureManager → convertToImage (works when Ogre keeps // pixels in an Image buffer beside the GPU upload). - Ogre::TexturePtr existing; - try { - existing = Ogre::TextureManager::getSingleton().getByName( - existingTex.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); - } catch (...) {} - if (existing) { + if (!loadedExisting && existing) { try { if (!existing->isLoaded()) existing->load(); Ogre::Image img; @@ -581,7 +682,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) } catch (...) { loadError = QStringLiteral("convertToImage exception"); } - } else { + } else if (!loadedExisting && !existing) { loadError = QStringLiteral("texture not found in TextureManager"); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index cafc1627..64e666b9 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -3879,26 +3879,9 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) continue; - QStringList textureRoots; - textureRoots << fileInfo.absolutePath(); - const QString normalized = QDir::fromNativeSeparators(fileInfo.absoluteFilePath()); - const QString marker = QStringLiteral("/cloud/"); - const int cloudIdx = normalized.indexOf(marker); - if (cloudIdx >= 0) { - const QString tail = normalized.mid(cloudIdx + marker.size()); - const int ownerEnd = tail.indexOf(QLatin1Char('/')); - if (ownerEnd > 0) { - const int slugEnd = tail.indexOf(QLatin1Char('/'), ownerEnd + 1); - const QString cloudRoot = slugEnd < 0 - ? normalized - : normalized.left(cloudIdx + marker.size() + slugEnd); - if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath()) - textureRoots << cloudRoot; - } - } - auto* entity = static_cast(obj); - MeshImporterExporter::rebindEntityMaterials(entity, textureRoots); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForImportFile(localMainFile)); } SpaceCamera* cam = nullptr; @@ -3914,15 +3897,14 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) cam->frameSelection(); QTimer::singleShot(0, this, [this, localMainFile, entityNamesBefore]() { - const QFileInfo fileInfo(localMainFile); + const QStringList textureRoots = + MeshImporterExporter::textureSearchRootsForImportFile(localMainFile); for (auto* obj : Manager::getSingleton()->getEntities()) { if (!obj || obj->getMovableType() != QLatin1String("Entity")) continue; if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) continue; - QStringList textureRoots; - textureRoots << fileInfo.absolutePath(); MeshImporterExporter::rebindEntityMaterials(static_cast(obj), textureRoots); } @@ -3939,6 +3921,12 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) void MainWindow::importMeshs(const QStringList &_uriList) { + QSet entityNamesBefore; + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == QLatin1String("Entity")) + entityNamesBefore.insert(QString::fromStdString(obj->getName())); + } + auto txn = SentryReporter::startTransaction("ui.import", "file.import"); QList animOnlySkeletons; try { @@ -3949,6 +3937,39 @@ void MainWindow::importMeshs(const QStringList &_uriList) } SentryReporter::finishTransaction(txn); + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (!obj || obj->getMovableType() != QLatin1String("Entity")) + continue; + if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) + continue; + + auto* entity = static_cast(obj); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForEntity(entity)); + } + + QTimer::singleShot(0, this, [this, entityNamesBefore]() { + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (!obj || obj->getMovableType() != QLatin1String("Entity")) + continue; + if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) + continue; + + auto* entity = static_cast(obj); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForEntity(entity)); + } + + if (m_pRoot && m_pRoot->getRenderSystem()) { + try { + m_pRoot->renderOneFrame(); + } catch (...) { + } + } + for (EditorViewport* vp : mDockWidgetList) + vp->getOgreWidget()->update(); + }); + // Handle animation-only files: show a notification and offer an immediate merge // if a compatible entity is already selected. for (const Ogre::SkeletonPtr& skel : animOnlySkeletons) { diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index a70fb5d1..057686d2 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.9.1'; +const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.9.2'; const CACHE_KEY = 'qtmesh.actionRef.cache.v1'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000;