Skip to content

Commit db5a1df

Browse files
fix(import): restore textured viewport and paint preview on File→Open (#757)
* fix(import): restore textured viewport and paint preview on File→Open Rebind RTSS materials for newly imported entities (same path cloud downloads already used) and load texture-paint buffers from embedded/disk sources before GPU readback, which fails for many imported FBX textures on Linux. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(import): scope texture rebind per entity source file Use each mesh's qtme.source_path binding for sidecar texture lookup so multi-file File→Open imports cannot cross-bind common texture basenames. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: bump version to 3.9.2 Sync README and website pinned refs via sync-doc-versions-from-cmake.sh. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 486704c commit db5a1df

7 files changed

Lines changed: 210 additions & 51 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0)
1313
cmake_policy(SET CMP0005 NEW)
1414
cmake_policy(SET CMP0048 NEW) # manages project version
1515

16-
project(QtMeshEditor VERSION 3.9.1 LANGUAGES C CXX)
16+
project(QtMeshEditor VERSION 3.9.2 LANGUAGES C CXX)
1717
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")
1818

1919
set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Available on the [GitHub Actions Marketplace](https://github.com/marketplace/act
3535
**Versioning**
3636

3737
- **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.
38-
- **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`.
38+
- **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`.
3939

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

@@ -53,10 +53,10 @@ jobs:
5353
- uses: actions/checkout@v4
5454

5555
- name: Run QtMesh scan
56-
uses: fernandotonon/QtMeshEditor@3.9.1
56+
uses: fernandotonon/QtMeshEditor@3.9.2
5757
with:
5858
command: scan
59-
image-tag: "3.9.1"
59+
image-tag: "3.9.2"
6060
env:
6161
QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }}
6262
```
@@ -81,37 +81,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/
8181

8282
```yaml
8383
# Validate a specific mesh
84-
- uses: fernandotonon/QtMeshEditor@3.9.1
84+
- uses: fernandotonon/QtMeshEditor@3.9.2
8585
with:
8686
command: validate
8787
input-file: ./models/character.fbx
88-
image-tag: "3.9.1"
88+
image-tag: "3.9.2"
8989
9090
# Convert FBX → glTF
91-
- uses: fernandotonon/QtMeshEditor@3.9.1
91+
- uses: fernandotonon/QtMeshEditor@3.9.2
9292
with:
9393
command: convert
9494
input-file: ./models/character.fbx
9595
output-file: ./output/character.gltf2
96-
image-tag: "3.9.1"
96+
image-tag: "3.9.2"
9797
9898
# Resample Mixamo animations (200+ keyframes → 30)
99-
- uses: fernandotonon/QtMeshEditor@3.9.1
99+
- uses: fernandotonon/QtMeshEditor@3.9.2
100100
with:
101101
command: anim
102102
input-file: ./animations/dance.fbx
103103
output-file: ./output/dance_optimized.fbx
104104
options: --resample 30
105-
image-tag: "3.9.1"
105+
image-tag: "3.9.2"
106106
107107
# Get mesh info as JSON
108-
- uses: fernandotonon/QtMeshEditor@3.9.1
108+
- uses: fernandotonon/QtMeshEditor@3.9.2
109109
id: info
110110
with:
111111
command: info
112112
input-file: ./models/character.fbx
113113
options: --json
114-
image-tag: "3.9.1"
114+
image-tag: "3.9.2"
115115
116116
# Docker (alternative — :latest tracks newest image; pin :3.4.0 to match semver action ref)
117117
docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error

src/MeshImporterExporter.cpp

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,13 +1669,44 @@ QString cloudProjectCacheRoot(const QString& localPath)
16691669
return normalized.left(cloudIdx + marker.size() + slugEnd);
16701670
}
16711671

1672-
void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile)
1672+
QStringList MeshImporterExporter::textureSearchRootsForImportFile(const QString& localPath)
16731673
{
1674-
const QFileInfo fileInfo(localMainFile);
1675-
registerImportResourceDirectory(fileInfo.absolutePath());
1674+
QStringList roots;
1675+
const QFileInfo fileInfo(localPath);
1676+
if (!fileInfo.exists())
1677+
return roots;
1678+
1679+
roots << fileInfo.absolutePath();
16761680
const QString cloudRoot = cloudProjectCacheRoot(fileInfo.absoluteFilePath());
16771681
if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath())
1678-
registerImportResourceDirectory(cloudRoot);
1682+
roots << cloudRoot;
1683+
return roots;
1684+
}
1685+
1686+
QStringList MeshImporterExporter::textureSearchRootsForEntity(const Ogre::Entity* entity)
1687+
{
1688+
if (!entity)
1689+
return {};
1690+
const Ogre::MeshPtr mesh = entity->getMesh();
1691+
if (!mesh)
1692+
return {};
1693+
const Ogre::Any& any = mesh->getUserObjectBindings().getUserAny("qtme.source_path");
1694+
if (!any.has_value())
1695+
return {};
1696+
try {
1697+
const std::string sourcePath = Ogre::any_cast<std::string>(any);
1698+
if (sourcePath.empty())
1699+
return {};
1700+
return textureSearchRootsForImportFile(QString::fromStdString(sourcePath));
1701+
} catch (const Ogre::Exception&) {
1702+
return {};
1703+
}
1704+
}
1705+
1706+
void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile)
1707+
{
1708+
for (const QString& root : textureSearchRootsForImportFile(localMainFile))
1709+
registerImportResourceDirectory(root);
16791710
}
16801711

16811712
/** @return true if at least one declared material exists in the manager for this group. */

src/MeshImporterExporter.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ class MeshImporterExporter
9191
/// Register cloud cache paths before importing a downloaded project file.
9292
static void prepareCloudCachedImport(const QString& localMainFile);
9393

94+
/// Directories to search for sidecar textures after import (file dir + cloud cache root).
95+
static QStringList textureSearchRootsForImportFile(const QString& localPath);
96+
97+
/// Texture search roots for an entity from its mesh `qtme.source_path` binding.
98+
static QStringList textureSearchRootsForEntity(const Ogre::Entity* entity);
99+
94100
/// Recompile RTSS materials and force SubEntity technique refresh (post-import).
95101
static void rebindEntityMaterials(Ogre::Entity* entity,
96102
const QStringList& textureSearchRoots = {});

src/TexturePaintController.cpp

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,103 @@ class TexturePaintMaskActionCommand : public QUndoCommand
159159
bool m_skipFirstRedo = true;
160160
};
161161

162+
Ogre::TexturePtr findTextureAcrossGroups(const std::string& name)
163+
{
164+
auto texPtr = Ogre::TextureManager::getSingleton().getByName(name);
165+
if (texPtr)
166+
return texPtr;
167+
auto it = Ogre::TextureManager::getSingleton().getResourceIterator();
168+
while (it.hasMoreElements()) {
169+
const Ogre::ResourcePtr r = it.getNext();
170+
if (r && r->getName() == name)
171+
return Ogre::static_pointer_cast<Ogre::Texture>(r);
172+
}
173+
return {};
174+
}
175+
176+
bool copyQImageToPaintBuffer(TexturePaintBuffer& buffer, const QImage& source)
177+
{
178+
QImage qimg = source;
179+
if (qimg.isNull())
180+
return false;
181+
if (qimg.format() != QImage::Format_RGBA8888)
182+
qimg = qimg.convertToFormat(QImage::Format_RGBA8888);
183+
const int w = qimg.width();
184+
const int h = qimg.height();
185+
if (w <= 0 || h <= 0)
186+
return false;
187+
buffer.resize(w, h);
188+
for (int y = 0; y < h; ++y) {
189+
std::memcpy(buffer.data().data() + static_cast<size_t>(y) * static_cast<size_t>(w) * 4u,
190+
qimg.constScanLine(y),
191+
static_cast<size_t>(w) * 4u);
192+
}
193+
buffer.clearDirty();
194+
return true;
195+
}
196+
197+
bool loadPaintBufferFromImageBytes(TexturePaintBuffer& buffer,
198+
const uint8_t* data,
199+
std::size_t size)
200+
{
201+
QImage qimg;
202+
if (!qimg.loadFromData(data, static_cast<int>(size)))
203+
return false;
204+
return copyQImageToPaintBuffer(buffer, qimg);
205+
}
206+
207+
bool loadPaintBufferFromDiskPath(TexturePaintBuffer& buffer, const QString& path)
208+
{
209+
if (path.isEmpty() || !QFileInfo::exists(path))
210+
return false;
211+
return copyQImageToPaintBuffer(buffer, QImage(path));
212+
}
213+
214+
// CPU-side sources first — same order as MaterialEditorQML::previewUrlFromOgreTexture.
215+
// GPU readback (convertToImage / blitToMemory) is unreliable for imported FBX textures.
216+
bool loadPaintBufferFromNonGpuSources(TexturePaintBuffer& buffer,
217+
const Ogre::TexturePtr& texPtr,
218+
const QString& texName)
219+
{
220+
if (texName.isEmpty())
221+
return false;
222+
223+
if (texPtr) {
224+
const QString origin = QString::fromStdString(texPtr->getOrigin());
225+
if (!origin.isEmpty() && loadPaintBufferFromDiskPath(buffer, origin))
226+
return true;
227+
228+
const QString group = QString::fromStdString(texPtr->getGroup());
229+
if (!group.isEmpty()) {
230+
if (loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + texName))
231+
return true;
232+
if (!origin.isEmpty()
233+
&& loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + origin)) {
234+
return true;
235+
}
236+
}
237+
}
238+
239+
const std::vector<uint8_t> bytes =
240+
EmbeddedTextureCache::retrieve(texName.toStdString());
241+
if (!bytes.empty()
242+
&& loadPaintBufferFromImageBytes(buffer, bytes.data(), bytes.size())) {
243+
return true;
244+
}
245+
246+
const QString baseName = QFileInfo(texName).fileName();
247+
if (baseName != texName) {
248+
const std::vector<uint8_t> baseBytes =
249+
EmbeddedTextureCache::retrieve(baseName.toStdString());
250+
if (!baseBytes.empty()
251+
&& loadPaintBufferFromImageBytes(buffer, baseBytes.data(), baseBytes.size())) {
252+
return true;
253+
}
254+
}
255+
256+
return loadPaintBufferFromDiskPath(buffer, texName);
257+
}
258+
162259
} // namespace
163260

164261
TexturePaintController* TexturePaintController::instance()
@@ -539,9 +636,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution)
539636
Ogre::TexturePtr originalTex;
540637
if (!existingTex.isEmpty()) {
541638
try {
542-
originalTex = Ogre::TextureManager::getSingleton().getByName(
543-
existingTex.toStdString(),
544-
Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME);
639+
originalTex = findTextureAcrossGroups(existingTex.toStdString());
545640
} catch (...) {}
546641
}
547642
m_originalTexture = originalTex;
@@ -551,16 +646,22 @@ bool TexturePaintController::ensurePaintableTexture(int resolution)
551646
// come from inline FBX embeds (no disk file), legacy on-disk
552647
// files, or auto-generated render targets. Each strategy
553648
// succeeds for a different source.
554-
//
649+
Ogre::TexturePtr existing = originalTex;
650+
if (!existing) {
651+
try {
652+
existing = findTextureAcrossGroups(existingTex.toStdString());
653+
} catch (...) {}
654+
}
655+
656+
// 0. CPU-side: embedded FBX bytes, on-disk origin, resource-group path.
657+
if (loadPaintBufferFromNonGpuSources(m_buffer, existing, existingTex)) {
658+
loadedExisting = true;
659+
loadError.clear();
660+
}
661+
555662
// 1. TextureManager → convertToImage (works when Ogre keeps
556663
// pixels in an Image buffer beside the GPU upload).
557-
Ogre::TexturePtr existing;
558-
try {
559-
existing = Ogre::TextureManager::getSingleton().getByName(
560-
existingTex.toStdString(),
561-
Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME);
562-
} catch (...) {}
563-
if (existing) {
664+
if (!loadedExisting && existing) {
564665
try {
565666
if (!existing->isLoaded()) existing->load();
566667
Ogre::Image img;
@@ -581,7 +682,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution)
581682
} catch (...) {
582683
loadError = QStringLiteral("convertToImage exception");
583684
}
584-
} else {
685+
} else if (!loadedExisting && !existing) {
585686
loadError = QStringLiteral("texture not found in TextureManager");
586687
}
587688

src/mainwindow.cpp

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3879,26 +3879,9 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile)
38793879
if (entityNamesBefore.contains(QString::fromStdString(obj->getName())))
38803880
continue;
38813881

3882-
QStringList textureRoots;
3883-
textureRoots << fileInfo.absolutePath();
3884-
const QString normalized = QDir::fromNativeSeparators(fileInfo.absoluteFilePath());
3885-
const QString marker = QStringLiteral("/cloud/");
3886-
const int cloudIdx = normalized.indexOf(marker);
3887-
if (cloudIdx >= 0) {
3888-
const QString tail = normalized.mid(cloudIdx + marker.size());
3889-
const int ownerEnd = tail.indexOf(QLatin1Char('/'));
3890-
if (ownerEnd > 0) {
3891-
const int slugEnd = tail.indexOf(QLatin1Char('/'), ownerEnd + 1);
3892-
const QString cloudRoot = slugEnd < 0
3893-
? normalized
3894-
: normalized.left(cloudIdx + marker.size() + slugEnd);
3895-
if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath())
3896-
textureRoots << cloudRoot;
3897-
}
3898-
}
3899-
39003882
auto* entity = static_cast<Ogre::Entity*>(obj);
3901-
MeshImporterExporter::rebindEntityMaterials(entity, textureRoots);
3883+
MeshImporterExporter::rebindEntityMaterials(
3884+
entity, MeshImporterExporter::textureSearchRootsForImportFile(localMainFile));
39023885
}
39033886

39043887
SpaceCamera* cam = nullptr;
@@ -3914,15 +3897,14 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile)
39143897
cam->frameSelection();
39153898

39163899
QTimer::singleShot(0, this, [this, localMainFile, entityNamesBefore]() {
3917-
const QFileInfo fileInfo(localMainFile);
3900+
const QStringList textureRoots =
3901+
MeshImporterExporter::textureSearchRootsForImportFile(localMainFile);
39183902
for (auto* obj : Manager::getSingleton()->getEntities()) {
39193903
if (!obj || obj->getMovableType() != QLatin1String("Entity"))
39203904
continue;
39213905
if (entityNamesBefore.contains(QString::fromStdString(obj->getName())))
39223906
continue;
39233907

3924-
QStringList textureRoots;
3925-
textureRoots << fileInfo.absolutePath();
39263908
MeshImporterExporter::rebindEntityMaterials(static_cast<Ogre::Entity*>(obj), textureRoots);
39273909
}
39283910

@@ -3939,6 +3921,12 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile)
39393921

39403922
void MainWindow::importMeshs(const QStringList &_uriList)
39413923
{
3924+
QSet<QString> entityNamesBefore;
3925+
for (auto* obj : Manager::getSingleton()->getEntities()) {
3926+
if (obj && obj->getMovableType() == QLatin1String("Entity"))
3927+
entityNamesBefore.insert(QString::fromStdString(obj->getName()));
3928+
}
3929+
39423930
auto txn = SentryReporter::startTransaction("ui.import", "file.import");
39433931
QList<Ogre::SkeletonPtr> animOnlySkeletons;
39443932
try {
@@ -3949,6 +3937,39 @@ void MainWindow::importMeshs(const QStringList &_uriList)
39493937
}
39503938
SentryReporter::finishTransaction(txn);
39513939

3940+
for (auto* obj : Manager::getSingleton()->getEntities()) {
3941+
if (!obj || obj->getMovableType() != QLatin1String("Entity"))
3942+
continue;
3943+
if (entityNamesBefore.contains(QString::fromStdString(obj->getName())))
3944+
continue;
3945+
3946+
auto* entity = static_cast<Ogre::Entity*>(obj);
3947+
MeshImporterExporter::rebindEntityMaterials(
3948+
entity, MeshImporterExporter::textureSearchRootsForEntity(entity));
3949+
}
3950+
3951+
QTimer::singleShot(0, this, [this, entityNamesBefore]() {
3952+
for (auto* obj : Manager::getSingleton()->getEntities()) {
3953+
if (!obj || obj->getMovableType() != QLatin1String("Entity"))
3954+
continue;
3955+
if (entityNamesBefore.contains(QString::fromStdString(obj->getName())))
3956+
continue;
3957+
3958+
auto* entity = static_cast<Ogre::Entity*>(obj);
3959+
MeshImporterExporter::rebindEntityMaterials(
3960+
entity, MeshImporterExporter::textureSearchRootsForEntity(entity));
3961+
}
3962+
3963+
if (m_pRoot && m_pRoot->getRenderSystem()) {
3964+
try {
3965+
m_pRoot->renderOneFrame();
3966+
} catch (...) {
3967+
}
3968+
}
3969+
for (EditorViewport* vp : mDockWidgetList)
3970+
vp->getOgreWidget()->update();
3971+
});
3972+
39523973
// Handle animation-only files: show a notification and offer an immediate merge
39533974
// if a compatible entity is already selected.
39543975
for (const Ogre::SkeletonPtr& skel : animOnlySkeletons) {

0 commit comments

Comments
 (0)