From 1a677fbd669965cdeed1a5d91a3fe12e962d6d08 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 12:04:52 -0400 Subject: [PATCH 1/4] Add read-only UV Editor panel (issue #459). Introduces UVEditorController + UVEditorPanel.qml with island-colored UV layout, texture preview, pan/zoom/fit, and bottom-dock tab integration alongside Context/Console/Curve Editor. Includes headless island-detection unit tests. Co-authored-by: Cursor --- qml/UVEditorPanel.qml | 367 +++++++++++++++++++++++++ src/CMakeLists.txt | 2 + src/UVEditorController.cpp | 466 ++++++++++++++++++++++++++++++++ src/UVEditorController.h | 108 ++++++++ src/UVEditorController_test.cpp | 139 ++++++++++ src/mainwindow.cpp | 74 ++++- src/mainwindow.h | 1 + src/qml_resources.qrc | 4 + 8 files changed, 1160 insertions(+), 1 deletion(-) create mode 100644 qml/UVEditorPanel.qml create mode 100644 src/UVEditorController.cpp create mode 100644 src/UVEditorController.h create mode 100644 src/UVEditorController_test.cpp diff --git a/qml/UVEditorPanel.qml b/qml/UVEditorPanel.qml new file mode 100644 index 00000000..b492a702 --- /dev/null +++ b/qml/UVEditorPanel.qml @@ -0,0 +1,367 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PropertiesPanel 1.0 +import ThemeManager 1.0 + +// Read-only UV layout viewer (issue #459). Software Canvas2D — no GL. +Rectangle { + id: root + color: ThemeManager.panelColor + focus: true + + // panU/panV = UV coordinate at the canvas centre; zoom = pixels per UV unit. + property real panU: 0.5 + property real panV: 0.5 + property real zoom: 200.0 + + property var triCache: [] + property int cachedRevision: -1 + + function uvToScreen(u, v) { + return Qt.point( + (u - panU) * zoom + viewCanvas.width * 0.5, + (panV - v) * zoom + viewCanvas.height * 0.5 + ) + } + + function screenToUv(x, y) { + return Qt.point( + (x - viewCanvas.width * 0.5) / zoom + panU, + panV - (y - viewCanvas.height * 0.5) / zoom + ) + } + + function rebuildTriangleCache() { + if (UVEditorController.meshRevision === cachedRevision) + return + cachedRevision = UVEditorController.meshRevision + triCache = UVEditorController.triangles() + viewCanvas.requestPaint() + } + + function resetView() { + const availW = Math.max(1, viewCanvas.width * 0.9) + const availH = Math.max(1, viewCanvas.height * 0.9) + panU = 0.5 + panV = 0.5 + zoom = Math.min(availW, availH) + viewCanvas.requestPaint() + } + + function fitToView() { + if (!UVEditorController.hasMesh) + return + const b = UVEditorController.uvBounds + const pad = 0.05 + const spanU = Math.max(b.width, 1e-4) + const spanV = Math.max(b.height, 1e-4) + const cx = b.x + spanU * 0.5 + const cy = b.y + spanV * 0.5 + const availW = Math.max(1, viewCanvas.width * 0.9) + const availH = Math.max(1, viewCanvas.height * 0.9) + panU = cx + panV = cy + zoom = Math.min(availW / (spanU + pad * 2), availH / (spanV + pad * 2)) + viewCanvas.requestPaint() + } + + Connections { + target: UVEditorController + function onMeshDataChanged() { + root.rebuildTriangleCache() + if (UVEditorController.hasMesh) + Qt.callLater(root.fitToView) + } + function onFitToViewRequested() { root.fitToView() } + function onShowTextureBackgroundChanged() { viewCanvas.requestPaint() } + } + + Component.onCompleted: { + rebuildTriangleCache() + Qt.callLater(fitToView) + } + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_F) { + fitToView() + event.accepted = true + } else if (event.key === Qt.Key_Home) { + resetView() + event.accepted = true + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 4 + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: UVEditorController.statusText + color: ThemeManager.textColor + font.pixelSize: 11 + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: UVEditorController.hasMesh + ? (UVEditorController.islandCount + " islands") + : "" + color: ThemeManager.disabledTextColor + font.pixelSize: 10 + } + + Text { + text: "Channel" + color: ThemeManager.disabledTextColor + font.pixelSize: 10 + } + + ThemedComboBox { + id: channelBox + Layout.preferredWidth: 58 + model: ["UV0", "UV1"] + currentIndex: UVEditorController.uvChannel + onActivated: UVEditorController.uvChannel = currentIndex + } + + Connections { + target: UVEditorController + function onUvChannelChanged() { + channelBox.currentIndex = UVEditorController.uvChannel + } + } + + Row { + spacing: 4 + anchors.verticalCenter: parent.verticalCenter + Rectangle { + width: 18; height: 18; radius: 3 + color: UVEditorController.showTextureBackground + ? ThemeManager.highlightColor + : ThemeManager.inputColor + border.color: ThemeManager.borderColor + border.width: 1 + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: UVEditorController.showTextureBackground ? "\u2713" : "" + color: ThemeManager.textColor + font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: UVEditorController.showTextureBackground + = !UVEditorController.showTextureBackground + } + } + Text { + text: "Texture" + color: ThemeManager.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + } + + Text { + text: "Fit" + color: ThemeManager.textColor + font.pixelSize: 11 + font.underline: fitMa.containsMouse + MouseArea { + id: fitMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.fitToView() + } + } + Text { + text: "100%" + color: ThemeManager.textColor + font.pixelSize: 11 + font.underline: resetMa.containsMouse + MouseArea { + id: resetMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.resetView() + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: ThemeManager.inputColor + border.color: ThemeManager.borderColor + border.width: 1 + clip: true + + // Texture mapped into 0–1 UV space (same transform as wireframe). + Image { + id: texBg + visible: UVEditorController.showTextureBackground + && UVEditorController.textureBackgroundSource.length > 0 + && UVEditorController.hasMesh + x: Math.min(root.uvToScreen(0, 1).x, root.uvToScreen(1, 0).x) + y: Math.min(root.uvToScreen(0, 1).y, root.uvToScreen(1, 0).y) + width: Math.abs(root.uvToScreen(1, 0).x - root.uvToScreen(0, 1).x) + height: Math.abs(root.uvToScreen(0, 0).y - root.uvToScreen(0, 1).y) + source: UVEditorController.textureBackgroundSource + fillMode: Image.Stretch + opacity: 0.65 + smooth: true + cache: false + } + + Canvas { + id: viewCanvas + anchors.fill: parent + anchors.margins: 1 + renderTarget: Canvas.Image + + onWidthChanged: viewCanvas.requestPaint() + onHeightChanged: viewCanvas.requestPaint() + + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + + if (!UVEditorController.showTextureBackground + || UVEditorController.textureBackgroundSource.length === 0) { + ctx.fillStyle = Qt.rgba(ThemeManager.inputColor.r, + ThemeManager.inputColor.g, + ThemeManager.inputColor.b, 1.0) + ctx.fillRect(0, 0, width, height) + } + + drawGrid(ctx) + drawTriangles(ctx) + drawUnitBoundary(ctx) + } + + function drawGrid(ctx) { + ctx.save() + ctx.lineWidth = 1 + ctx.strokeStyle = Qt.rgba(ThemeManager.borderColor.r, + ThemeManager.borderColor.g, + ThemeManager.borderColor.b, 0.35) + for (let i = 0; i <= 10; ++i) { + const t = i * 0.1 + const a = root.uvToScreen(t, 0) + const b = root.uvToScreen(t, 1) + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.stroke() + const c = root.uvToScreen(0, t) + const d = root.uvToScreen(1, t) + ctx.beginPath() + ctx.moveTo(c.x, c.y) + ctx.lineTo(d.x, d.y) + ctx.stroke() + } + ctx.restore() + } + + function drawUnitBoundary(ctx) { + const p0 = root.uvToScreen(0, 0) + const p1 = root.uvToScreen(1, 1) + const x = Math.min(p0.x, p1.x) + const y = Math.min(p0.y, p1.y) + const w = Math.abs(p1.x - p0.x) + const h = Math.abs(p1.y - p0.y) + ctx.save() + ctx.lineWidth = 2 + ctx.strokeStyle = ThemeManager.accentColor + ctx.strokeRect(x, y, w, h) + ctx.restore() + } + + function drawTriangles(ctx) { + if (!UVEditorController.hasMesh) + return + ctx.save() + for (let i = 0; i < root.triCache.length; ++i) { + const t = root.triCache[i] + const p0 = root.uvToScreen(t.u0, t.v0) + const p1 = root.uvToScreen(t.u1, t.v1) + const p2 = root.uvToScreen(t.u2, t.v2) + ctx.beginPath() + ctx.moveTo(p0.x, p0.y) + ctx.lineTo(p1.x, p1.y) + ctx.lineTo(p2.x, p2.y) + ctx.closePath() + ctx.fillStyle = t.color + ctx.fill() + ctx.strokeStyle = Qt.rgba(ThemeManager.textColor.r, + ThemeManager.textColor.g, + ThemeManager.textColor.b, 0.65) + ctx.lineWidth = 1 + ctx.stroke() + } + ctx.restore() + } + } + + Text { + anchors.centerIn: parent + visible: !UVEditorController.hasMesh + text: "Select a mesh to view its UV layout." + color: ThemeManager.disabledTextColor + font.pixelSize: 12 + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.MiddleButton | Qt.NoButton + hoverEnabled: true + preventStealing: false + + property real lastX: 0 + property real lastY: 0 + + onPressed: function(mouse) { + if (mouse.button === Qt.MiddleButton) { + lastX = mouse.x + lastY = mouse.y + } + } + onPositionChanged: function(mouse) { + if (mouse.buttons & Qt.MiddleButton) { + const du = (mouse.x - lastX) / root.zoom + const dv = (mouse.y - lastY) / root.zoom + root.panU -= du + root.panV += dv + lastX = mouse.x + lastY = mouse.y + viewCanvas.requestPaint() + } + } + } + + WheelHandler { + target: null + onWheel: function(event) { + const uv = root.screenToUv(event.x, event.y) + const factor = event.angleDelta.y > 0 ? 1.12 : 1.0 / 1.12 + root.zoom = Math.max(20, Math.min(8000, root.zoom * factor)) + const after = root.screenToUv(event.x, event.y) + root.panU += uv.x - after.x + root.panV += uv.y - after.y + viewCanvas.requestPaint() + event.accepted = true + } + } + } + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3d5646a0..1303d357 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -86,6 +86,7 @@ MeshOptimizerLod.cpp ExportOptimizer.cpp UvUnwrap.cpp UvUnwrapController.cpp +UVEditorController.cpp QuadRetopo.cpp QuadRetopoController.cpp SkinWeights.cpp @@ -225,6 +226,7 @@ MeshOptimizerLod.h ExportOptimizer.h UvUnwrap.h UvUnwrapController.h +UVEditorController.h QuadRetopo.h QuadRetopoController.h SkinWeights.h diff --git a/src/UVEditorController.cpp b/src/UVEditorController.cpp new file mode 100644 index 00000000..9b876bf0 --- /dev/null +++ b/src/UVEditorController.cpp @@ -0,0 +1,466 @@ +#include "UVEditorController.h" + +#include "EditableMesh.h" +#include "EditModeController.h" +#include "HalfEdgeMesh.h" +#include "EmbeddedTextureCache.h" +#include "Manager.h" +#include "SelectionSet.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +UVEditorController* UVEditorController::s_instance = nullptr; + +UVEditorController* UVEditorController::instance() +{ + if (!s_instance) + s_instance = new UVEditorController(); + return s_instance; +} + +UVEditorController* UVEditorController::qmlInstance(QQmlEngine* engine, QJSEngine*) +{ + Q_UNUSED(engine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void UVEditorController::kill() +{ + delete s_instance; + s_instance = nullptr; +} + +UVEditorController::UVEditorController(QObject* parent) + : QObject(parent) +{ + connectSignals(); + rebuildMeshCache(); +} + +void UVEditorController::connectSignals() +{ + if (auto* sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, this, &UVEditorController::refresh); + } + if (auto* mgr = Manager::getSingletonPtr()) { + connect(mgr, &Manager::entityCreated, this, &UVEditorController::refresh); + connect(mgr, &Manager::sceneNodeDestroyed, this, &UVEditorController::refresh); + } + if (auto* edit = EditModeController::instance()) { + connect(edit, &EditModeController::meshDataChanged, this, &UVEditorController::refresh); + connect(edit, &EditModeController::editModeChanged, this, &UVEditorController::refresh); + } +} + +void UVEditorController::setUvChannel(int channel) +{ + channel = std::max(0, std::min(channel, 1)); + if (m_uvChannel == channel) + return; + m_uvChannel = channel; + emit uvChannelChanged(); + rebuildMeshCache(); +} + +void UVEditorController::setShowTextureBackground(bool on) +{ + if (m_showTextureBackground == on) + return; + m_showTextureBackground = on; + emit showTextureBackgroundChanged(); + emit meshDataChanged(); +} + +void UVEditorController::refresh() +{ + rebuildMeshCache(); +} + +bool UVEditorController::readUvChannel(const Ogre::VertexData* vertexData, int channel, + std::vector& outUvs) +{ + outUvs.clear(); + if (!vertexData || vertexData->vertexCount == 0) + return false; + + const auto* elem = vertexData->vertexDeclaration->findElementBySemantic( + Ogre::VES_TEXTURE_COORDINATES, static_cast(channel)); + if (!elem) + return false; + + outUvs.resize(vertexData->vertexCount, Ogre::Vector2::ZERO); + auto vbuf = vertexData->vertexBufferBinding->getBuffer(elem->getSource()); + if (!vbuf) + return false; + + const size_t stride = vbuf->getVertexSize(); + auto* base = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t i = 0; i < vertexData->vertexCount; ++i) { + float* p = nullptr; + elem->baseVertexPointerToElement(base + i * stride, &p); + outUvs[i] = Ogre::Vector2(p[0], p[1]); + } + vbuf->unlock(); + return true; +} + +void UVEditorController::applyUvChannel(EditableMesh& mesh, Ogre::Entity* entity, int channel, + const QSet& submeshFilter) +{ + if (channel == 0 || !entity || !entity->getMesh()) + return; + + Ogre::Mesh* ogreMesh = entity->getMesh().get(); + for (size_t si = 0; si < mesh.subMeshes().size(); ++si) { + if (!submeshFilter.isEmpty() && !submeshFilter.contains(static_cast(si))) + continue; + if (si >= ogreMesh->getNumSubMeshes()) + continue; + + const Ogre::SubMesh* sub = ogreMesh->getSubMesh(static_cast(si)); + const Ogre::VertexData* vd = sub->useSharedVertices ? ogreMesh->sharedVertexData : sub->vertexData; + std::vector uvs; + if (!readUvChannel(vd, channel, uvs)) + continue; + + auto& verts = mesh.subMeshes()[si].vertices; + const size_t n = std::min(verts.size(), uvs.size()); + for (size_t vi = 0; vi < n; ++vi) { + verts[vi].uv = uvs[vi]; + verts[vi].hasUV = true; + } + } +} + +QString UVEditorController::colorForIsland(int islandId) +{ + const int hue = (islandId * 67) % 360; + return QColor::fromHsv(hue, 170, 220, 200).name(QColor::HexArgb); +} + +UVEditorController::IslandResult UVEditorController::computeIslandsFromHalfEdgeMesh(const HalfEdgeMesh& hem) +{ + IslandResult result; + const int faceTotal = static_cast(hem.faceCount()); + result.faceIslandIds.assign(faceTotal, -1); + + for (int start = 0; start < faceTotal; ++start) { + if (result.faceIslandIds[start] >= 0) + continue; + + std::queue q; + q.push(start); + result.faceIslandIds[start] = result.islandCount; + + while (!q.empty()) { + const int faceIdx = q.front(); + q.pop(); + + for (const int edgeIdx : hem.faceEdges(faceIdx)) { + const int heIdx = hem.edge(edgeIdx).halfEdge; + if (heIdx < 0) + continue; + const int twinIdx = hem.halfEdge(heIdx).twin; + if (twinIdx < 0) + continue; + const int adjFace = hem.halfEdge(twinIdx).face; + if (adjFace < 0 || result.faceIslandIds[adjFace] >= 0) + continue; + result.faceIslandIds[adjFace] = result.islandCount; + q.push(adjFace); + } + } + + ++result.islandCount; + } + + return result; +} + +UVEditorController::IslandResult UVEditorController::computeIslandsFromEditableMesh(const EditableMesh& mesh, + int uvChannel) +{ + if (mesh.subMeshes().empty()) + return {}; + + HalfEdgeMesh hem; + if (!hem.buildFromEditableMesh(mesh)) + return {}; + + Q_UNUSED(uvChannel); + return computeIslandsFromHalfEdgeMesh(hem); +} + +static QString fileUrl(const QString& path) +{ + return QUrl::fromLocalFile(path).toString(); +} + +static Ogre::TexturePtr findLoadedTextureByName(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 {}; +} + +static QString diffuseTextureNameForSubEntity(Ogre::SubEntity* sub) +{ + if (!sub) + return {}; + const Ogre::MaterialPtr mat = sub->getMaterial(); + if (!mat || mat->getNumTechniques() == 0) + return {}; + auto* tech = mat->getTechnique(0); + if (!tech || tech->getNumPasses() == 0) + return {}; + auto* pass = tech->getPass(0); + if (!pass) + return {}; + + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + const std::string n = tus->getName(); + if (n == "diffuse_map" || n == "albedo" || n == "DiffuseColor") + return QString::fromStdString(tus->getTextureName()); + } + if (pass->getNumTextureUnitStates() > 0) + return QString::fromStdString(pass->getTextureUnitState(0)->getTextureName()); + return {}; +} + +QString UVEditorController::resolveDiffuseTextureSource(Ogre::Entity* entity, int submeshIndex) +{ + if (!entity || submeshIndex < 0 + || submeshIndex >= static_cast(entity->getNumSubEntities())) + return {}; + + const QString texName = diffuseTextureNameForSubEntity(entity->getSubEntity(submeshIndex)); + if (texName.isEmpty()) + return {}; + + if (auto texPtr = findLoadedTextureByName(texName.toStdString())) { + const QString origin = QString::fromStdString(texPtr->getOrigin()); + if (!origin.isEmpty() && QFileInfo::exists(origin)) + return fileUrl(origin); + } + + const std::vector embedded = EmbeddedTextureCache::retrieve(texName.toStdString()); + if (!embedded.empty()) { + const QString outDir = + QDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) + .filePath(QStringLiteral("uv_editor_previews")); + QDir().mkpath(outDir); + const QString outPath = QDir(outDir).filePath(texName + QStringLiteral("_uvbg.png")); + if (!QFileInfo::exists(outPath)) { + QImage img; + if (img.loadFromData(embedded.data(), static_cast(embedded.size()))) + img.save(outPath, "PNG"); + } + if (QFileInfo::exists(outPath)) + return fileUrl(outPath); + } + + const QStringList candidates = { + QStringLiteral("media/materials/textures/%1").arg(texName), + QDir(QCoreApplication::applicationDirPath()).filePath(texName), + QDir::current().filePath(texName) + }; + for (const QString& path : candidates) { + if (QFileInfo::exists(path)) + return fileUrl(QFileInfo(path).absoluteFilePath()); + } + return {}; +} + +static EditableMesh filteredEditableMesh(const EditableMesh& source, const QSet& submeshFilter) +{ + EditableMesh out; + if (submeshFilter.isEmpty()) { + out.subMeshes() = source.subMeshes(); + return out; + } + + for (size_t si = 0; si < source.subMeshes().size(); ++si) { + if (submeshFilter.contains(static_cast(si))) + out.subMeshes().push_back(source.subMeshes()[si]); + } + return out; +} + +bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& submeshFilter, int uvChannel) +{ + if (!entity) + return false; + + EditableMesh displayMesh; + if (auto* edit = EditModeController::instance()) { + if (edit->isEditModeActive() && edit->editEntity() == entity && edit->currentMesh()) { + displayMesh.subMeshes() = edit->currentMesh()->subMeshes(); + } else if (!displayMesh.loadFromEntity(entity)) { + return false; + } + } else if (!displayMesh.loadFromEntity(entity)) { + return false; + } + + applyUvChannel(displayMesh, entity, uvChannel, submeshFilter); + EditableMesh mesh = filteredEditableMesh(displayMesh, submeshFilter); + if (mesh.subMeshes().empty()) + return false; + + HalfEdgeMesh hem; + if (!hem.buildFromEditableMesh(mesh)) + return false; + + const IslandResult islands = computeIslandsFromHalfEdgeMesh(hem); + + float uMin = 1e9f, vMin = 1e9f, uMax = -1e9f, vMax = -1e9f; + QVariantList tris; + tris.reserve(static_cast(hem.faceCount())); + + int heFaceIdx = 0; + for (const auto& sub : mesh.subMeshes()) { + for (const auto& tri : sub.triangles) { + if (heFaceIdx >= static_cast(islands.faceIslandIds.size())) + break; + + Ogre::Vector2 uvs[3]; + bool ok = true; + for (int c = 0; c < 3; ++c) { + const size_t vi = tri.indices[c]; + if (vi >= sub.vertices.size() || !sub.vertices[vi].hasUV) { + ok = false; + break; + } + uvs[c] = sub.vertices[vi].uv; + } + if (!ok) { + ++heFaceIdx; + continue; + } + + uMin = std::min({uMin, uvs[0].x, uvs[1].x, uvs[2].x}); + vMin = std::min({vMin, uvs[0].y, uvs[1].y, uvs[2].y}); + uMax = std::max({uMax, uvs[0].x, uvs[1].x, uvs[2].x}); + vMax = std::max({vMax, uvs[0].y, uvs[1].y, uvs[2].y}); + + const int islandId = islands.faceIslandIds[heFaceIdx]; + tris.push_back(QVariantMap{ + {QStringLiteral("u0"), uvs[0].x}, + {QStringLiteral("v0"), uvs[0].y}, + {QStringLiteral("u1"), uvs[1].x}, + {QStringLiteral("v1"), uvs[1].y}, + {QStringLiteral("u2"), uvs[2].x}, + {QStringLiteral("v2"), uvs[2].y}, + {QStringLiteral("island"), islandId}, + {QStringLiteral("color"), colorForIsland(islandId)} + }); + ++heFaceIdx; + } + } + + m_triangles = tris; + m_islandCount = islands.islandCount; + m_hasMesh = !tris.isEmpty(); + if (m_hasMesh) { + if (!std::isfinite(uMin)) + m_uvBounds = QRectF(0, 0, 1, 1); + else + m_uvBounds = QRectF(uMin, vMin, + std::max(1e-4f, uMax - uMin), + std::max(1e-4f, vMax - vMin)); + } else { + m_uvBounds = QRectF(0, 0, 1, 1); + } + + const int previewSub = submeshFilter.isEmpty() ? 0 : *submeshFilter.constBegin(); + m_textureBackgroundSource = resolveDiffuseTextureSource(entity, previewSub); + return m_hasMesh; +} + +void UVEditorController::rebuildMeshCache() +{ + m_triangles.clear(); + m_hasMesh = false; + m_islandCount = 0; + m_textureBackgroundSource.clear(); + m_uvBounds = QRectF(0, 0, 1, 1); + m_statusText = tr("Select a mesh to view UVs."); + + auto* sel = SelectionSet::getSingleton(); + if (!sel) { + ++m_meshRevision; + emit meshDataChanged(); + return; + } + + Ogre::Entity* entity = nullptr; + QSet submeshFilter; + + if (sel->hasSubEntities()) { + for (Ogre::SubEntity* sub : sel->getSubEntitiesSelectionList()) { + if (!sub) + continue; + Ogre::Entity* ent = sub->getParent(); + if (!ent) + continue; + if (!entity) + entity = ent; + if (ent != entity) + continue; + for (unsigned int si = 0; si < ent->getNumSubEntities(); ++si) { + if (ent->getSubEntity(si) == sub) { + submeshFilter.insert(static_cast(si)); + break; + } + } + } + } + + if (!entity) { + const auto entities = sel->getResolvedEntities(); + if (!entities.isEmpty()) + entity = entities.first(); + } + + if (!entity) { + ++m_meshRevision; + emit meshDataChanged(); + return; + } + + m_statusText = submeshFilter.isEmpty() + ? tr("UV layout — %1").arg(QString::fromStdString(entity->getName())) + : tr("UV layout — %1 (sub-mesh selection)").arg(QString::fromStdString(entity->getName())); + + buildFromEntity(entity, submeshFilter, m_uvChannel); + ++m_meshRevision; + emit meshDataChanged(); +} diff --git a/src/UVEditorController.h b/src/UVEditorController.h new file mode 100644 index 00000000..e628bfc4 --- /dev/null +++ b/src/UVEditorController.h @@ -0,0 +1,108 @@ +#ifndef UV_EDITOR_CONTROLLER_H +#define UV_EDITOR_CONTROLLER_H + +#include +#include +#include +#include +#include +#include + +#include + +class EditableMesh; +class HalfEdgeMesh; + +namespace Ogre { +class Entity; +class VertexData; +} + +/// QML-facing singleton for the read-only UV editor panel (issue #459). +/// Extracts UV layouts from the active mesh selection, groups triangles +/// into islands via HalfEdgeMesh adjacency, and exposes draw data to +/// UVEditorPanel.qml (Canvas2D, software rendering). +class UVEditorController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(int uvChannel READ uvChannel WRITE setUvChannel NOTIFY uvChannelChanged) + Q_PROPERTY(bool showTextureBackground READ showTextureBackground WRITE setShowTextureBackground + NOTIFY showTextureBackgroundChanged) + Q_PROPERTY(int meshRevision READ meshRevision NOTIFY meshDataChanged) + Q_PROPERTY(bool hasMesh READ hasMesh NOTIFY meshDataChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY meshDataChanged) + Q_PROPERTY(QString textureBackgroundSource READ textureBackgroundSource NOTIFY meshDataChanged) + Q_PROPERTY(QRectF uvBounds READ uvBounds NOTIFY meshDataChanged) + Q_PROPERTY(int islandCount READ islandCount NOTIFY meshDataChanged) + +public: + struct IslandResult { + int islandCount = 0; + std::vector faceIslandIds; + }; + + static UVEditorController* instance(); + static UVEditorController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + int uvChannel() const { return m_uvChannel; } + void setUvChannel(int channel); + + bool showTextureBackground() const { return m_showTextureBackground; } + void setShowTextureBackground(bool on); + + int meshRevision() const { return m_meshRevision; } + bool hasMesh() const { return m_hasMesh; } + QString statusText() const { return m_statusText; } + QString textureBackgroundSource() const { return m_textureBackgroundSource; } + QRectF uvBounds() const { return m_uvBounds; } + int islandCount() const { return m_islandCount; } + + /// Triangle draw payload for QML Canvas: list of + /// { u0,v0,u1,v1,u2,v2, island, color } maps. + Q_INVOKABLE QVariantList triangles() const { return m_triangles; } + + /// Re-read the active selection and rebuild cached draw data. + Q_INVOKABLE void refresh(); + + /// Connected UV islands for a mesh snapshot (headless tests). + static IslandResult computeIslandsFromEditableMesh(const EditableMesh& mesh, int uvChannel = 0); + +signals: + void uvChannelChanged(); + void showTextureBackgroundChanged(); + void meshDataChanged(); + void fitToViewRequested(); + +private: + explicit UVEditorController(QObject* parent = nullptr); + ~UVEditorController() override = default; + + void connectSignals(); + void rebuildMeshCache(); + bool buildFromEntity(Ogre::Entity* entity, const QSet& submeshFilter, int uvChannel); + static IslandResult computeIslandsFromHalfEdgeMesh(const HalfEdgeMesh& hem); + static bool readUvChannel(const Ogre::VertexData* vertexData, int channel, + std::vector& outUvs); + static void applyUvChannel(EditableMesh& mesh, Ogre::Entity* entity, int channel, + const QSet& submeshFilter); + static QString resolveDiffuseTextureSource(Ogre::Entity* entity, int submeshIndex); + static QString colorForIsland(int islandId); + + static UVEditorController* s_instance; + + int m_uvChannel = 0; + bool m_showTextureBackground = true; + int m_meshRevision = 0; + bool m_hasMesh = false; + QString m_statusText; + QString m_textureBackgroundSource; + QRectF m_uvBounds; + int m_islandCount = 0; + QVariantList m_triangles; +}; + +#endif // UV_EDITOR_CONTROLLER_H diff --git a/src/UVEditorController_test.cpp b/src/UVEditorController_test.cpp new file mode 100644 index 00000000..a7af9563 --- /dev/null +++ b/src/UVEditorController_test.cpp @@ -0,0 +1,139 @@ +#include + +#include + +#include "UVEditorController.h" +#include "EditableMesh.h" +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Two triangles sharing a 3D edge but with a UV seam (duplicated verts). +static Ogre::MeshPtr createSeamedQuadMesh(const std::string& name) +{ + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + auto* sub = mesh->createSubMesh(); + sub->useSharedVertices = true; + 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_FLOAT2, Ogre::VES_TEXTURE_COORDINATES); + + // v0,v1,v2 = tri A; v3,v4,v5 = tri B. v1/v2 share positions with v4/v3 but UVs differ. + const float verts[] = { + 0, 0, 0, 0.0f, 0.0f, + 1, 0, 0, 1.0f, 0.0f, + 0, 1, 0, 0.0f, 1.0f, + 1, 0, 0, 1.0f, 0.2f, + 0, 1, 0, 0.0f, 1.2f, + 1, 1, 0, 1.0f, 1.2f, + }; + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), 6, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + vbuf->writeData(0, sizeof(verts), verts); + mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); + mesh->sharedVertexData->vertexCount = 6; + + const uint16_t idx[] = {0, 1, 2, 3, 4, 5}; + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 6, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + ibuf->writeData(0, sizeof(idx), idx); + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 6; + + mesh->_setBounds(Ogre::AxisAlignedBox(0, 0, 0, 1, 1, 0)); + mesh->_setBoundingSphereRadius(1.5f); + mesh->load(); + return mesh; +} + +class UVEditorControllerTest : public ::testing::Test { +protected: + void SetUp() override { + Manager::kill(); + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb required in CI)"; + createStandardOgreMaterials(); + UVEditorController::kill(); + } + + void TearDown() override { + UVEditorController::kill(); + Manager::kill(); + } +}; + +TEST_F(UVEditorControllerTest, SingleTriangleOneIsland) +{ + auto mesh = createInMemoryTriangleMesh("UVEditor_single_tri"); + EditableMesh em; + ASSERT_TRUE(em.loadFromMesh(mesh)); + + const auto result = UVEditorController::computeIslandsFromEditableMesh(em, 0); + EXPECT_EQ(result.islandCount, 1); + ASSERT_EQ(result.faceIslandIds.size(), 1u); + EXPECT_EQ(result.faceIslandIds[0], 0); +} + +TEST_F(UVEditorControllerTest, TwoSubmeshesTwoIslands) +{ + auto mesh = createInMemoryMeshSharedVertsPlusLocalSubmesh("UVEditor_two_subs"); + EditableMesh em; + ASSERT_TRUE(em.loadFromMesh(mesh)); + + const auto result = UVEditorController::computeIslandsFromEditableMesh(em, 0); + EXPECT_EQ(result.islandCount, 2); +} + +TEST_F(UVEditorControllerTest, UvSeamSplitsIslands) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "GL context unavailable"; + + auto mesh = createSeamedQuadMesh("UVEditor_seamed_quad"); + EditableMesh em; + ASSERT_TRUE(em.loadFromMesh(mesh)); + + const auto result = UVEditorController::computeIslandsFromEditableMesh(em, 0); + EXPECT_EQ(result.islandCount, 2); + ASSERT_EQ(result.faceIslandIds.size(), 2u); + EXPECT_NE(result.faceIslandIds[0], result.faceIslandIds[1]); +} + +TEST_F(UVEditorControllerTest, ControllerRefreshTracksSelection) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "GL context unavailable"; + + auto mesh = createInMemoryTriangleMesh("UVEditor_ctrl_tri"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_node"); + auto* entity = sceneMgr->createEntity("UVEditor_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + QSignalSpy spy(ctrl, &UVEditorController::meshDataChanged); + + EXPECT_FALSE(ctrl->hasMesh()); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->refresh(); + + EXPECT_GE(spy.count(), 1); + EXPECT_TRUE(ctrl->hasMesh()); + EXPECT_EQ(ctrl->islandCount(), 1); + EXPECT_EQ(ctrl->triangles().size(), 1); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 64e666b9..e1f7427b 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -99,6 +99,7 @@ #include "MeshValidator.h" #include "AssetScanController.h" #include "UvUnwrapController.h" +#include "UVEditorController.h" #include "QuadRetopoController.h" #include "SkinWeightsController.h" #include "MeshDepthRenderer.h" @@ -468,6 +469,7 @@ MainWindow::~MainWindow() CurveEditModel::kill(); MeshLodController::kill(); UvUnwrapController::kill(); + UVEditorController::kill(); QuadRetopoController::kill(); SkinWeightsController::kill(); IsometricSpritesController::kill(); @@ -639,6 +641,11 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return UvUnwrapController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "UVEditorController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return UVEditorController::qmlInstance(engine, nullptr); + }); qmlRegisterSingletonType( "PropertiesPanel", 1, 0, "QuadRetopoController", [](QQmlEngine* engine, QJSEngine*) -> QObject* { @@ -938,6 +945,36 @@ void MainWindow::initToolBar() }); } + // UV Editor dock — read-only UV layout viewer (issue #459). + { + auto* uvEditorWidget = new QQuickWidget(); // NOSONAR — Qt parent ownership + uvEditorWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + uvEditorWidget->setMinimumHeight(kBottomToolHeight); + uvEditorWidget->setMaximumHeight(kBottomToolHeight); + uvEditorWidget->setFocusPolicy(Qt::StrongFocus); + uvEditorWidget->setSource(QUrl("qrc:/UVEditor/UVEditorPanel.qml")); + + m_uvEditorDock = new QDockWidget(tr("UV Editor"), this); // NOSONAR + m_uvEditorDock->setWidget(uvEditorWidget); + m_uvEditorDock->setObjectName("UVEditorDock"); + configureBottomToolDock(m_uvEditorDock); + addDockWidget(Qt::BottomDockWidgetArea, m_uvEditorDock); + m_uvEditorDock->hide(); + + connect(m_uvEditorDock, &QDockWidget::visibilityChanged, this, [this](bool vis) { + SentryReporter::addBreadcrumb("ui.action", + vis ? "UV Editor shown" : "UV Editor hidden"); + if (vis) { + UVEditorController::instance()->refresh(); + if (auto* root = qobject_cast(m_uvEditorDock->widget())) { + if (root->rootObject()) + QMetaObject::invokeMethod(root->rootObject(), "fitToView"); + } + } + QSettings().setValue(QStringLiteral("View/showUVEditor"), vis); + }); + } + // Console dock — Qt message log (tabbed with other bottom tools) { auto* consoleContainer = new QWidget(); @@ -1189,6 +1226,15 @@ void MainWindow::initToolBar() showBottomToolDock(m_curveEditorDock); }); + addRailButton( + QStringLiteral("UV"), + tr("Show UV Editor"), + QStringLiteral("modeUVEditorAction"), + [this]() { + SentryReporter::addBreadcrumb("ui.action", "Rail: UV Editor"); + showBottomToolDock(m_uvEditorDock); + }); + addRailButton( QStringLiteral("\u2713"), tr("Validate selected mesh"), @@ -2225,6 +2271,26 @@ void MainWindow::initToolBar() }); }); } + if (m_uvEditorDock && ui->menuView) { + QAction* uvAct = m_uvEditorDock->toggleViewAction(); + uvAct->setText(tr("UV Editor")); + uvAct->setChecked( + QSettings().value(QStringLiteral("View/showUVEditor"), false).toBool()); + ui->menuView->addAction(uvAct); + connect(uvAct, &QAction::triggered, this, [this](bool checked) { + if (!checked || !m_uvEditorDock) + return; + + QTimer::singleShot(0, this, [this]() { + showBottomToolDock(m_uvEditorDock); + }); + }); + if (uvAct->isChecked()) { + QTimer::singleShot(0, this, [this]() { + showBottomToolDock(m_uvEditorDock); + }); + } + } if (m_bottomContextDock && m_consoleDock && ui->menuView) { m_contextPanelViewAction = new QAction(tr("Context Panel"), this); m_contextPanelViewAction->setObjectName(QStringLiteral("actionView_Context_Panel")); @@ -3115,6 +3181,11 @@ void MainWindow::revealBottomTool(const QString& toolId) dock = m_consoleDock; breadcrumb = QStringLiteral("Rail: Console"); } + else if (toolId == QStringLiteral("uvEditor")) + { + dock = m_uvEditorDock; + breadcrumb = QStringLiteral("Rail: UV Editor"); + } if (dock) { SentryReporter::addBreadcrumb("ui.action", breadcrumb); @@ -3194,7 +3265,8 @@ void MainWindow::tabifyBottomToolDocks() m_consoleDock, m_assetBrowserDock, m_dopeSheetDock, - m_curveEditorDock + m_curveEditorDock, + m_uvEditorDock }; QDockWidget* anchor = nullptr; diff --git a/src/mainwindow.h b/src/mainwindow.h index 8b740c0f..221e87e7 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -198,6 +198,7 @@ public slots: QDockWidget* m_assetBrowserDock = nullptr; QDockWidget* m_dopeSheetDock = nullptr; QDockWidget* m_curveEditorDock = nullptr; + QDockWidget* m_uvEditorDock = nullptr; QDockWidget* m_bottomContextDock = nullptr; QDockWidget* m_consoleDock = nullptr; QPlainTextEdit* m_consoleEdit = nullptr; diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index e03fc373..034a7796 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -46,6 +46,10 @@ ../qml/AnimationCurveEditor.qml ../qml/ThemedComboBox.qml + + ../qml/UVEditorPanel.qml + ../qml/ThemedComboBox.qml + ../qml/AIChatPanel.qml From 67a5048afcfc29796f001ee5b00ddeaa3f3cfb1a Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 12:27:54 -0400 Subject: [PATCH 2/4] Fix UV Editor CI link and address review feedback. Link UVEditorController into UnitTests, stabilize preview submesh selection, sanitize texture cache paths, and harden macOS bundle texture resolution. Co-authored-by: Cursor --- src/UVEditorController.cpp | 63 +++++++++++++++++++++++++++------ src/UVEditorController.h | 3 +- src/UVEditorController_test.cpp | 19 ++++++++-- tests/CMakeLists.txt | 1 + 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/UVEditorController.cpp b/src/UVEditorController.cpp index 9b876bf0..2becf6a5 100644 --- a/src/UVEditorController.cpp +++ b/src/UVEditorController.cpp @@ -6,12 +6,14 @@ #include "EmbeddedTextureCache.h" #include "Manager.h" #include "SelectionSet.h" +#include "SentryReporter.h" #include #include #include #include #include +#include #include #include @@ -26,6 +28,7 @@ #include #include +#include UVEditorController* UVEditorController::s_instance = nullptr; @@ -197,8 +200,7 @@ UVEditorController::IslandResult UVEditorController::computeIslandsFromHalfEdgeM return result; } -UVEditorController::IslandResult UVEditorController::computeIslandsFromEditableMesh(const EditableMesh& mesh, - int uvChannel) +UVEditorController::IslandResult UVEditorController::computeIslandsFromEditableMesh(const EditableMesh& mesh) { if (mesh.subMeshes().empty()) return {}; @@ -207,10 +209,33 @@ UVEditorController::IslandResult UVEditorController::computeIslandsFromEditableM if (!hem.buildFromEditableMesh(mesh)) return {}; - Q_UNUSED(uvChannel); return computeIslandsFromHalfEdgeMesh(hem); } +namespace { + +QString safePreviewBaseName(const QString& texName) +{ + QString base = QFileInfo(texName).fileName(); + base.replace(QRegularExpression(QStringLiteral("[^A-Za-z0-9._-]")), + QStringLiteral("_")); + if (base.isEmpty()) + base = QStringLiteral("texture"); + return base; +} + +#ifdef Q_OS_MACOS +QString macAppBundleRoot() +{ + const QString bundleRoot = + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("../..")); + const QString canonical = QFileInfo(bundleRoot).canonicalFilePath(); + return canonical.isEmpty() ? bundleRoot : canonical; +} +#endif + +} // namespace + static QString fileUrl(const QString& path) { return QUrl::fromLocalFile(path).toString(); @@ -267,8 +292,11 @@ QString UVEditorController::resolveDiffuseTextureSource(Ogre::Entity* entity, in if (auto texPtr = findLoadedTextureByName(texName.toStdString())) { const QString origin = QString::fromStdString(texPtr->getOrigin()); - if (!origin.isEmpty() && QFileInfo::exists(origin)) + if (!origin.isEmpty() && QFileInfo::exists(origin)) { + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("UV editor texture from Ogre origin: %1").arg(texName)); return fileUrl(origin); + } } const std::vector embedded = EmbeddedTextureCache::retrieve(texName.toStdString()); @@ -277,24 +305,37 @@ QString UVEditorController::resolveDiffuseTextureSource(Ogre::Entity* entity, in QDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) .filePath(QStringLiteral("uv_editor_previews")); QDir().mkpath(outDir); - const QString outPath = QDir(outDir).filePath(texName + QStringLiteral("_uvbg.png")); + const QString outPath = + QDir(outDir).filePath(safePreviewBaseName(texName) + QStringLiteral("_uvbg.png")); if (!QFileInfo::exists(outPath)) { QImage img; - if (img.loadFromData(embedded.data(), static_cast(embedded.size()))) + if (img.loadFromData(embedded.data(), static_cast(embedded.size()))) { img.save(outPath, "PNG"); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("UV editor embedded preview: %1").arg(texName)); + } } if (QFileInfo::exists(outPath)) return fileUrl(outPath); } - const QStringList candidates = { + QStringList candidates = { QStringLiteral("media/materials/textures/%1").arg(texName), - QDir(QCoreApplication::applicationDirPath()).filePath(texName), QDir::current().filePath(texName) }; +#ifdef Q_OS_MACOS + candidates.prepend(QDir(macAppBundleRoot()).filePath( + QStringLiteral("media/materials/textures/%1").arg(texName))); + candidates.append(QDir(macAppBundleRoot()).filePath(texName)); +#else + candidates.append(QDir(QCoreApplication::applicationDirPath()).filePath(texName)); +#endif for (const QString& path : candidates) { - if (QFileInfo::exists(path)) + if (QFileInfo::exists(path)) { + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("UV editor texture from disk: %1").arg(texName)); return fileUrl(QFileInfo(path).absoluteFilePath()); + } } return {}; } @@ -400,7 +441,9 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& m_uvBounds = QRectF(0, 0, 1, 1); } - const int previewSub = submeshFilter.isEmpty() ? 0 : *submeshFilter.constBegin(); + const int previewSub = submeshFilter.isEmpty() + ? 0 + : *std::min_element(submeshFilter.begin(), submeshFilter.end()); m_textureBackgroundSource = resolveDiffuseTextureSource(entity, previewSub); return m_hasMesh; } diff --git a/src/UVEditorController.h b/src/UVEditorController.h index e628bfc4..c534f672 100644 --- a/src/UVEditorController.h +++ b/src/UVEditorController.h @@ -69,7 +69,8 @@ class UVEditorController : public QObject Q_INVOKABLE void refresh(); /// Connected UV islands for a mesh snapshot (headless tests). - static IslandResult computeIslandsFromEditableMesh(const EditableMesh& mesh, int uvChannel = 0); + /// Caller must populate `EditableMesh` UVs for the channel under test. + static IslandResult computeIslandsFromEditableMesh(const EditableMesh& mesh); signals: void uvChannelChanged(); diff --git a/src/UVEditorController_test.cpp b/src/UVEditorController_test.cpp index a7af9563..5f732751 100644 --- a/src/UVEditorController_test.cpp +++ b/src/UVEditorController_test.cpp @@ -83,7 +83,7 @@ TEST_F(UVEditorControllerTest, SingleTriangleOneIsland) EditableMesh em; ASSERT_TRUE(em.loadFromMesh(mesh)); - const auto result = UVEditorController::computeIslandsFromEditableMesh(em, 0); + const auto result = UVEditorController::computeIslandsFromEditableMesh(em); EXPECT_EQ(result.islandCount, 1); ASSERT_EQ(result.faceIslandIds.size(), 1u); EXPECT_EQ(result.faceIslandIds[0], 0); @@ -95,7 +95,7 @@ TEST_F(UVEditorControllerTest, TwoSubmeshesTwoIslands) EditableMesh em; ASSERT_TRUE(em.loadFromMesh(mesh)); - const auto result = UVEditorController::computeIslandsFromEditableMesh(em, 0); + const auto result = UVEditorController::computeIslandsFromEditableMesh(em); EXPECT_EQ(result.islandCount, 2); } @@ -108,7 +108,7 @@ TEST_F(UVEditorControllerTest, UvSeamSplitsIslands) EditableMesh em; ASSERT_TRUE(em.loadFromMesh(mesh)); - const auto result = UVEditorController::computeIslandsFromEditableMesh(em, 0); + const auto result = UVEditorController::computeIslandsFromEditableMesh(em); EXPECT_EQ(result.islandCount, 2); ASSERT_EQ(result.faceIslandIds.size(), 2u); EXPECT_NE(result.faceIslandIds[0], result.faceIslandIds[1]); @@ -137,3 +137,16 @@ TEST_F(UVEditorControllerTest, ControllerRefreshTracksSelection) EXPECT_EQ(ctrl->islandCount(), 1); EXPECT_EQ(ctrl->triangles().size(), 1); } + +TEST_F(UVEditorControllerTest, ShowTextureBackgroundToggle) +{ + UVEditorController* ctrl = UVEditorController::instance(); + QSignalSpy spy(ctrl, &UVEditorController::showTextureBackgroundChanged); + + const bool initial = ctrl->showTextureBackground(); + ctrl->setShowTextureBackground(!initial); + EXPECT_EQ(ctrl->showTextureBackground(), !initial); + EXPECT_GE(spy.count(), 1); + + ctrl->setShowTextureBackground(initial); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2778fdb6..7be58f54 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -133,6 +133,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshDecimatorController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/UvUnwrapController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/UVEditorController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QuadRetopo.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QuadRetopoController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SkinWeights.cpp From d1ad8ce278b4c20bd519fe9ab2f0620dc15fb480 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 12:30:31 -0400 Subject: [PATCH 3/4] Address UV Editor review feedback on island walk and UX. Walk face half-edges directly, fan-triangulate canonical n-gon faces, clear stale UVs when a channel is missing, and only auto-fit when mesh revision changes. Co-authored-by: Cursor --- qml/UVEditorPanel.qml | 8 ++-- src/UVEditorController.cpp | 68 ++++++++++++++++++++++----------- src/UVEditorController_test.cpp | 7 +--- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/qml/UVEditorPanel.qml b/qml/UVEditorPanel.qml index b492a702..71f5130b 100644 --- a/qml/UVEditorPanel.qml +++ b/qml/UVEditorPanel.qml @@ -69,8 +69,10 @@ Rectangle { Connections { target: UVEditorController function onMeshDataChanged() { + const prevRevision = root.cachedRevision root.rebuildTriangleCache() - if (UVEditorController.hasMesh) + if (UVEditorController.meshRevision !== prevRevision + && UVEditorController.hasMesh) Qt.callLater(root.fitToView) } function onFitToViewRequested() { root.fitToView() } @@ -212,8 +214,8 @@ Rectangle { visible: UVEditorController.showTextureBackground && UVEditorController.textureBackgroundSource.length > 0 && UVEditorController.hasMesh - x: Math.min(root.uvToScreen(0, 1).x, root.uvToScreen(1, 0).x) - y: Math.min(root.uvToScreen(0, 1).y, root.uvToScreen(1, 0).y) + x: viewCanvas.x + Math.min(root.uvToScreen(0, 1).x, root.uvToScreen(1, 0).x) + y: viewCanvas.y + Math.min(root.uvToScreen(0, 1).y, root.uvToScreen(1, 0).y) width: Math.abs(root.uvToScreen(1, 0).x - root.uvToScreen(0, 1).x) height: Math.abs(root.uvToScreen(0, 0).y - root.uvToScreen(0, 1).y) source: UVEditorController.textureBackgroundSource diff --git a/src/UVEditorController.cpp b/src/UVEditorController.cpp index 2becf6a5..eec9c314 100644 --- a/src/UVEditorController.cpp +++ b/src/UVEditorController.cpp @@ -91,7 +91,6 @@ void UVEditorController::setShowTextureBackground(bool on) return; m_showTextureBackground = on; emit showTextureBackgroundChanged(); - emit meshDataChanged(); } void UVEditorController::refresh() @@ -143,10 +142,13 @@ void UVEditorController::applyUvChannel(EditableMesh& mesh, Ogre::Entity* entity const Ogre::SubMesh* sub = ogreMesh->getSubMesh(static_cast(si)); const Ogre::VertexData* vd = sub->useSharedVertices ? ogreMesh->sharedVertexData : sub->vertexData; std::vector uvs; - if (!readUvChannel(vd, channel, uvs)) + auto& verts = mesh.subMeshes()[si].vertices; + if (!readUvChannel(vd, channel, uvs)) { + for (auto& v : verts) + v.hasUV = false; continue; + } - auto& verts = mesh.subMeshes()[si].vertices; const size_t n = std::min(verts.size(), uvs.size()); for (size_t vi = 0; vi < n; ++vi) { verts[vi].uv = uvs[vi]; @@ -179,19 +181,22 @@ UVEditorController::IslandResult UVEditorController::computeIslandsFromHalfEdgeM const int faceIdx = q.front(); q.pop(); - for (const int edgeIdx : hem.faceEdges(faceIdx)) { - const int heIdx = hem.edge(edgeIdx).halfEdge; - if (heIdx < 0) - continue; - const int twinIdx = hem.halfEdge(heIdx).twin; - if (twinIdx < 0) - continue; - const int adjFace = hem.halfEdge(twinIdx).face; - if (adjFace < 0 || result.faceIslandIds[adjFace] >= 0) - continue; - result.faceIslandIds[adjFace] = result.islandCount; - q.push(adjFace); - } + const int startHE = hem.face(faceIdx).halfEdge; + if (startHE < 0) + continue; + + int he = startHE; + do { + const int twinIdx = hem.halfEdge(he).twin; + if (twinIdx >= 0) { + const int adjFace = hem.halfEdge(twinIdx).face; + if (adjFace >= 0 && result.faceIslandIds[adjFace] < 0) { + result.faceIslandIds[adjFace] = result.islandCount; + q.push(adjFace); + } + } + he = hem.halfEdge(he).next; + } while (he != startHE); } ++result.islandCount; @@ -388,9 +393,9 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& int heFaceIdx = 0; for (const auto& sub : mesh.subMeshes()) { - for (const auto& tri : sub.triangles) { + auto emitTriangle = [&](const EditableTriangle& tri) { if (heFaceIdx >= static_cast(islands.faceIslandIds.size())) - break; + return; Ogre::Vector2 uvs[3]; bool ok = true; @@ -402,10 +407,8 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& } uvs[c] = sub.vertices[vi].uv; } - if (!ok) { - ++heFaceIdx; - continue; - } + if (!ok) + return; uMin = std::min({uMin, uvs[0].x, uvs[1].x, uvs[2].x}); vMin = std::min({vMin, uvs[0].y, uvs[1].y, uvs[2].y}); @@ -423,7 +426,26 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& {QStringLiteral("island"), islandId}, {QStringLiteral("color"), colorForIsland(islandId)} }); - ++heFaceIdx; + }; + + if (!sub.faces.empty()) { + for (const auto& face : sub.faces) { + if (!face.isValid()) + continue; + for (size_t i = 1; i + 1 < face.indices.size(); ++i) { + EditableTriangle tri; + tri.indices[0] = face.indices[0]; + tri.indices[1] = face.indices[i]; + tri.indices[2] = face.indices[i + 1]; + emitTriangle(tri); + } + ++heFaceIdx; + } + } else { + for (const auto& tri : sub.triangles) { + emitTriangle(tri); + ++heFaceIdx; + } } } diff --git a/src/UVEditorController_test.cpp b/src/UVEditorController_test.cpp index 5f732751..d40b2c44 100644 --- a/src/UVEditorController_test.cpp +++ b/src/UVEditorController_test.cpp @@ -67,6 +67,7 @@ class UVEditorControllerTest : public ::testing::Test { void SetUp() override { Manager::kill(); ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb required in CI)"; + ASSERT_TRUE(canLoadMeshFiles()) << "GL context unavailable (Xvfb required in CI)"; createStandardOgreMaterials(); UVEditorController::kill(); } @@ -101,9 +102,6 @@ TEST_F(UVEditorControllerTest, TwoSubmeshesTwoIslands) TEST_F(UVEditorControllerTest, UvSeamSplitsIslands) { - if (!canLoadMeshFiles()) - GTEST_SKIP() << "GL context unavailable"; - auto mesh = createSeamedQuadMesh("UVEditor_seamed_quad"); EditableMesh em; ASSERT_TRUE(em.loadFromMesh(mesh)); @@ -116,9 +114,6 @@ TEST_F(UVEditorControllerTest, UvSeamSplitsIslands) TEST_F(UVEditorControllerTest, ControllerRefreshTracksSelection) { - if (!canLoadMeshFiles()) - GTEST_SKIP() << "GL context unavailable"; - auto mesh = createInMemoryTriangleMesh("UVEditor_ctrl_tri"); auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_node"); From 70896d68e5cde9aecfea669acd49df2f3f3192b5 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 14:54:25 -0400 Subject: [PATCH 4/4] Add UV editor tests for Codex review coverage. Cover canonical quad-face island grouping and UV1 channel fallback when the second UV set is absent. Co-authored-by: Cursor --- src/UVEditorController_test.cpp | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/UVEditorController_test.cpp b/src/UVEditorController_test.cpp index d40b2c44..829e3484 100644 --- a/src/UVEditorController_test.cpp +++ b/src/UVEditorController_test.cpp @@ -62,6 +62,24 @@ static Ogre::MeshPtr createSeamedQuadMesh(const std::string& name) return mesh; } +static EditableMesh makeUvQuadMesh() +{ + EditableMesh mesh; + EditableSubMesh sub; + sub.vertices = { + EditableVertex{.position = {0, 0, 0}, .uv = {0, 0}, .hasUV = true}, + EditableVertex{.position = {1, 0, 0}, .uv = {1, 0}, .hasUV = true}, + EditableVertex{.position = {1, 1, 0}, .uv = {1, 1}, .hasUV = true}, + EditableVertex{.position = {0, 1, 0}, .uv = {0, 1}, .hasUV = true}, + }; + EditableFace face; + face.indices = {0, 1, 2, 3}; + sub.faces.push_back(std::move(face)); + triangulateFaces(sub); + mesh.subMeshes().push_back(std::move(sub)); + return mesh; +} + class UVEditorControllerTest : public ::testing::Test { protected: void SetUp() override { @@ -112,6 +130,37 @@ TEST_F(UVEditorControllerTest, UvSeamSplitsIslands) EXPECT_NE(result.faceIslandIds[0], result.faceIslandIds[1]); } +TEST_F(UVEditorControllerTest, CanonicalQuadFaceOneIsland) +{ + const auto result = + UVEditorController::computeIslandsFromEditableMesh(makeUvQuadMesh()); + EXPECT_EQ(result.islandCount, 1); + ASSERT_EQ(result.faceIslandIds.size(), 1u); + EXPECT_EQ(result.faceIslandIds[0], 0); +} + +TEST_F(UVEditorControllerTest, MissingUvChannelClearsLayout) +{ + auto mesh = createInMemoryTriangleMesh("UVEditor_uv0_only"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_uv1_node"); + auto* entity = sceneMgr->createEntity("UVEditor_uv1_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + + ctrl->setUvChannel(0); + ctrl->refresh(); + EXPECT_TRUE(ctrl->hasMesh()); + EXPECT_EQ(ctrl->triangles().size(), 1); + + ctrl->setUvChannel(1); + ctrl->refresh(); + EXPECT_FALSE(ctrl->hasMesh()); + EXPECT_TRUE(ctrl->triangles().isEmpty()); +} + TEST_F(UVEditorControllerTest, ControllerRefreshTracksSelection) { auto mesh = createInMemoryTriangleMesh("UVEditor_ctrl_tri");