diff --git a/qml/UVEditorPanel.qml b/qml/UVEditorPanel.qml index 71f5130be..053a97ed6 100644 --- a/qml/UVEditorPanel.qml +++ b/qml/UVEditorPanel.qml @@ -4,7 +4,7 @@ import QtQuick.Layouts import PropertiesPanel 1.0 import ThemeManager 1.0 -// Read-only UV layout viewer (issue #459). Software Canvas2D — no GL. +// UV layout viewer with component selection (issues #459 / #460). Rectangle { id: root color: ThemeManager.panelColor @@ -17,6 +17,21 @@ Rectangle { property var triCache: [] property int cachedRevision: -1 + property int cachedSelectionRevision: -1 + property var selVertCache: [] + property var selEdgeCache: [] + property var selFaceCache: [] + property var ctxIslandCache: [] + + property bool draggingSelect: false + property real dragStartX: 0 + property real dragStartY: 0 + property real dragEndX: 0 + property real dragEndY: 0 + + readonly property int modNone: 0 + readonly property int modShift: 0x02000000 + readonly property int modCtrl: 0x04000000 function uvToScreen(u, v) { return Qt.point( @@ -33,13 +48,32 @@ Rectangle { } function rebuildTriangleCache() { - if (UVEditorController.meshRevision === cachedRevision) + if (UVEditorController.meshRevision === cachedRevision + && UVEditorController.selectionRevision === cachedSelectionRevision) return cachedRevision = UVEditorController.meshRevision + cachedSelectionRevision = UVEditorController.selectionRevision triCache = UVEditorController.triangles() + selVertCache = UVEditorController.selectionVertices() + selEdgeCache = UVEditorController.selectionEdges() + selFaceCache = UVEditorController.selectionFaces() + ctxIslandCache = UVEditorController.contextIslandFaces() viewCanvas.requestPaint() } + function pickRadiusUv() { + return 8.0 / Math.max(1, root.zoom) + } + + function eventModifiers(event) { + let m = modNone + if (event.modifiers & Qt.ShiftModifier) + m |= modShift + if (event.modifiers & Qt.ControlModifier) + m |= modCtrl + return m + } + function resetView() { const availW = Math.max(1, viewCanvas.width * 0.9) const availH = Math.max(1, viewCanvas.height * 0.9) @@ -77,6 +111,8 @@ Rectangle { } function onFitToViewRequested() { root.fitToView() } function onShowTextureBackgroundChanged() { viewCanvas.requestPaint() } + function onUvSelectionChanged() { root.rebuildTriangleCache() } + function onSelectionModeChanged() { viewCanvas.requestPaint() } } Component.onCompleted: { @@ -91,6 +127,15 @@ Rectangle { } else if (event.key === Qt.Key_Home) { resetView() event.accepted = true + } else if (event.key === Qt.Key_1) { + UVEditorController.selectionMode = 0 + event.accepted = true + } else if (event.key === Qt.Key_2) { + UVEditorController.selectionMode = 1 + event.accepted = true + } else if (event.key === Qt.Key_3) { + UVEditorController.selectionMode = 2 + event.accepted = true } } @@ -119,6 +164,41 @@ Rectangle { font.pixelSize: 10 } + Row { + spacing: 2 + Repeater { + model: [ + { label: "V", mode: 0, tip: "UV Vertex (1)" }, + { label: "E", mode: 1, tip: "UV Edge (2)" }, + { label: "F", mode: 2, tip: "UV Face (3)" } + ] + delegate: Rectangle { + width: 20; height: 18; radius: 3 + color: UVEditorController.selectionMode === modelData.mode + ? ThemeManager.highlightColor + : ThemeManager.inputColor + border.color: ThemeManager.borderColor + border.width: 1 + ToolTip.visible: modeMa.containsMouse + ToolTip.text: modelData.tip + Text { + anchors.centerIn: parent + text: modelData.label + color: ThemeManager.textColor + font.pixelSize: 10 + font.bold: UVEditorController.selectionMode === modelData.mode + } + MouseArea { + id: modeMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: UVEditorController.selectionMode = modelData.mode + } + } + } + } + Text { text: "Channel" color: ThemeManager.disabledTextColor @@ -247,7 +327,9 @@ Rectangle { } drawGrid(ctx) + drawContextIslands(ctx) drawTriangles(ctx) + drawSelection(ctx) drawUnitBoundary(ctx) } @@ -289,6 +371,68 @@ Rectangle { ctx.restore() } + function drawContextIslands(ctx) { + if (ctxIslandCache.length === 0) + return + ctx.save() + for (let i = 0; i < ctxIslandCache.length; ++i) { + const t = ctxIslandCache[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 = Qt.rgba(ThemeManager.accentColor.r, + ThemeManager.accentColor.g, + ThemeManager.accentColor.b, 0.18) + ctx.fill() + } + ctx.restore() + } + + function drawSelection(ctx) { + ctx.save() + for (let i = 0; i < selFaceCache.length; ++i) { + const t = selFaceCache[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 = Qt.rgba(ThemeManager.highlightColor.r, + ThemeManager.highlightColor.g, + ThemeManager.highlightColor.b, 0.35) + ctx.fill() + } + ctx.lineWidth = 2 + ctx.strokeStyle = ThemeManager.highlightColor + for (let i = 0; i < selEdgeCache.length; ++i) { + const e = selEdgeCache[i] + const a = root.uvToScreen(e.u0, e.v0) + const b = root.uvToScreen(e.u1, e.v1) + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.stroke() + } + const r = 4 + ctx.fillStyle = ThemeManager.highlightColor + for (let i = 0; i < selVertCache.length; ++i) { + const v = selVertCache[i] + const p = root.uvToScreen(v.u, v.v) + ctx.beginPath() + ctx.arc(p.x, p.y, r, 0, Math.PI * 2) + ctx.fill() + } + ctx.restore() + } + function drawTriangles(ctx) { if (!UVEditorController.hasMesh) return @@ -323,20 +467,56 @@ Rectangle { font.pixelSize: 12 } + // Drag marquee for box select (issue #460). + Canvas { + id: marqueeCanvas + anchors.fill: viewCanvas + visible: root.draggingSelect + renderTarget: Canvas.Immediate + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + if (!root.draggingSelect) + return + const x = Math.min(root.dragStartX, root.dragEndX) + const y = Math.min(root.dragStartY, root.dragEndY) + const w = Math.abs(root.dragEndX - root.dragStartX) + const h = Math.abs(root.dragEndY - root.dragStartY) + ctx.strokeStyle = ThemeManager.highlightColor + ctx.lineWidth = 1 + ctx.setLineDash([4, 3]) + ctx.strokeRect(x, y, w, h) + ctx.fillStyle = Qt.rgba(ThemeManager.highlightColor.r, + ThemeManager.highlightColor.g, + ThemeManager.highlightColor.b, 0.12) + ctx.fillRect(x, y, w, h) + } + } + MouseArea { + id: selectMa anchors.fill: parent - acceptedButtons: Qt.MiddleButton | Qt.NoButton + acceptedButtons: Qt.LeftButton | Qt.MiddleButton hoverEnabled: true preventStealing: false property real lastX: 0 property real lastY: 0 + property int activeModifiers: modNone onPressed: function(mouse) { + activeModifiers = eventModifiers(mouse) if (mouse.button === Qt.MiddleButton) { lastX = mouse.x lastY = mouse.y + return } + root.draggingSelect = true + root.dragStartX = mouse.x + root.dragStartY = mouse.y + root.dragEndX = mouse.x + root.dragEndY = mouse.y + marqueeCanvas.requestPaint() } onPositionChanged: function(mouse) { if (mouse.buttons & Qt.MiddleButton) { @@ -347,7 +527,38 @@ Rectangle { lastX = mouse.x lastY = mouse.y viewCanvas.requestPaint() + return + } + if (root.draggingSelect) { + root.dragEndX = mouse.x + root.dragEndY = mouse.y + marqueeCanvas.requestPaint() + } + } + onReleased: function(mouse) { + if (mouse.button === Qt.MiddleButton) + return + if (!root.draggingSelect) + return + root.draggingSelect = false + marqueeCanvas.requestPaint() + + const dx = mouse.x - root.dragStartX + const dy = mouse.y - root.dragStartY + const dist = Math.sqrt(dx * dx + dy * dy) + const mods = activeModifiers + + if (dist < 4) { + const uv = root.screenToUv(mouse.x - viewCanvas.x, mouse.y - viewCanvas.y) + UVEditorController.pickAt(uv.x, uv.y, mods, root.pickRadiusUv()) + } else { + const a = root.screenToUv(root.dragStartX - viewCanvas.x, + root.dragStartY - viewCanvas.y) + const b = root.screenToUv(root.dragEndX - viewCanvas.x, + root.dragEndY - viewCanvas.y) + UVEditorController.boxSelect(a.x, a.y, b.x, b.y, mods) } + root.rebuildTriangleCache() } } diff --git a/src/UVEditorController.cpp b/src/UVEditorController.cpp index eec9c3144..8a7090df9 100644 --- a/src/UVEditorController.cpp +++ b/src/UVEditorController.cpp @@ -29,6 +29,8 @@ #include #include #include +#include +#include UVEditorController* UVEditorController::s_instance = nullptr; @@ -72,6 +74,8 @@ void UVEditorController::connectSignals() if (auto* edit = EditModeController::instance()) { connect(edit, &EditModeController::meshDataChanged, this, &UVEditorController::refresh); connect(edit, &EditModeController::editModeChanged, this, &UVEditorController::refresh); + connect(edit, &EditModeController::editSelectionChanged, this, + &UVEditorController::onEditSelectionChanged); } } @@ -85,6 +89,439 @@ void UVEditorController::setUvChannel(int channel) rebuildMeshCache(); } +void UVEditorController::setSelectionMode(int mode) +{ + mode = std::max(0, std::min(mode, 2)); + const auto next = static_cast(mode); + if (m_selectionMode == next) + return; + m_selectionMode = next; + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("UV editor selection mode: %1").arg(mode)); + emit selectionModeChanged(); +} + +void UVEditorController::clearUvSelection() +{ + if (m_selectedUvVerts.isEmpty() && m_selectedUvEdges.isEmpty() && m_selectedUvFaces.isEmpty()) + return; + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("UV editor clear selection")); + m_selectedUvVerts.clear(); + m_selectedUvEdges.clear(); + m_selectedUvFaces.clear(); + notifyUvSelectionChanged(); +} + +void UVEditorController::notifyUvSelectionChanged() +{ + ++m_selectionRevision; + emit uvSelectionChanged(); +} + +namespace { + +constexpr float kUvEpsilon = 1e-5f; + +bool pointInTriangle(double u, double v, float u0, float v0, float u1, float v1, float u2, float v2) +{ + const double dX = static_cast(u) - static_cast(u2); + const double dY = static_cast(v) - static_cast(v2); + const double dX21 = static_cast(u2) - static_cast(u1); + const double dY12 = static_cast(v1) - static_cast(v2); + const double D = dY12 * (static_cast(u0) - static_cast(u2)) + + dX21 * (static_cast(v0) - static_cast(v2)); + if (std::abs(D) < 1e-12) + return false; + const double s = dY12 * dX + dX21 * dY; + const double t = (static_cast(v2) - static_cast(v0)) * dX + + (static_cast(u0) - static_cast(u2)) * dY; + if (D < 0) + return s <= 0 && t <= 0 && s + t >= D; + return s >= 0 && t >= 0 && s + t <= D; +} + +double distPointToSegmentSq(double u, double v, float u0, float v0, float u1, float v1) +{ + const double dx = static_cast(u1) - static_cast(u0); + const double dy = static_cast(v1) - static_cast(v0); + const double lenSq = dx * dx + dy * dy; + if (lenSq < 1e-18) + return (u - u0) * (u - u0) + (v - v0) * (v - v0); + double t = ((u - u0) * dx + (v - v0) * dy) / lenSq; + t = std::max(0.0, std::min(1.0, t)); + const double px = u0 + t * dx; + const double py = v0 + t * dy; + const double ddx = u - px; + const double ddy = v - py; + return ddx * ddx + ddy * ddy; +} + +bool segmentIntersectsRect(double u0, double v0, double u1, double v1, + double minU, double minV, double maxU, double maxV) +{ + if ((u0 >= minU && u0 <= maxU && v0 >= minV && v0 <= maxV) + || (u1 >= minU && u1 <= maxU && v1 >= minV && v1 <= maxV)) + return true; + const auto inside = [&](double u, double v) { + return u >= minU && u <= maxU && v >= minV && v <= maxV; + }; + const double rectU[4] = {minU, maxU, maxU, minU}; + const double rectV[4] = {minV, minV, maxV, maxV}; + for (int i = 0; i < 4; ++i) { + const double ru0 = rectU[i]; + const double rv0 = rectV[i]; + const double ru1 = rectU[(i + 1) % 4]; + const double rv1 = rectV[(i + 1) % 4]; + const double d1x = u1 - u0; + const double d1y = v1 - v0; + const double d2x = ru1 - ru0; + const double d2y = rv1 - rv0; + const double denom = d1x * d2y - d1y * d2x; + if (std::abs(denom) < 1e-18) + continue; + const double t = ((ru0 - u0) * d2y - (rv0 - v0) * d2x) / denom; + const double s = ((ru0 - u0) * d1y - (rv0 - v0) * d1x) / denom; + if (t >= 0.0 && t <= 1.0 && s >= 0.0 && s <= 1.0) + return true; + } + return inside(u0, v0) || inside(u1, v1); +} + +bool triangleTouchesRect(const float u[3], const float v[3], + double minU, double minV, double maxU, double maxV) +{ + for (int c = 0; c < 3; ++c) { + if (u[c] >= minU && u[c] <= maxU && v[c] >= minV && v[c] <= maxV) + return true; + } + const double cx = (u[0] + u[1] + u[2]) / 3.0; + const double cy = (v[0] + v[1] + v[2]) / 3.0; + if (cx >= minU && cx <= maxU && cy >= minV && cy <= maxV) + return true; + for (int e = 0; e < 3; ++e) { + const int n = (e + 1) % 3; + if (segmentIntersectsRect(u[e], v[e], u[n], v[n], minU, minV, maxU, maxV)) + return true; + } + return pointInTriangle((minU + maxU) * 0.5, (minV + maxV) * 0.5, + u[0], v[0], u[1], v[1], u[2], v[2]); +} + +class UnionFind { +public: + explicit UnionFind(int n) : parent(n), rank(n, 0) + { + for (int i = 0; i < n; ++i) + parent[i] = i; + } + + int find(int x) + { + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + + void unite(int a, int b) + { + a = find(a); + b = find(b); + if (a == b) + return; + if (rank[a] < rank[b]) + std::swap(a, b); + parent[b] = a; + if (rank[a] == rank[b]) + ++rank[a]; + } + +private: + std::vector parent; + std::vector rank; +}; + +} // namespace + +void UVEditorController::applySelectionSet(const QSet& verts, const QSet& edges, + const QSet& faces, int modifiers) +{ + const bool add = (modifiers & static_cast(ShiftModifier)) != 0; + const bool toggle = (modifiers & static_cast(ControlModifier)) != 0; + + if (!add && !toggle) { + m_selectedUvVerts = verts; + m_selectedUvEdges = edges; + m_selectedUvFaces = faces; + } else if (add) { + m_selectedUvVerts.unite(verts); + m_selectedUvEdges.unite(edges); + m_selectedUvFaces.unite(faces); + } else { + for (int id : verts) { + if (m_selectedUvVerts.contains(id)) + m_selectedUvVerts.remove(id); + else + m_selectedUvVerts.insert(id); + } + for (int id : edges) { + if (m_selectedUvEdges.contains(id)) + m_selectedUvEdges.remove(id); + else + m_selectedUvEdges.insert(id); + } + for (int id : faces) { + if (m_selectedUvFaces.contains(id)) + m_selectedUvFaces.remove(id); + else + m_selectedUvFaces.insert(id); + } + } + + notifyUvSelectionChanged(); +} + +void UVEditorController::pickAt(double u, double v, int modifiers, double pickRadiusUv) +{ + if (!m_hasMesh || m_uvTris.empty()) + return; + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("UV editor pick selection")); + + const double pickRadiusSq = pickRadiusUv * pickRadiusUv; + QSet verts; + QSet edges; + QSet faces; + + if (m_selectionMode == FaceMode) { + int bestFace = -1; + double bestDistSq = 1e30; + bool insideTri = false; + for (size_t fi = 0; fi < m_uvTris.size(); ++fi) { + const auto& tri = m_uvTris[fi]; + if (pointInTriangle(u, v, tri.u[0], tri.v[0], tri.u[1], tri.v[1], tri.u[2], tri.v[2])) { + bestFace = static_cast(fi); + insideTri = true; + break; + } + const double cx = (tri.u[0] + tri.u[1] + tri.u[2]) / 3.0; + const double cy = (tri.v[0] + tri.v[1] + tri.v[2]) / 3.0; + const double d = (u - cx) * (u - cx) + (v - cy) * (v - cy); + if (d < bestDistSq) { + bestDistSq = d; + bestFace = static_cast(fi); + } + } + if (insideTri || (bestFace >= 0 && bestDistSq <= pickRadiusSq)) + faces.insert(bestFace); + } else if (m_selectionMode == EdgeMode) { + int bestEdge = -1; + double bestDistSq = 1e30; + for (size_t ei = 0; ei < m_uvEdges.size(); ++ei) { + const auto& edge = m_uvEdges[ei]; + if (edge.v0 < 0 || edge.v1 < 0 + || edge.v0 >= static_cast(m_uvVerts.size()) + || edge.v1 >= static_cast(m_uvVerts.size())) + continue; + const auto& a = m_uvVerts[edge.v0]; + const auto& b = m_uvVerts[edge.v1]; + const double d = distPointToSegmentSq(u, v, a.u, a.v, b.u, b.v); + if (d < bestDistSq) { + bestDistSq = d; + bestEdge = static_cast(ei); + } + } + if (bestEdge >= 0 && bestDistSq <= pickRadiusSq) + edges.insert(bestEdge); + } else { + int bestVert = -1; + double bestDistSq = 1e30; + for (size_t vi = 0; vi < m_uvVerts.size(); ++vi) { + const auto& vert = m_uvVerts[vi]; + const double d = (u - vert.u) * (u - vert.u) + (v - vert.v) * (v - vert.v); + if (d < bestDistSq) { + bestDistSq = d; + bestVert = static_cast(vi); + } + } + if (bestVert >= 0 && bestDistSq <= pickRadiusSq) + verts.insert(bestVert); + } + + applySelectionSet(verts, edges, faces, modifiers); +} + +void UVEditorController::boxSelect(double uMin, double vMin, double uMax, double vMax, int modifiers) +{ + if (!m_hasMesh) + return; + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("UV editor box selection")); + + if (uMin > uMax) + std::swap(uMin, uMax); + if (vMin > vMax) + std::swap(vMin, vMax); + + QSet verts; + QSet edges; + QSet faces; + + if (m_selectionMode == VertexMode) { + for (size_t vi = 0; vi < m_uvVerts.size(); ++vi) { + const auto& vert = m_uvVerts[vi]; + if (vert.u >= uMin && vert.u <= uMax && vert.v >= vMin && vert.v <= vMax) + verts.insert(static_cast(vi)); + } + } else if (m_selectionMode == EdgeMode) { + for (size_t ei = 0; ei < m_uvEdges.size(); ++ei) { + const auto& edge = m_uvEdges[ei]; + if (edge.v0 < 0 || edge.v1 < 0 + || edge.v0 >= static_cast(m_uvVerts.size()) + || edge.v1 >= static_cast(m_uvVerts.size())) + continue; + const auto& a = m_uvVerts[edge.v0]; + const auto& b = m_uvVerts[edge.v1]; + if (segmentIntersectsRect(a.u, a.v, b.u, b.v, uMin, vMin, uMax, vMax)) + edges.insert(static_cast(ei)); + } + } else { + for (size_t fi = 0; fi < m_uvTris.size(); ++fi) { + if (triangleTouchesRect(m_uvTris[fi].u, m_uvTris[fi].v, uMin, vMin, uMax, vMax)) + faces.insert(static_cast(fi)); + } + } + + applySelectionSet(verts, edges, faces, modifiers); +} + +QVariantList UVEditorController::selectionVertices() const +{ + QVariantList out; + for (int id : m_selectedUvVerts) { + if (id < 0 || id >= static_cast(m_uvVerts.size())) + continue; + const auto& v = m_uvVerts[id]; + out.push_back(QVariantMap{ + {QStringLiteral("u"), v.u}, + {QStringLiteral("v"), v.v} + }); + } + return out; +} + +QVariantList UVEditorController::selectionEdges() const +{ + QVariantList out; + for (int id : m_selectedUvEdges) { + if (id < 0 || id >= static_cast(m_uvEdges.size())) + continue; + const auto& e = m_uvEdges[id]; + if (e.v0 < 0 || e.v1 < 0 + || e.v0 >= static_cast(m_uvVerts.size()) + || e.v1 >= static_cast(m_uvVerts.size())) + continue; + const auto& a = m_uvVerts[e.v0]; + const auto& b = m_uvVerts[e.v1]; + out.push_back(QVariantMap{ + {QStringLiteral("u0"), a.u}, + {QStringLiteral("v0"), a.v}, + {QStringLiteral("u1"), b.u}, + {QStringLiteral("v1"), b.v} + }); + } + return out; +} + +QVariantList UVEditorController::selectionFaces() const +{ + QVariantList out; + for (int id : m_selectedUvFaces) { + if (id < 0 || id >= static_cast(m_uvTris.size())) + continue; + const auto& tri = m_uvTris[id]; + out.push_back(QVariantMap{ + {QStringLiteral("u0"), tri.u[0]}, + {QStringLiteral("v0"), tri.v[0]}, + {QStringLiteral("u1"), tri.u[1]}, + {QStringLiteral("v1"), tri.v[1]}, + {QStringLiteral("u2"), tri.u[2]}, + {QStringLiteral("v2"), tri.v[2]} + }); + } + return out; +} + +QVariantList UVEditorController::contextIslandFaces() const +{ + QVariantList out; + for (size_t fi = 0; fi < m_uvTris.size(); ++fi) { + if (!m_contextIslandIds.contains(m_uvTris[fi].island)) + continue; + const auto& tri = m_uvTris[fi]; + out.push_back(QVariantMap{ + {QStringLiteral("u0"), tri.u[0]}, + {QStringLiteral("v0"), tri.v[0]}, + {QStringLiteral("u1"), tri.u[1]}, + {QStringLiteral("v1"), tri.v[1]}, + {QStringLiteral("u2"), tri.u[2]}, + {QStringLiteral("v2"), tri.v[2]} + }); + } + return out; +} + +void UVEditorController::onEditSelectionChanged() +{ + updateContextIslandsFromEdit(); + notifyUvSelectionChanged(); +} + +void UVEditorController::updateContextIslandsFromEdit() +{ + m_contextIslandIds.clear(); + auto* edit = EditModeController::instance(); + if (!edit || !edit->isEditModeActive() || !m_activeEntity + || edit->editEntity() != m_activeEntity) + return; + + QSet islands; + for (int gv : edit->selectedVertices()) { + for (size_t fi = 0; fi < m_uvTris.size(); ++fi) { + const auto& tri = m_uvTris[fi]; + for (int c = 0; c < 3; ++c) { + if (tri.meshGlobalVert[c] == gv) + islands.insert(tri.island); + } + } + } + for (const auto& edge : edit->selectedEdges()) { + for (size_t fi = 0; fi < m_uvTris.size(); ++fi) { + const auto& tri = m_uvTris[fi]; + for (int e = 0; e < 3; ++e) { + const int n = (e + 1) % 3; + const int a = tri.meshGlobalVert[e]; + const int b = tri.meshGlobalVert[n]; + if ((a == edge.first && b == edge.second) + || (a == edge.second && b == edge.first)) + islands.insert(tri.island); + } + } + } + for (int gt : edit->selectedFaces()) { + for (size_t fi = 0; fi < m_uvTris.size(); ++fi) { + if (m_uvTris[fi].meshGlobalTri == gt) + islands.insert(m_uvTris[fi].island); + } + } + + m_contextIslandIds = islands; +} + void UVEditorController::setShowTextureBackground(bool on) { if (m_showTextureBackground == on) @@ -365,6 +802,10 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& if (!entity) return false; + m_uvVerts.clear(); + m_uvTris.clear(); + m_uvEdges.clear(); + EditableMesh displayMesh; if (auto* edit = EditModeController::instance()) { if (edit->isEditModeActive() && edit->editEntity() == entity && edit->currentMesh()) { @@ -392,11 +833,47 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& tris.reserve(static_cast(hem.faceCount())); int heFaceIdx = 0; - for (const auto& sub : mesh.subMeshes()) { - auto emitTriangle = [&](const EditableTriangle& tri) { + + std::vector sourceSubIndices; + sourceSubIndices.reserve(displayMesh.subMeshes().size()); + if (submeshFilter.isEmpty()) { + for (size_t si = 0; si < displayMesh.subMeshes().size(); ++si) + sourceSubIndices.push_back(si); + } else { + for (size_t si = 0; si < displayMesh.subMeshes().size(); ++si) { + if (submeshFilter.contains(static_cast(si))) + sourceSubIndices.push_back(si); + } + } + + auto globalVertOffsetForSub = [&](size_t sourceSubIdx) { + int off = 0; + for (size_t si = 0; si < sourceSubIdx; ++si) + off += static_cast(displayMesh.subMeshes()[si].vertices.size()); + return off; + }; + + auto globalTriOffsetForSub = [&](size_t sourceSubIdx) { + int off = 0; + for (size_t si = 0; si < sourceSubIdx; ++si) + off += static_cast(displayMesh.subMeshes()[si].triangles.size()); + return off; + }; + + for (size_t meshSubIdx = 0; meshSubIdx < mesh.subMeshes().size(); ++meshSubIdx) { + const size_t sourceSubIdx = sourceSubIndices[meshSubIdx]; + const auto& sub = mesh.subMeshes()[meshSubIdx]; + const int globalVertOffset = globalVertOffsetForSub(sourceSubIdx); + const int globalTriOffset = globalTriOffsetForSub(sourceSubIdx); + + auto emitTriangle = [&](const EditableTriangle& tri, int meshGlobalTri) { if (heFaceIdx >= static_cast(islands.faceIslandIds.size())) return; + UvTri uvTri; + uvTri.meshGlobalTri = meshGlobalTri; + uvTri.island = islands.faceIslandIds[heFaceIdx]; + Ogre::Vector2 uvs[3]; bool ok = true; for (int c = 0; c < 3; ++c) { @@ -406,6 +883,9 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& break; } uvs[c] = sub.vertices[vi].uv; + uvTri.u[c] = uvs[c].x; + uvTri.v[c] = uvs[c].y; + uvTri.meshGlobalVert[c] = globalVertOffset + static_cast(vi); } if (!ok) return; @@ -426,29 +906,130 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& {QStringLiteral("island"), islandId}, {QStringLiteral("color"), colorForIsland(islandId)} }); + m_uvTris.push_back(uvTri); }; if (!sub.faces.empty()) { + size_t localTriCursor = 0; for (const auto& face : sub.faces) { if (!face.isValid()) continue; + const int faceBaseGlobalTri = + globalTriOffset + static_cast(localTriCursor); 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); + emitTriangle(tri, faceBaseGlobalTri); } + if (face.indices.size() >= 3) + localTriCursor += face.indices.size() - 2; ++heFaceIdx; } } else { + size_t localTri = 0; for (const auto& tri : sub.triangles) { - emitTriangle(tri); + emitTriangle(tri, globalTriOffset + static_cast(localTri)); + ++localTri; ++heFaceIdx; } } } + // Weld UV corners on shared manifold edges and build UV-edge list. + const int cornerCount = static_cast(m_uvTris.size()) * 3; + UnionFind uf(cornerCount); + + struct EdgeOcc { + int tri = -1; + int c0 = -1; + int c1 = -1; + }; + std::unordered_map> edgeOccMap; + edgeOccMap.reserve(m_uvTris.size() * 3); + + auto cornerIndex = [](int tri, int corner) { return tri * 3 + corner; }; + + for (size_t ti = 0; ti < m_uvTris.size(); ++ti) { + const auto& tri = m_uvTris[ti]; + for (int e = 0; e < 3; ++e) { + const int n = (e + 1) % 3; + int gv0 = tri.meshGlobalVert[e]; + int gv1 = tri.meshGlobalVert[n]; + if (gv0 > gv1) + std::swap(gv0, gv1); + const uint64_t key = (static_cast(static_cast(gv0)) << 32) + | static_cast(gv1); + edgeOccMap[key].push_back({static_cast(ti), e, n}); + } + } + + for (const auto& [key, occs] : edgeOccMap) { + if (occs.size() != 2) + continue; + const EdgeOcc& a = occs[0]; + const EdgeOcc& b = occs[1]; + const auto& ta = m_uvTris[a.tri]; + const auto& tb = m_uvTris[b.tri]; + + auto weldCorner = [&](int triA, int cornerA, int triB, int cornerB) { + if (std::abs(ta.u[cornerA] - tb.u[cornerB]) > kUvEpsilon + || std::abs(ta.v[cornerA] - tb.v[cornerB]) > kUvEpsilon) + return; + uf.unite(cornerIndex(triA, cornerA), cornerIndex(triB, cornerB)); + }; + + weldCorner(a.tri, a.c0, b.tri, b.c0); + weldCorner(a.tri, a.c1, b.tri, b.c1); + } + + std::unordered_map rootToUvVert; + rootToUvVert.reserve(cornerCount); + for (int ci = 0; ci < cornerCount; ++ci) { + const int tri = ci / 3; + const int corner = ci % 3; + const int root = uf.find(ci); + auto it = rootToUvVert.find(root); + if (it == rootToUvVert.end()) { + const int vid = static_cast(m_uvVerts.size()); + UvVert vert; + vert.u = m_uvTris[tri].u[corner]; + vert.v = m_uvTris[tri].v[corner]; + vert.meshGlobalVert = m_uvTris[tri].meshGlobalVert[corner]; + m_uvVerts.push_back(vert); + rootToUvVert.emplace(root, vid); + it = rootToUvVert.find(root); + } + m_uvTris[tri].uvVertId[corner] = it->second; + } + + std::unordered_map edgeDedup; + edgeDedup.reserve(m_uvTris.size() * 3); + for (size_t ti = 0; ti < m_uvTris.size(); ++ti) { + const auto& tri = m_uvTris[ti]; + for (int e = 0; e < 3; ++e) { + const int n = (e + 1) % 3; + int v0 = tri.uvVertId[e]; + int v1 = tri.uvVertId[n]; + if (v0 < 0 || v1 < 0) + continue; + if (v0 > v1) + std::swap(v0, v1); + const uint64_t key = (static_cast(static_cast(v0)) << 32) + | static_cast(v1); + if (edgeDedup.find(key) != edgeDedup.end()) + continue; + UvEdge edge; + edge.v0 = v0; + edge.v1 = v1; + edge.meshGlobalV0 = tri.meshGlobalVert[e]; + edge.meshGlobalV1 = tri.meshGlobalVert[n]; + edgeDedup.emplace(key, static_cast(m_uvEdges.size())); + m_uvEdges.push_back(edge); + } + } + m_triangles = tris; m_islandCount = islands.islandCount; m_hasMesh = !tris.isEmpty(); @@ -467,20 +1048,28 @@ bool UVEditorController::buildFromEntity(Ogre::Entity* entity, const QSet& ? 0 : *std::min_element(submeshFilter.begin(), submeshFilter.end()); m_textureBackgroundSource = resolveDiffuseTextureSource(entity, previewSub); + m_activeEntity = entity; return m_hasMesh; } void UVEditorController::rebuildMeshCache() { + Ogre::Entity* prevEntity = m_activeEntity; m_triangles.clear(); + m_uvVerts.clear(); + m_uvTris.clear(); + m_uvEdges.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."); + m_activeEntity = nullptr; auto* sel = SelectionSet::getSingleton(); if (!sel) { + clearUvSelection(); + m_contextIslandIds.clear(); ++m_meshRevision; emit meshDataChanged(); return; @@ -516,6 +1105,8 @@ void UVEditorController::rebuildMeshCache() } if (!entity) { + clearUvSelection(); + m_contextIslandIds.clear(); ++m_meshRevision; emit meshDataChanged(); return; @@ -525,7 +1116,10 @@ void UVEditorController::rebuildMeshCache() ? 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); + const bool built = buildFromEntity(entity, submeshFilter, m_uvChannel); + if (!built || entity != prevEntity) + clearUvSelection(); + updateContextIslandsFromEdit(); ++m_meshRevision; emit meshDataChanged(); } diff --git a/src/UVEditorController.h b/src/UVEditorController.h index c534f6721..df58d4946 100644 --- a/src/UVEditorController.h +++ b/src/UVEditorController.h @@ -18,10 +18,9 @@ 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). +/// QML-facing singleton for the UV editor panel (issues #459 / #460). +/// Extracts UV layouts, groups islands, supports UV-space component +/// selection (vertex / edge / face), with read-only island tint from Edit Mode. class UVEditorController : public QObject { Q_OBJECT @@ -38,7 +37,28 @@ class UVEditorController : public QObject Q_PROPERTY(QRectF uvBounds READ uvBounds NOTIFY meshDataChanged) Q_PROPERTY(int islandCount READ islandCount NOTIFY meshDataChanged) + Q_PROPERTY(int selectionMode READ selectionMode WRITE setSelectionMode NOTIFY selectionModeChanged) + Q_PROPERTY(int selectionRevision READ selectionRevision NOTIFY uvSelectionChanged) + Q_PROPERTY(int selectedVertexCount READ selectedVertexCount NOTIFY uvSelectionChanged) + Q_PROPERTY(int selectedEdgeCount READ selectedEdgeCount NOTIFY uvSelectionChanged) + Q_PROPERTY(int selectedFaceCount READ selectedFaceCount NOTIFY uvSelectionChanged) + public: + enum SelectionMode { + VertexMode = 0, + EdgeMode = 1, + FaceMode = 2 + }; + Q_ENUM(SelectionMode) + + /// Pick / box-select modifier flags (match Qt::KeyboardModifier bits used from QML). + enum SelectionModifier { + NoModifier = 0, + ShiftModifier = 0x02000000, + ControlModifier = 0x04000000 + }; + Q_ENUM(SelectionModifier) + struct IslandResult { int islandCount = 0; std::vector faceIslandIds; @@ -61,10 +81,31 @@ class UVEditorController : public QObject QRectF uvBounds() const { return m_uvBounds; } int islandCount() const { return m_islandCount; } + int selectionMode() const { return static_cast(m_selectionMode); } + void setSelectionMode(int mode); + + int selectionRevision() const { return m_selectionRevision; } + int selectedVertexCount() const { return static_cast(m_selectedUvVerts.size()); } + int selectedEdgeCount() const { return static_cast(m_selectedUvEdges.size()); } + int selectedFaceCount() const { return static_cast(m_selectedUvFaces.size()); } + /// 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; } + /// Highlight geometry for the active UV selection. + Q_INVOKABLE QVariantList selectionVertices() const; + Q_INVOKABLE QVariantList selectionEdges() const; + Q_INVOKABLE QVariantList selectionFaces() const; + + /// Read-only island tint for the current 3D Edit Mode selection. + Q_INVOKABLE QVariantList contextIslandFaces() const; + + Q_INVOKABLE void clearUvSelection(); + Q_INVOKABLE void pickAt(double u, double v, int modifiers, double pickRadiusUv); + /// Box rules: vertex = fully enclosed; edge/face = any intersection/touch. + Q_INVOKABLE void boxSelect(double uMin, double vMin, double uMax, double vMax, int modifiers); + /// Re-read the active selection and rebuild cached draw data. Q_INVOKABLE void refresh(); @@ -77,14 +118,43 @@ class UVEditorController : public QObject void showTextureBackgroundChanged(); void meshDataChanged(); void fitToViewRequested(); + void selectionModeChanged(); + void uvSelectionChanged(); private: + struct UvVert { + float u = 0.f; + float v = 0.f; + int meshGlobalVert = -1; + }; + + struct UvTri { + float u[3]{}; + float v[3]{}; + int meshGlobalVert[3]{-1, -1, -1}; + int meshGlobalTri = -1; + int island = 0; + int uvVertId[3]{-1, -1, -1}; + }; + + struct UvEdge { + int v0 = -1; + int v1 = -1; + int meshGlobalV0 = -1; + int meshGlobalV1 = -1; + }; + explicit UVEditorController(QObject* parent = nullptr); ~UVEditorController() override = default; void connectSignals(); void rebuildMeshCache(); bool buildFromEntity(Ogre::Entity* entity, const QSet& submeshFilter, int uvChannel); + void applySelectionSet(const QSet& verts, const QSet& edges, const QSet& faces, + int modifiers); + void notifyUvSelectionChanged(); + void onEditSelectionChanged(); + void updateContextIslandsFromEdit(); static IslandResult computeIslandsFromHalfEdgeMesh(const HalfEdgeMesh& hem); static bool readUvChannel(const Ogre::VertexData* vertexData, int channel, std::vector& outUvs); @@ -104,6 +174,19 @@ class UVEditorController : public QObject QRectF m_uvBounds; int m_islandCount = 0; QVariantList m_triangles; + + SelectionMode m_selectionMode = VertexMode; + int m_selectionRevision = 0; + QSet m_selectedUvVerts; + QSet m_selectedUvEdges; + QSet m_selectedUvFaces; + QSet m_contextIslandIds; + + std::vector m_uvVerts; + std::vector m_uvTris; + std::vector m_uvEdges; + + Ogre::Entity* m_activeEntity = nullptr; }; -#endif // UV_EDITOR_CONTROLLER_H +#endif // UV_EDITOR_CONTROLLER_H \ No newline at end of file diff --git a/src/UVEditorController_test.cpp b/src/UVEditorController_test.cpp index 829e34840..882945659 100644 --- a/src/UVEditorController_test.cpp +++ b/src/UVEditorController_test.cpp @@ -4,6 +4,7 @@ #include "UVEditorController.h" #include "EditableMesh.h" +#include "EditModeController.h" #include "Manager.h" #include "SelectionSet.h" #include "TestHelpers.h" @@ -91,6 +92,10 @@ class UVEditorControllerTest : public ::testing::Test { } void TearDown() override { + if (auto* edit = EditModeController::instance()) { + if (edit->isEditModeActive()) + edit->exitEditMode(false); + } UVEditorController::kill(); Manager::kill(); } @@ -194,3 +199,156 @@ TEST_F(UVEditorControllerTest, ShowTextureBackgroundToggle) ctrl->setShowTextureBackground(initial); } + +TEST_F(UVEditorControllerTest, FacePickSelectsTriangle) +{ + auto mesh = createInMemoryTriangleMesh("UVEditor_pick_tri"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_pick_node"); + auto* entity = sceneMgr->createEntity("UVEditor_pick_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->setSelectionMode(UVEditorController::FaceMode); + ctrl->refresh(); + + ASSERT_TRUE(ctrl->hasMesh()); + ctrl->pickAt(0.25, 0.25, UVEditorController::NoModifier, 0.5); + EXPECT_EQ(ctrl->selectedFaceCount(), 1); + EXPECT_EQ(ctrl->selectionFaces().size(), 1); +} + +TEST_F(UVEditorControllerTest, ShiftClickAddsVertexSelection) +{ + auto mesh = createSeamedQuadMesh("UVEditor_multi_pick"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_multi_node"); + auto* entity = sceneMgr->createEntity("UVEditor_multi_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->setSelectionMode(UVEditorController::VertexMode); + ctrl->refresh(); + ASSERT_TRUE(ctrl->hasMesh()); + + ctrl->pickAt(0.0, 0.0, UVEditorController::NoModifier, 0.5); + EXPECT_EQ(ctrl->selectedVertexCount(), 1); + + ctrl->pickAt(1.0, 0.2, UVEditorController::ShiftModifier, 0.5); + EXPECT_GE(ctrl->selectedVertexCount(), 2); +} + +TEST_F(UVEditorControllerTest, BoxSelectFacesTouchesPartialOverlap) +{ + auto mesh = createSeamedQuadMesh("UVEditor_box_pick"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_box_node"); + auto* entity = sceneMgr->createEntity("UVEditor_box_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->setSelectionMode(UVEditorController::FaceMode); + ctrl->refresh(); + ASSERT_TRUE(ctrl->hasMesh()); + + ctrl->boxSelect(0.4, 0.0, 1.1, 1.3, UVEditorController::NoModifier); + EXPECT_GE(ctrl->selectedFaceCount(), 1); +} + +TEST_F(UVEditorControllerTest, BoxSelectVerticesRequiresFullEnclosure) +{ + auto mesh = createSeamedQuadMesh("UVEditor_box_vert"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_box_vert_node"); + auto* entity = sceneMgr->createEntity("UVEditor_box_vert_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->setSelectionMode(UVEditorController::VertexMode); + ctrl->refresh(); + ASSERT_TRUE(ctrl->hasMesh()); + + ctrl->boxSelect(0.45, 0.45, 0.55, 0.55, UVEditorController::NoModifier); + EXPECT_EQ(ctrl->selectedVertexCount(), 0); + + ctrl->boxSelect(-0.05, -0.05, 0.15, 0.15, UVEditorController::NoModifier); + EXPECT_GE(ctrl->selectedVertexCount(), 1); +} + +TEST_F(UVEditorControllerTest, SelectionModeEmitsBreadcrumbSignal) +{ + UVEditorController* ctrl = UVEditorController::instance(); + QSignalSpy spy(ctrl, &UVEditorController::selectionModeChanged); + ctrl->setSelectionMode(UVEditorController::EdgeMode); + EXPECT_EQ(ctrl->selectionMode(), UVEditorController::EdgeMode); + EXPECT_GE(spy.count(), 1); +} + +TEST_F(UVEditorControllerTest, FacePickMissesEmptySpaceOutsideRadius) +{ + auto mesh = createInMemoryTriangleMesh("UVEditor_pick_miss"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_pick_miss_node"); + auto* entity = sceneMgr->createEntity("UVEditor_pick_miss_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->setSelectionMode(UVEditorController::FaceMode); + ctrl->refresh(); + ASSERT_TRUE(ctrl->hasMesh()); + + ctrl->pickAt(5.0, 5.0, UVEditorController::NoModifier, 0.1); + EXPECT_EQ(ctrl->selectedFaceCount(), 0); +} + +TEST_F(UVEditorControllerTest, ContextIslandsHighlightFromEditSelection) +{ + auto mesh = createInMemoryTriangleMesh("UVEditor_ctx_island"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_ctx_node"); + auto* entity = sceneMgr->createEntity("UVEditor_ctx_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->refresh(); + ASSERT_TRUE(ctrl->hasMesh()); + + auto* edit = EditModeController::instance(); + ASSERT_TRUE(edit->enterEditMode()); + ctrl->refresh(); + + edit->setSelectionMode(EditModeController::FaceMode); + edit->selectFace(0); + EXPECT_EQ(ctrl->selectedFaceCount(), 0); + EXPECT_FALSE(ctrl->contextIslandFaces().isEmpty()); +} + +TEST_F(UVEditorControllerTest, FacePickWorksInEditMode) +{ + auto mesh = createInMemoryTriangleMesh("UVEditor_edit_pick"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("UVEditor_edit_pick_node"); + auto* entity = sceneMgr->createEntity("UVEditor_edit_pick_entity", mesh); + node->attachObject(entity); + + UVEditorController* ctrl = UVEditorController::instance(); + SelectionSet::getSingleton()->selectOne(entity); + ctrl->setSelectionMode(UVEditorController::FaceMode); + ctrl->refresh(); + ASSERT_TRUE(ctrl->hasMesh()); + + auto* edit = EditModeController::instance(); + ASSERT_TRUE(edit->enterEditMode()); + ctrl->refresh(); + ASSERT_TRUE(ctrl->hasMesh()); + ASSERT_GE(ctrl->triangles().size(), 1); + + ctrl->pickAt(0.25, 0.25, UVEditorController::NoModifier, 0.5); + EXPECT_GE(ctrl->selectedFaceCount(), 1); +}