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.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}\"")
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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 }}
```
Expand All @@ -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
Expand Down
39 changes: 35 additions & 4 deletions src/MeshImporterExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1669,13 +1669,44 @@
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())

Check warning on line 1681 in src/MeshImporterExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "cloudRoot" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAZDoQgVRUpnx8wn&open=AZ72xAZDoQgVRUpnx8wn&pullRequest=757
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<std::string>(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. */
Expand Down
6 changes: 6 additions & 0 deletions src/MeshImporterExporter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {});
Expand Down
125 changes: 113 additions & 12 deletions src/TexturePaintController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,103 @@
bool m_skipFirstRedo = true;
};

Ogre::TexturePtr findTextureAcrossGroups(const std::string& name)
{
auto texPtr = Ogre::TextureManager::getSingleton().getByName(name);
if (texPtr)

Check warning on line 165 in src/TexturePaintController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "texPtr" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAPSoQgVRUpnx8we&open=AZ72xAPSoQgVRUpnx8we&pullRequest=757
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<Ogre::Texture>(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<size_t>(y) * static_cast<size_t>(w) * 4u,
qimg.constScanLine(y),
static_cast<size_t>(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<int>(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<uint8_t> bytes =
EmbeddedTextureCache::retrieve(texName.toStdString());
if (!bytes.empty()
&& loadPaintBufferFromImageBytes(buffer, bytes.data(), bytes.size())) {

Check warning on line 242 in src/TexturePaintController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "bytes" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAPSoQgVRUpnx8wf&open=AZ72xAPSoQgVRUpnx8wf&pullRequest=757
return true;
}

const QString baseName = QFileInfo(texName).fileName();
if (baseName != texName) {

Check warning on line 247 in src/TexturePaintController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "baseName" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAPSoQgVRUpnx8wg&open=AZ72xAPSoQgVRUpnx8wg&pullRequest=757
const std::vector<uint8_t> baseBytes =
EmbeddedTextureCache::retrieve(baseName.toStdString());
if (!baseBytes.empty()
&& loadPaintBufferFromImageBytes(buffer, baseBytes.data(), baseBytes.size())) {
return true;
}
}

return loadPaintBufferFromDiskPath(buffer, texName);
}

} // namespace

TexturePaintController* TexturePaintController::instance()
Expand Down Expand Up @@ -539,9 +636,7 @@
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;
Expand All @@ -551,16 +646,22 @@
// 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;
Expand All @@ -581,7 +682,7 @@
} catch (...) {
loadError = QStringLiteral("convertToImage exception");
}
} else {
} else if (!loadedExisting && !existing) {
loadError = QStringLiteral("texture not found in TextureManager");
}

Expand Down
65 changes: 43 additions & 22 deletions src/mainwindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3822,7 +3822,7 @@
void MainWindow::importCloudDownloadedFile(const QString& localMainFile)
{
const QFileInfo fileInfo(localMainFile);
if (!fileInfo.exists() || !fileInfo.isFile()) {

Check warning on line 3825 in src/mainwindow.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "fileInfo" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAW8oQgVRUpnx8wh&open=AZ72xAW8oQgVRUpnx8wh&pullRequest=757
QMessageBox::warning(this,
tr("QtMesh Cloud"),
tr("Downloaded project file was not found on disk."));
Expand Down Expand Up @@ -3879,26 +3879,9 @@
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<Ogre::Entity*>(obj);
MeshImporterExporter::rebindEntityMaterials(entity, textureRoots);
MeshImporterExporter::rebindEntityMaterials(
entity, MeshImporterExporter::textureSearchRootsForImportFile(localMainFile));
}

SpaceCamera* cam = nullptr;
Expand All @@ -3914,15 +3897,14 @@
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<Ogre::Entity*>(obj), textureRoots);
}

Expand All @@ -3939,6 +3921,12 @@

void MainWindow::importMeshs(const QStringList &_uriList)
{
QSet<QString> entityNamesBefore;
for (auto* obj : Manager::getSingleton()->getEntities()) {

Check warning on line 3925 in src/mainwindow.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "obj" is "class Ogre::Entity *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAW8oQgVRUpnx8wi&open=AZ72xAW8oQgVRUpnx8wi&pullRequest=757
if (obj && obj->getMovableType() == QLatin1String("Entity"))
entityNamesBefore.insert(QString::fromStdString(obj->getName()));
}

auto txn = SentryReporter::startTransaction("ui.import", "file.import");
QList<Ogre::SkeletonPtr> animOnlySkeletons;
try {
Expand All @@ -3949,6 +3937,39 @@
}
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<Ogre::Entity*>(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<Ogre::Entity*>(obj);
MeshImporterExporter::rebindEntityMaterials(
entity, MeshImporterExporter::textureSearchRootsForEntity(entity));
}

if (m_pRoot && m_pRoot->getRenderSystem()) {
try {
m_pRoot->renderOneFrame();
} catch (...) {

Check warning on line 3966 in src/mainwindow.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAW8oQgVRUpnx8wj&open=AZ72xAW8oQgVRUpnx8wj&pullRequest=757
}

Check warning on line 3967 in src/mainwindow.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Fill this compound statement, remove it, or add a nested comment explaining why it is empty.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAW8oQgVRUpnx8wk&open=AZ72xAW8oQgVRUpnx8wk&pullRequest=757
}
for (EditorViewport* vp : mDockWidgetList)

Check warning on line 3969 in src/mainwindow.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "vp" is "class EditorViewport *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAW8oQgVRUpnx8wl&open=AZ72xAW8oQgVRUpnx8wl&pullRequest=757
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) {
Expand Down Expand Up @@ -4206,7 +4227,7 @@
QMessageBox::critical(this, "Material List Error",
QString("QML Material List Modal encountered an error: %1").arg(e.what()));

} catch (...) {

Check warning on line 4230 in src/mainwindow.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

"catch" a specific exception type.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ72xAW8oQgVRUpnx8wm&open=AZ72xAW8oQgVRUpnx8wm&pullRequest=757
qDebug() << "Unknown exception in QML Material List Modal creation";
QMessageBox::critical(this, "Material List Error",
"QML Material List Modal encountered an unknown error.");
Expand Down
Loading
Loading