From 407c28dfce9de79676baec171e3ab127a53d2e6f Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 01:25:42 -0400 Subject: [PATCH 01/40] feat(paint): texture paint MVP + bake vertex-colors to texture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Epic #313 β€” closes #317 (texture paint MVP), #318 (texture paint I/O + dirty rect), #319 (bake vertex colors to texture), #321 (docs). - TexturePaintBuffer: pure-data RGBA8 pixel buffer with dirty-rect tracking, brush stamp (radius/strength/falloff matching vertex paint), save/load via QImage. Unit tested. - VertexColorBaker: per-triangle UV-space rasterizer with barycentric color interpolation + edge-dilation pass for seam masking. Unit tested with degenerate / flipped-winding / dilation coverage. - TexturePaintController: QML_SINGLETON owning the active paint session (buffer + live Ogre texture). Hit-tests via the existing EditModeController raycast, recovers barycentric UV via MΓΆller–Trumbore, paints into the CPU buffer, then uploads only the dirty rect to the GPU each stroke. Stroke undo/redo via a snapshot command pushed to UndoManager. - Wires into TransformOperator mouse pipeline alongside vertex paint. - New CLI: `qtmesh bake-vertex-colors -o out.png [--resolution N] [--dilation N] [--json]`. - QML "Texture Paint" section in Inspector: enable mode, create/save/ load texture, bake button, color picker + radius/strength/falloff sliders. - README workflow doc + features bullet; website feature card. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 45 +++ qml/PropertiesPanel.qml | 251 +++++++++++++++ src/CLIPipeline.cpp | 129 ++++++++ src/CLIPipeline.h | 5 + src/CMakeLists.txt | 6 + src/TexturePaintBuffer.cpp | 199 ++++++++++++ src/TexturePaintBuffer.h | 131 ++++++++ src/TexturePaintBuffer_test.cpp | 230 ++++++++++++++ src/TexturePaintController.cpp | 544 ++++++++++++++++++++++++++++++++ src/TexturePaintController.h | 200 ++++++++++++ src/TransformOperator.cpp | 25 ++ src/TransformOperator.h | 1 + src/VertexColorBaker.cpp | 184 +++++++++++ src/VertexColorBaker.h | 81 +++++ src/VertexColorBaker_test.cpp | 203 ++++++++++++ src/main.cpp | 2 +- src/mainwindow.cpp | 5 + tests/CMakeLists.txt | 3 + website/src/data/content.js | 4 + 19 files changed, 2247 insertions(+), 1 deletion(-) create mode 100644 src/TexturePaintBuffer.cpp create mode 100644 src/TexturePaintBuffer.h create mode 100644 src/TexturePaintBuffer_test.cpp create mode 100644 src/TexturePaintController.cpp create mode 100644 src/TexturePaintController.h create mode 100644 src/VertexColorBaker.cpp create mode 100644 src/VertexColorBaker.h create mode 100644 src/VertexColorBaker_test.cpp diff --git a/README.md b/README.md index 402b71b34..86eaed36e 100755 --- a/README.md +++ b/README.md @@ -166,8 +166,52 @@ qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # LOD generation qtmesh lod model.fbx --auto + +# Bake vertex colors β†’ texture (with UV-seam dilation) +qtmesh bake-vertex-colors model.fbx -o color_map.png --resolution 1024 --dilation 4 +``` + +--- + +### 🎨 Paint Tools + +ZBrush-style polypaint and BaseColor texture painting, with a one-click bake +that turns vertex colors into a UV-space texture. + +**Quick start (GUI):** + +1. Select a mesh entity. Press **Tab** to enter Edit Mode. +2. In the Inspector β†’ **Edit Mode Tools** section, tick *Vertex Color Preview* + to see vertex colors live on the mesh. +3. Click the **Vertex Paint** brush button in the toolbar. Pick a color, + radius, strength, and falloff. Left-click-drag on the mesh to paint. + Strokes go through Undo/Redo (Ctrl+Z / Ctrl+Shift+Z). +4. To paint into a texture instead, open the Inspector β†’ **Texture Paint** + section. Click *Create / Attach Texture*, tick *Enable texture paint + mode*, then left-click-drag on the mesh. The painted texture is + uploaded to the GPU with per-stroke dirty-rect updates β€” no full + re-upload β€” so live painting stays responsive on large textures. +5. **Bake Vertex Colors β†’ Texture** rasterizes the active mesh's vertex + colors into a UV-space PNG via barycentric interpolation, then dilates + the result outward by N pixels to mask UV-seam bleed at MIP-map time. + +**Headless (CLI):** + +```bash +qtmesh bake-vertex-colors model.fbx -o color_map.png --resolution 1024 --dilation 4 ``` +**Export.** Vertex colors are preserved on export to formats that support +them (glTF preferred β€” verified round-trip). + +**Limitations (MVP).** + +- Texture paint operates on the **first submesh's diffuse texture only**. + Multi-material per-submesh paint will land in a follow-up. +- Texture paint requires Edit Mode (vertex paint conventions). +- Bake uses fan triangulation of n-gon faces; concave faces should be + pre-triangulated. + --- ### ✨ Merge Mixamo Animations in Seconds @@ -198,6 +242,7 @@ Split View|Skeleton Animation Controls - **Animation resampling** β€” reduce keyframe density for game engines - **Pose export** β€” bake animation frames as static meshes (3D printing) - **LOD generation** β€” automatic level-of-detail mesh reduction +- **Paint tools** β€” vertex paint, texture paint (BaseColor), bake vertex colors to texture with seam dilation - **Material editor** β€” visual editing with AI-assisted generation - **Skeleton inspection** β€” bone weights, debug overlays, animation preview - **Scene management** β€” duplicate (Ctrl+D), group (Ctrl+G), snap, pivot modes diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 0c3e61dd1..a6b1e6a4f 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Dialogs import PropertiesPanel 1.0 import AnimationControl 1.0 import EditorMode 1.0 @@ -282,6 +283,17 @@ Rectangle { Component.onCompleted: content = editModeToolsComponent } + // ---- Texture Paint ---- + CollapsibleSection { + title: "Texture Paint" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.EditMode, + EditModeController.editModeActive) + expanded: false + + Component.onCompleted: content = texturePaintComponent + } + CollapsibleSection { title: "Workspace Panels" sectionVisible: root.currentTab === root.modeToolsTab @@ -934,6 +946,245 @@ Rectangle { } } + // ---- Texture Paint Content ---- + Component { + id: texturePaintComponent + + Column { + id: texPaintCol + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + property bool paintOn: TexturePaintController.texturePaintEnabled + property color brushColor: TexturePaintController.texturePaintColor + property real brushRadius: TexturePaintController.texturePaintRadius + property real brushStrength: TexturePaintController.texturePaintStrength + property real brushFalloff: TexturePaintController.texturePaintFalloff + property bool hasSession: TexturePaintController.hasActiveSession + property int sessionRes: TexturePaintController.textureResolution + + Connections { + target: TexturePaintController + function onTexturePaintChanged() { + texPaintCol.paintOn = TexturePaintController.texturePaintEnabled + texPaintCol.brushColor = TexturePaintController.texturePaintColor + texPaintCol.brushRadius = TexturePaintController.texturePaintRadius + texPaintCol.brushStrength = TexturePaintController.texturePaintStrength + texPaintCol.brushFalloff = TexturePaintController.texturePaintFalloff + } + function onSessionChanged() { + texPaintCol.hasSession = TexturePaintController.hasActiveSession + texPaintCol.sessionRes = TexturePaintController.textureResolution + } + } + + Text { + width: parent.width - 16 + text: "Paint directly into a BaseColor texture using mesh UVs." + color: PropertiesPanelController.textColor + font.pixelSize: 10; opacity: 0.7 + wrapMode: Text.Wrap + } + + // Enable toggle + Row { + spacing: 6 + Rectangle { + width: 18; height: 18; radius: 3 + color: texPaintCol.paintOn ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: texPaintCol.paintOn ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { TexturePaintController.texturePaintEnabled = !texPaintCol.paintOn } + } + } + Text { + text: "Enable texture paint mode" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Session info + Text { + width: parent.width - 16 + text: texPaintCol.hasSession + ? ("Active texture: " + texPaintCol.sessionRes + "\u00d7" + texPaintCol.sessionRes) + : "No texture session \u2014 click \"Create / Attach Texture\" to start." + color: texPaintCol.hasSession ? "#60c060" : PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: texPaintCol.hasSession ? 1.0 : 0.7 + wrapMode: Text.Wrap + } + + // Action row 1: create, save, load + Flow { + width: parent.width - 16 + spacing: 4 + + Rectangle { + width: 140; height: 24; radius: 3 + color: createMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Create / Attach Texture"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: createMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.ensurePaintableTexture(1024) + } + } + + Rectangle { + width: 70; height: 24; radius: 3 + color: saveMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + opacity: texPaintCol.hasSession ? 1.0 : 0.4 + Text { anchors.centerIn: parent; text: "Save\u2026"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: saveMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + enabled: texPaintCol.hasSession + onClicked: texPaintSaveDialog.open() + } + } + + Rectangle { + width: 70; height: 24; radius: 3 + color: loadMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Load\u2026"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: loadMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: texPaintLoadDialog.open() + } + } + } + + // Action row 2: bake vertex colors + Flow { + width: parent.width - 16 + spacing: 4 + + Rectangle { + width: 200; height: 24; radius: 3 + color: bakeMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "Bake Vertex Colors \u2192 Texture"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: bakeMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.bakeVertexColorsToTexture(1024, 4, "") + } + } + } + + // Brush color picker + Row { + spacing: 6 + Text { + text: "Color" + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 70 + } + Rectangle { + width: 28; height: 22; radius: 3 + color: texPaintCol.brushColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: texPaintColorDialog.open() + } + } + } + + // Radius slider + Row { + spacing: 6 + Text { + text: "Radius"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.005; to: 0.5; stepSize: 0.005 + value: texPaintCol.brushRadius + onMoved: TexturePaintController.texturePaintRadius = value + } + Text { + text: texPaintCol.brushRadius.toFixed(3) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Strength slider + Row { + spacing: 6 + Text { + text: "Strength"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.0; to: 1.0; stepSize: 0.01 + value: texPaintCol.brushStrength + onMoved: TexturePaintController.texturePaintStrength = value + } + Text { + text: texPaintCol.brushStrength.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Falloff slider + Row { + spacing: 6 + Text { + text: "Falloff"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.0; to: 1.0; stepSize: 0.01 + value: texPaintCol.brushFalloff + onMoved: TexturePaintController.texturePaintFalloff = value + } + Text { + text: texPaintCol.brushFalloff.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + ColorDialog { + id: texPaintColorDialog + title: "Texture paint color" + onAccepted: TexturePaintController.texturePaintColor = selectedColor + } + + FileDialog { + id: texPaintSaveDialog + title: "Save painted texture" + fileMode: FileDialog.SaveFile + nameFilters: ["PNG image (*.png)", "JPEG image (*.jpg *.jpeg)", "TGA image (*.tga)"] + onAccepted: TexturePaintController.savePaintBuffer(selectedFile.toString().replace(/^file:\/\//, '')) + } + + FileDialog { + id: texPaintLoadDialog + title: "Load texture into paint buffer" + fileMode: FileDialog.OpenFile + nameFilters: ["Image files (*.png *.jpg *.jpeg *.tga *.bmp)"] + onAccepted: TexturePaintController.loadPaintBuffer(selectedFile.toString().replace(/^file:\/\//, '')) + } + } + } + Component { id: workspacePanelsComponent diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index f6792ec11..d9de75d13 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -18,6 +18,9 @@ #include "DrawCallAnalyzer.h" #include "VertexCacheOptimizer.h" #include "MeshDecimator.h" +#include "EditableMesh.h" +#include "TexturePaintBuffer.h" +#include "VertexColorBaker.h" #include "QtMeshCloudClient.h" #include #include @@ -664,6 +667,11 @@ void CLIPipeline::printUsage() " --simplify-scale-tol S default 0.0001\n" " --simplify-preset P shorthand for the three tolerances;\n" " P = conservative | balanced | aggressive\n" + " bake-vertex-colors -o [--resolution N] [--dilation N] [--json]\n" + " Bake vertex colors to a UV-space PNG. Walks every UV-mapped\n" + " triangle, rasterizes barycentric-interpolated vertex colors,\n" + " then dilates outward by N pixels to mask seam bleed at MIP time.\n" + " Default resolution=1024, dilation=4. Output PNG is RGBA.\n" "\n" "Global options:\n" " --help, -h Show this help\n" @@ -1056,6 +1064,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "vertex-cache") rc = cmdVertexCache(argc, argv); else if (cmd == "decimate") rc = cmdDecimate(argc, argv); else if (cmd == "optimize") rc = cmdOptimize(argc, argv); + else if (cmd == "bake-vertex-colors") rc = cmdBakeVertexColors(argc, argv); if (rc < 0) { err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; @@ -4623,3 +4632,123 @@ int CLIPipeline::cmdOptimize(int argc, char* argv[]) emitOptimizeReport(fi, cmdArgs.outputPath, srcBytes, outBytes, stages, cmdArgs.jsonOutput); return 0; } + +int CLIPipeline::cmdBakeVertexColors(int argc, char* argv[]) +{ + // Parse: + // bake-vertex-colors -o + // [--resolution N] [--dilation N] [--json] + QString inputPath, outputPath; + int resolution = 1024; + int dilation = 4; + bool jsonOutput = false; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "bake-vertex-colors" || arg == "--cli") continue; + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString(argv[++i]); continue; + } + if (arg == "--resolution" && i + 1 < argc) { + bool ok = false; + const int v = QString(argv[++i]).toInt(&ok); + if (!ok || v < 16 || v > 8192) { + err() << "Error: --resolution must be an integer in [16..8192]" << Qt::endl; + return 2; + } + resolution = v; continue; + } + if (arg == "--dilation" && i + 1 < argc) { + bool ok = false; + const int v = QString(argv[++i]).toInt(&ok); + if (!ok || v < 0 || v > 64) { + err() << "Error: --dilation must be an integer in [0..64]" << Qt::endl; + return 2; + } + dilation = v; continue; + } + if (arg == "--json") { jsonOutput = true; continue; } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; continue; + } + } + + if (inputPath.isEmpty() || outputPath.isEmpty()) { + err() << "Error: missing required arguments." << Qt::endl; + err() << "Usage: qtmesh bake-vertex-colors -o " << Qt::endl; + err() << " [--resolution N] [--dilation N] [--json]" << Qt::endl; + return 2; + } + + const QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: File not found: " << inputPath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb("cli.bake-vertex-colors", + QString("Bake vertex colors β†’ %1Γ—%1 PNG (dilation=%2)").arg(resolution).arg(dilation)); + SentryReporter::addBreadcrumb("file.import", + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); + const auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << inputPath << Qt::endl; + return 1; + } + + // Bake the first entity found. Multi-entity scenes get a warning; the + // caller can pre-split the scene if they need per-entity bakes. + Ogre::Entity* entity = nullptr; + int entityCount = 0; + for (auto* obj : entities) { + if (!obj || obj->getMovableType() != "Entity") continue; + if (!entity) entity = static_cast(obj); + ++entityCount; + } + if (!entity) { + err() << "Error: No mesh entity in: " << inputPath << Qt::endl; + return 1; + } + if (entityCount > 1) { + err() << "Note: " << entityCount + << " entities in scene; baking the first only." << Qt::endl; + } + + EditableMesh mesh; + if (!mesh.loadFromEntity(entity)) { + err() << "Error: Failed to decompose mesh for: " << inputPath << Qt::endl; + return 1; + } + + TexturePaintBuffer buffer; + VertexColorBaker::Options opts; + opts.resolution = resolution; + opts.dilationPixels = dilation; + const int painted = VertexColorBaker::bake(mesh, buffer, opts); + + if (!buffer.save(outputPath.toStdString())) { + err() << "Error: Failed to write: " << outputPath << Qt::endl; + return 1; + } + + if (jsonOutput) { + QJsonObject root; + root["input"] = inputPath; + root["output"] = outputPath; + root["resolution"] = resolution; + root["dilation"] = dilation; + root["pixels_rasterized"] = painted; + root["entity"] = QString::fromStdString(entity->getName()); + QJsonDocument doc(root); + cliWrite(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)) + "\n"); + } else { + cliWrite(QStringLiteral("Baked %1 pixels into %2Γ—%2 texture: %3\n") + .arg(painted).arg(resolution).arg(outputPath)); + cliWrite(QStringLiteral(" dilation: %1 px\n").arg(dilation)); + } + return 0; +} diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 582fca9fc..8b646652b 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -114,6 +114,11 @@ class CLIPipeline { /// per-stage before/after report (text or --json). static int cmdOptimize(int argc, char* argv[]); + /// Phase 7 paint: bake EditableMesh vertex colors into a UV-space + /// PNG texture with configurable resolution and seam dilation. + /// Surfaces VertexColorBaker via the CLI for headless asset pipelines. + static int cmdBakeVertexColors(int argc, char* argv[]); + /// Map file extension to MeshImporterExporter format string. static QString formatForExtension(const QString& path); }; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 67d13feec..be8b517fd 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -79,6 +79,9 @@ MaterialPresetLibrary.cpp MeshLodController.cpp TextureChannelPacker.cpp TextureAtlasPacker.cpp +TexturePaintBuffer.cpp +TexturePaintController.cpp +VertexColorBaker.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp @@ -173,6 +176,9 @@ MaterialPresetLibrary.h MeshLodController.h TextureChannelPacker.h TextureAtlasPacker.h +TexturePaintBuffer.h +TexturePaintController.h +VertexColorBaker.h ApplyAtlas.h EmbeddedTextureCache.h NormalMapGenerator.h diff --git a/src/TexturePaintBuffer.cpp b/src/TexturePaintBuffer.cpp new file mode 100644 index 000000000..d33983939 --- /dev/null +++ b/src/TexturePaintBuffer.cpp @@ -0,0 +1,199 @@ +#include "TexturePaintBuffer.h" + +#include +#include + +#include +#include +#include + +namespace { + +inline uint8_t floatToByte(float v) +{ + if (v <= 0.0f) return 0; + if (v >= 1.0f) return 255; + return static_cast(std::lround(v * 255.0f)); +} + +inline float byteToFloat(uint8_t v) +{ + return static_cast(v) / 255.0f; +} + +} // namespace + +TexturePaintBuffer::TexturePaintBuffer(int width, int height) +{ + resize(width, height); +} + +void TexturePaintBuffer::resize(int width, int height) +{ + m_width = std::max(0, width); + m_height = std::max(0, height); + const size_t n = static_cast(m_width) * static_cast(m_height) * 4u; + m_pixels.assign(n, 0xFF); + m_dirty = {}; +} + +void TexturePaintBuffer::clear(const Ogre::ColourValue& color) +{ + if (m_width <= 0 || m_height <= 0) return; + const uint8_t r = floatToByte(color.r); + const uint8_t g = floatToByte(color.g); + const uint8_t b = floatToByte(color.b); + const uint8_t a = floatToByte(color.a); + for (size_t i = 0; i < m_pixels.size(); i += 4) { + m_pixels[i + 0] = r; + m_pixels[i + 1] = g; + m_pixels[i + 2] = b; + m_pixels[i + 3] = a; + } + expandDirty(0, 0, m_width, m_height); +} + +Ogre::ColourValue TexturePaintBuffer::pixel(int x, int y) const +{ + if (x < 0 || y < 0 || x >= m_width || y >= m_height) + return Ogre::ColourValue(0.0f, 0.0f, 0.0f, 0.0f); + const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; + return Ogre::ColourValue( + byteToFloat(m_pixels[off + 0]), + byteToFloat(m_pixels[off + 1]), + byteToFloat(m_pixels[off + 2]), + byteToFloat(m_pixels[off + 3])); +} + +void TexturePaintBuffer::setPixel(int x, int y, const Ogre::ColourValue& color) +{ + if (x < 0 || y < 0 || x >= m_width || y >= m_height) + return; + const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; + m_pixels[off + 0] = floatToByte(color.r); + m_pixels[off + 1] = floatToByte(color.g); + m_pixels[off + 2] = floatToByte(color.b); + m_pixels[off + 3] = floatToByte(color.a); + expandDirty(x, y, x + 1, y + 1); +} + +void TexturePaintBuffer::uvToPixel(const Ogre::Vector2& uv, int& outX, int& outY) const +{ + outX = static_cast(std::floor(uv.x * static_cast(m_width))); + outY = static_cast(std::floor((1.0f - uv.y) * static_cast(m_height))); +} + +Ogre::Vector2 TexturePaintBuffer::pixelToUV(int x, int y) const +{ + if (m_width <= 0 || m_height <= 0) + return Ogre::Vector2::ZERO; + const float u = (static_cast(x) + 0.5f) / static_cast(m_width); + const float v = 1.0f - (static_cast(y) + 0.5f) / static_cast(m_height); + return Ogre::Vector2(u, v); +} + +int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, + float radiusUV, + const Ogre::ColourValue& color, + float strength, + float falloff) +{ + if (m_width <= 0 || m_height <= 0) return 0; + if (radiusUV <= 0.0f) return 0; + strength = std::clamp(strength, 0.0f, 1.0f); + falloff = std::clamp(falloff, 0.0f, 1.0f); + if (strength <= 0.0f) return 0; + + const float radiusXf = radiusUV * static_cast(m_width); + const float radiusYf = radiusUV * static_cast(m_height); + const float centerXf = uv.x * static_cast(m_width); + const float centerYf = (1.0f - uv.y) * static_cast(m_height); + + int x0 = static_cast(std::floor(centerXf - radiusXf)); + int x1 = static_cast(std::ceil(centerXf + radiusXf)); + int y0 = static_cast(std::floor(centerYf - radiusYf)); + int y1 = static_cast(std::ceil(centerYf + radiusYf)); + x0 = std::max(0, x0); + y0 = std::max(0, y0); + x1 = std::min(m_width, x1); + y1 = std::min(m_height, y1); + if (x0 >= x1 || y0 >= y1) return 0; + + const float p = 1.0f + falloff * 3.0f; + const float invRx = 1.0f / std::max(radiusXf, 1e-6f); + const float invRy = 1.0f / std::max(radiusYf, 1e-6f); + int affected = 0; + int touchedX0 = x1; + int touchedX1 = x0; + int touchedY0 = y1; + int touchedY1 = y0; + + for (int y = y0; y < y1; ++y) { + const float dy = (static_cast(y) + 0.5f - centerYf) * invRy; + for (int x = x0; x < x1; ++x) { + const float dx = (static_cast(x) + 0.5f - centerXf) * invRx; + const float r2 = dx * dx + dy * dy; + if (r2 >= 1.0f) continue; + const float w = std::pow(1.0f - r2, p); + const float blend = strength * w; + if (blend <= 0.0f) continue; + const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; + const float prevR = byteToFloat(m_pixels[off + 0]); + const float prevG = byteToFloat(m_pixels[off + 1]); + const float prevB = byteToFloat(m_pixels[off + 2]); + const float prevA = byteToFloat(m_pixels[off + 3]); + m_pixels[off + 0] = floatToByte(prevR + (color.r - prevR) * blend); + m_pixels[off + 1] = floatToByte(prevG + (color.g - prevG) * blend); + m_pixels[off + 2] = floatToByte(prevB + (color.b - prevB) * blend); + m_pixels[off + 3] = floatToByte(prevA + (color.a - prevA) * blend); + ++affected; + touchedX0 = std::min(touchedX0, x); + touchedY0 = std::min(touchedY0, y); + touchedX1 = std::max(touchedX1, x + 1); + touchedY1 = std::max(touchedY1, y + 1); + } + } + + if (affected > 0) + expandDirty(touchedX0, touchedY0, touchedX1, touchedY1); + return affected; +} + +bool TexturePaintBuffer::save(const std::string& path) const +{ + if (m_width <= 0 || m_height <= 0) return false; + QImage img(m_pixels.data(), m_width, m_height, m_width * 4, QImage::Format_RGBA8888); + if (img.isNull()) return false; + return img.save(QString::fromStdString(path)); +} + +bool TexturePaintBuffer::load(const std::string& path) +{ + QImage img(QString::fromStdString(path)); + if (img.isNull()) return false; + img = img.convertToFormat(QImage::Format_RGBA8888); + m_width = img.width(); + m_height = img.height(); + const size_t n = static_cast(m_width) * static_cast(m_height) * 4u; + m_pixels.resize(n); + for (int y = 0; y < m_height; ++y) { + const uchar* src = img.constScanLine(y); + uint8_t* dst = m_pixels.data() + static_cast(y) * static_cast(m_width) * 4u; + std::memcpy(dst, src, static_cast(m_width) * 4u); + } + expandDirty(0, 0, m_width, m_height); + return true; +} + +void TexturePaintBuffer::expandDirty(int x0, int y0, int x1, int y1) +{ + if (x0 >= x1 || y0 >= y1) return; + if (m_dirty.empty()) { + m_dirty = {x0, y0, x1, y1}; + return; + } + m_dirty.x0 = std::min(m_dirty.x0, x0); + m_dirty.y0 = std::min(m_dirty.y0, y0); + m_dirty.x1 = std::max(m_dirty.x1, x1); + m_dirty.y1 = std::max(m_dirty.y1, y1); +} diff --git a/src/TexturePaintBuffer.h b/src/TexturePaintBuffer.h new file mode 100644 index 000000000..163a6d9b8 --- /dev/null +++ b/src/TexturePaintBuffer.h @@ -0,0 +1,131 @@ +#ifndef TEXTUREPAINTBUFFER_H +#define TEXTUREPAINTBUFFER_H + +#include +#include + +#include +#include +#include +#include + +/** + * @brief RGBA8 pixel buffer with dirty-rect tracking for texture painting. + * + * Owns a flat `std::vector` of size `width * height * 4` (RGBA8, + * top-left origin, UV (0,0) maps to pixel (0, height-1) β€” i.e. V is flipped). + * + * All mutations expand `dirtyRect()`. The dirty rect is the smallest pixel + * AABB covering every pixel mutated since the last `clearDirty()` call. It + * is empty when no mutation has happened or after `clearDirty()`. + * + * Pure data. Has no Qt or Ogre runtime dependencies β€” the only Ogre types + * referenced (`ColourValue`, `Vector2`) are header-only math. + */ +class TexturePaintBuffer +{ +public: + /// Dirty rect in pixel coordinates. [x0..x1) Γ— [y0..y1) β€” half-open. + struct DirtyRect { + int x0 = 0; + int y0 = 0; + int x1 = 0; + int y1 = 0; + bool empty() const { return x1 <= x0 || y1 <= y0; } + int width() const { return empty() ? 0 : x1 - x0; } + int height() const { return empty() ? 0 : y1 - y0; } + }; + + TexturePaintBuffer() = default; + + /// Construct with given size, all pixels initialized to opaque white. + TexturePaintBuffer(int width, int height); + + /// Resize the buffer, clearing it to opaque white. Marks no dirty rect. + void resize(int width, int height); + + /// Fill the buffer with a single color. Marks the full buffer dirty. + void clear(const Ogre::ColourValue& color); + + int width() const { return m_width; } + int height() const { return m_height; } + /// Raw RGBA8 byte buffer (row-major, top-left origin). + const std::vector& data() const { return m_pixels; } + std::vector& data() { return m_pixels; } + /// Current dirty rect (in pixel coords). Empty when no mutation pending. + const DirtyRect& dirtyRect() const { return m_dirty; } + void clearDirty() { m_dirty = {}; } + /// Expand the dirty rect manually. Used when external code mutates the + /// raw `data()` array (e.g. VertexColorBaker dilation). + void markDirty(int x0, int y0, int x1, int y1) { expandDirty(x0, y0, x1, y1); } + + /// Read pixel. Out-of-bounds returns black-transparent. + Ogre::ColourValue pixel(int x, int y) const; + + /// Write pixel (clamped to bounds). Expands dirty rect. + void setPixel(int x, int y, const Ogre::ColourValue& color); + + /** + * @brief Paint a circular brush stamp at UV coordinate. + * + * @param uv Center UV in [0..1]^2. V is flipped (0 = bottom). + * @param radiusUV Brush radius in UV-space units. + * @param color Brush color (alpha is interpreted as flow). + * @param strength 0..1 β€” how much the brush moves the pixel toward `color`. + * @param falloff 0 = hard, 1 = full quadratic falloff at the edge. + * @return Number of pixels modified. + * + * Each pixel inside the brush footprint is lerped: + * + * out = lerp(prev, color, strength * weight) + * + * where `weight = 1` at center and falls off to `0` at the edge. The + * falloff curve is `(1 - r^2)^p` with `p = 1 + falloff * 3`, matching + * the vertex-paint brush in EditModeController. + */ + int paintBrush(const Ogre::Vector2& uv, + float radiusUV, + const Ogre::ColourValue& color, + float strength = 1.0f, + float falloff = 0.5f); + + /** + * @brief Save the buffer to disk as a PNG/JPEG/TGA/BMP/etc. + * + * Uses Qt's QImage internally. Returns false if Qt can't write the + * given format (typically a typo in the extension). + * + * @param path Output file path. Extension determines format. + * @return true on success. + */ + bool save(const std::string& path) const; + + /** + * @brief Load a PNG/JPEG/TGA/BMP/etc into the buffer. + * + * Replaces buffer contents and resizes if needed. The loaded image is + * converted to RGBA8. Mark loaded contents fully dirty so any consumer + * (e.g. an Ogre HardwarePixelBuffer mirror) re-uploads. + * + * @return true on success. + */ + bool load(const std::string& path); + + /// Convenience: map a UV to integer pixel coordinates. + /// V is flipped: uv.y=0 β†’ y=height-1, uv.y=1 β†’ y=0. Caller is responsible + /// for clamping to bounds. + void uvToPixel(const Ogre::Vector2& uv, int& outX, int& outY) const; + + /// Convenience: map a pixel back to UV center. Inverse of uvToPixel. + Ogre::Vector2 pixelToUV(int x, int y) const; + +private: + void expandDirty(int x0, int y0, int x1, int y1); + + int m_width = 0; + int m_height = 0; + std::vector m_pixels; + DirtyRect m_dirty; +}; + +#endif // TEXTUREPAINTBUFFER_H diff --git a/src/TexturePaintBuffer_test.cpp b/src/TexturePaintBuffer_test.cpp new file mode 100644 index 000000000..57f1c1cf4 --- /dev/null +++ b/src/TexturePaintBuffer_test.cpp @@ -0,0 +1,230 @@ +#include + +#include +#include + +#include "TexturePaintBuffer.h" + +namespace { + +constexpr uint8_t kFull = 255; + +uint8_t byte(int x, int y, int width, const std::vector& data, int channel) +{ + const size_t off = (static_cast(y) * static_cast(width) + static_cast(x)) * 4u; + return data[off + static_cast(channel)]; +} + +} // namespace + +TEST(TexturePaintBufferTest, DefaultInitOpaqueWhite) +{ + TexturePaintBuffer buf(4, 4); + EXPECT_EQ(buf.width(), 4); + EXPECT_EQ(buf.height(), 4); + // 4x4 RGBA = 64 bytes, all 0xFF + EXPECT_EQ(buf.data().size(), 64u); + for (uint8_t b : buf.data()) + EXPECT_EQ(b, kFull); + EXPECT_TRUE(buf.dirtyRect().empty()); +} + +TEST(TexturePaintBufferTest, ResizeClearsBufferAndDirtyRect) +{ + TexturePaintBuffer buf(2, 2); + buf.setPixel(0, 0, Ogre::ColourValue(0, 0, 0, 1)); + EXPECT_FALSE(buf.dirtyRect().empty()); + buf.resize(8, 4); + EXPECT_EQ(buf.width(), 8); + EXPECT_EQ(buf.height(), 4); + EXPECT_TRUE(buf.dirtyRect().empty()); + EXPECT_EQ(buf.pixel(0, 0).r, 1.0f); // post-resize is opaque white +} + +TEST(TexturePaintBufferTest, ClearFillsBufferAndMarksFullDirty) +{ + TexturePaintBuffer buf(4, 4); + buf.clear(Ogre::ColourValue(0.5f, 0.0f, 1.0f, 1.0f)); + EXPECT_EQ(buf.dirtyRect().x0, 0); + EXPECT_EQ(buf.dirtyRect().y0, 0); + EXPECT_EQ(buf.dirtyRect().x1, 4); + EXPECT_EQ(buf.dirtyRect().y1, 4); + EXPECT_NEAR(buf.pixel(2, 2).r, 0.5f, 0.01f); + EXPECT_NEAR(buf.pixel(2, 2).b, 1.0f, 0.01f); +} + +TEST(TexturePaintBufferTest, SetPixelExpandsDirtyRect) +{ + TexturePaintBuffer buf(8, 8); + buf.setPixel(2, 3, Ogre::ColourValue::Red); + EXPECT_EQ(buf.dirtyRect().x0, 2); + EXPECT_EQ(buf.dirtyRect().y0, 3); + EXPECT_EQ(buf.dirtyRect().x1, 3); + EXPECT_EQ(buf.dirtyRect().y1, 4); + + buf.setPixel(5, 6, Ogre::ColourValue::Blue); + EXPECT_EQ(buf.dirtyRect().x0, 2); + EXPECT_EQ(buf.dirtyRect().y0, 3); + EXPECT_EQ(buf.dirtyRect().x1, 6); + EXPECT_EQ(buf.dirtyRect().y1, 7); +} + +TEST(TexturePaintBufferTest, SetPixelOutOfBoundsIsNoop) +{ + TexturePaintBuffer buf(4, 4); + buf.setPixel(-1, -1, Ogre::ColourValue::Red); + buf.setPixel(4, 4, Ogre::ColourValue::Red); + EXPECT_TRUE(buf.dirtyRect().empty()); + // All pixels still opaque white. + for (int y = 0; y < 4; ++y) + for (int x = 0; x < 4; ++x) + EXPECT_EQ(byte(x, y, 4, buf.data(), 0), kFull); +} + +TEST(TexturePaintBufferTest, ClearDirtyResets) +{ + TexturePaintBuffer buf(4, 4); + buf.setPixel(0, 0, Ogre::ColourValue::Red); + EXPECT_FALSE(buf.dirtyRect().empty()); + buf.clearDirty(); + EXPECT_TRUE(buf.dirtyRect().empty()); +} + +TEST(TexturePaintBufferTest, PaintBrushFullStrengthFillsCenterPixel) +{ + TexturePaintBuffer buf(32, 32); + // Brush at UV center, radius covering ~6 pixels horizontally. + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.1f, + Ogre::ColourValue::Red, + 1.0f, 0.0f); + EXPECT_GT(painted, 0); + // Center pixel must be ~red. + int cx = 0, cy = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + EXPECT_NEAR(buf.pixel(cx, cy).r, 1.0f, 0.02f); + EXPECT_NEAR(buf.pixel(cx, cy).g, 0.0f, 0.02f); + // Pixel far outside brush is untouched white. + EXPECT_EQ(byte(0, 0, 32, buf.data(), 0), kFull); + EXPECT_EQ(byte(0, 0, 32, buf.data(), 1), kFull); +} + +TEST(TexturePaintBufferTest, PaintBrushDirtyRectContainsAffectedPixels) +{ + TexturePaintBuffer buf(64, 64); + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.1f, + Ogre::ColourValue::Green, + 1.0f, 0.5f); + EXPECT_GT(painted, 0); + const auto& d = buf.dirtyRect(); + EXPECT_FALSE(d.empty()); + // The brush is centered at ~(32, 32). The dirty rect must include it. + EXPECT_LE(d.x0, 32); + EXPECT_GE(d.x1, 32); + EXPECT_LE(d.y0, 32); + EXPECT_GE(d.y1, 32); + // And must NOT span the entire buffer. + EXPECT_LT(d.width(), 64); + EXPECT_LT(d.height(), 64); +} + +TEST(TexturePaintBufferTest, PaintBrushZeroStrengthIsNoop) +{ + TexturePaintBuffer buf(16, 16); + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.2f, + Ogre::ColourValue::Red, + 0.0f, 0.5f); + EXPECT_EQ(painted, 0); + EXPECT_TRUE(buf.dirtyRect().empty()); +} + +TEST(TexturePaintBufferTest, PaintBrushZeroRadiusIsNoop) +{ + TexturePaintBuffer buf(16, 16); + const int painted = buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), + 0.0f, + Ogre::ColourValue::Red, + 1.0f, 0.5f); + EXPECT_EQ(painted, 0); +} + +TEST(TexturePaintBufferTest, PaintBrushClampsToBufferBounds) +{ + TexturePaintBuffer buf(8, 8); + // Brush centered at UV (0, 1) β€” top-left corner pixel. + const int painted = buf.paintBrush(Ogre::Vector2(0.0f, 1.0f), + 0.5f, + Ogre::ColourValue::Red, + 1.0f, 0.0f); + EXPECT_GT(painted, 0); + // The dirty rect must lie within the buffer. + const auto& d = buf.dirtyRect(); + EXPECT_GE(d.x0, 0); + EXPECT_GE(d.y0, 0); + EXPECT_LE(d.x1, 8); + EXPECT_LE(d.y1, 8); +} + +TEST(TexturePaintBufferTest, UvToPixelRoundTrip) +{ + TexturePaintBuffer buf(64, 32); + int x = 0, y = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), x, y); + EXPECT_EQ(x, 32); + EXPECT_EQ(y, 16); + buf.uvToPixel(Ogre::Vector2(0.0f, 0.0f), x, y); + EXPECT_EQ(x, 0); + EXPECT_EQ(y, 32); // V flipped + buf.uvToPixel(Ogre::Vector2(1.0f, 1.0f), x, y); + // 1.0 * 64 = 64 (out of bounds upper) β€” the helper doesn't clamp; that's + // the consumer's job. Verify the computed value rather than asserting + // in-bounds. + EXPECT_EQ(x, 64); + EXPECT_EQ(y, 0); +} + +TEST(TexturePaintBufferTest, SaveAndLoadRoundTripPreservesPixels) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + TexturePaintBuffer buf(8, 8); + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.3f, Ogre::ColourValue(0.2f, 0.4f, 0.6f, 1.0f), 1.0f, 0.0f); + const std::string path = dir.filePath("paint.png").toStdString(); + ASSERT_TRUE(buf.save(path)); + + TexturePaintBuffer reloaded; + ASSERT_TRUE(reloaded.load(path)); + EXPECT_EQ(reloaded.width(), 8); + EXPECT_EQ(reloaded.height(), 8); + // Center pixel should match (small tolerance for PNG byte rounding). + int cx = 0, cy = 0; + reloaded.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + EXPECT_NEAR(reloaded.pixel(cx, cy).r, 0.2f, 0.02f); + EXPECT_NEAR(reloaded.pixel(cx, cy).g, 0.4f, 0.02f); + EXPECT_NEAR(reloaded.pixel(cx, cy).b, 0.6f, 0.02f); +} + +TEST(TexturePaintBufferTest, LoadOnNonExistentFileFails) +{ + TexturePaintBuffer buf; + EXPECT_FALSE(buf.load("/definitely/does/not/exist/asdf.png")); +} + +TEST(TexturePaintBufferTest, MarkDirtyExpandsRectExternally) +{ + TexturePaintBuffer buf(16, 16); + EXPECT_TRUE(buf.dirtyRect().empty()); + buf.markDirty(3, 4, 7, 9); + EXPECT_EQ(buf.dirtyRect().x0, 3); + EXPECT_EQ(buf.dirtyRect().y0, 4); + EXPECT_EQ(buf.dirtyRect().x1, 7); + EXPECT_EQ(buf.dirtyRect().y1, 9); + // Expand again β€” should grow. + buf.markDirty(0, 0, 5, 5); + EXPECT_EQ(buf.dirtyRect().x0, 0); + EXPECT_EQ(buf.dirtyRect().y0, 0); + EXPECT_EQ(buf.dirtyRect().x1, 7); + EXPECT_EQ(buf.dirtyRect().y1, 9); +} diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp new file mode 100644 index 000000000..a70f82f3c --- /dev/null +++ b/src/TexturePaintController.cpp @@ -0,0 +1,544 @@ +#include "TexturePaintController.h" + +#include "EditModeController.h" +#include "EditableMesh.h" +#include "OgreWidget.h" +#include "SelectionSet.h" +#include "SentryReporter.h" +#include "SpaceCamera.h" +#include "UndoManager.h" +#include "VertexColorBaker.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +TexturePaintController* TexturePaintController::s_instance = nullptr; + +namespace { + +/// Undo command for one texture-paint stroke. Snapshots the buffer +/// before/after and restores via memcpy into the Ogre texture. +class TexturePaintStrokeCommand : public QUndoCommand +{ +public: + TexturePaintStrokeCommand(TexturePaintController* controller, + std::vector before, + std::vector after, + int width, + int height, + QString textureName) + : QUndoCommand(QStringLiteral("Texture paint")) + , m_controller(controller) + , m_before(std::move(before)) + , m_after(std::move(after)) + , m_width(width) + , m_height(height) + , m_textureName(std::move(textureName)) + { + } + + void undo() override { apply(m_before); } + void redo() override + { + if (m_skipFirstRedo) { + m_skipFirstRedo = false; + return; + } + apply(m_after); + } + +private: + void apply(const std::vector& pixels) + { + if (!m_controller) return; + if (m_controller->currentTextureName() != m_textureName) return; + const auto& buf = m_controller->buffer(); + if (buf.width() != m_width || buf.height() != m_height) return; + m_controller->applyPixelSnapshot(pixels); + } + + TexturePaintController* m_controller = nullptr; + std::vector m_before; + std::vector m_after; + int m_width = 0; + int m_height = 0; + QString m_textureName; + bool m_skipFirstRedo = true; // command is pushed *after* the stroke applied +}; + +} // namespace + +TexturePaintController* TexturePaintController::instance() +{ + if (!s_instance) + s_instance = new TexturePaintController(); + return s_instance; +} + +TexturePaintController* TexturePaintController::qmlInstance(QQmlEngine*, QJSEngine*) +{ + auto* p = instance(); + QQmlEngine::setObjectOwnership(p, QQmlEngine::CppOwnership); + return p; +} + +void TexturePaintController::kill() +{ + delete s_instance; + s_instance = nullptr; +} + +TexturePaintController::TexturePaintController(QObject* parent) + : QObject(parent) +{ +} + +TexturePaintController::~TexturePaintController() = default; + +void TexturePaintController::setTexturePaintEnabled(bool enabled) +{ + if (m_paintEnabled == enabled) return; + m_paintEnabled = enabled; + if (!enabled && m_strokeActive) + endStroke(); + SentryReporter::addBreadcrumb("ui.action", + enabled ? "Texture paint: enabled" : "Texture paint: disabled"); + emit texturePaintChanged(); +} + +void TexturePaintController::setTexturePaintColor(const QColor& c) +{ + if (!c.isValid()) return; + QColor rgb = c; + rgb.setAlpha(255); + if (m_color.rgba() == rgb.rgba()) return; + m_color = rgb; + emit texturePaintChanged(); +} + +void TexturePaintController::setTexturePaintColorHex(const QString& cssColor) +{ + if (cssColor.isEmpty()) return; + QColor c(cssColor); + if (!c.isValid()) return; + setTexturePaintColor(c); +} + +void TexturePaintController::setTexturePaintRadius(double r) +{ + if (r <= 0.0 || qFuzzyCompare(m_radiusUV, r)) return; + m_radiusUV = r; + emit texturePaintChanged(); +} + +void TexturePaintController::setTexturePaintStrength(double s) +{ + const double clamped = std::clamp(s, 0.0, 1.0); + if (qFuzzyCompare(m_strength, clamped)) return; + m_strength = clamped; + emit texturePaintChanged(); +} + +void TexturePaintController::setTexturePaintFalloff(double f) +{ + const double clamped = std::clamp(f, 0.0, 1.0); + if (qFuzzyCompare(m_falloff, clamped)) return; + m_falloff = clamped; + emit texturePaintChanged(); +} + +Ogre::Entity* TexturePaintController::activeEntity() const +{ + auto* edit = EditModeController::instance(); + if (!edit || !edit->isEditModeActive()) + return nullptr; + return edit->editEntity(); +} + +Ogre::TextureUnitState* TexturePaintController::findOrCreateDiffuseTextureUnit(Ogre::Entity* entity) +{ + if (!entity || entity->getNumSubEntities() == 0) return nullptr; + auto* subEnt = entity->getSubEntity(0); + if (!subEnt) return nullptr; + Ogre::MaterialPtr mat = subEnt->getMaterial(); + if (!mat || mat->getNumTechniques() == 0) return nullptr; + auto* tech = mat->getTechnique(0); + if (!tech || tech->getNumPasses() == 0) return nullptr; + auto* pass = tech->getPass(0); + if (!pass) return nullptr; + if (pass->getNumTextureUnitStates() > 0) + return pass->getTextureUnitState(0); + return pass->createTextureUnitState(); +} + +bool TexturePaintController::ensurePaintableTexture(int resolution) +{ + auto* entity = activeEntity(); + if (!entity) { + m_sessionEntity = nullptr; + emit sessionChanged(); + return false; + } + + if (m_sessionEntity == entity && m_buffer.width() > 0 && !m_textureName.isEmpty()) + return true; + + // Reset any prior session. + closeSession(); + m_sessionEntity = entity; + + auto* tu = findOrCreateDiffuseTextureUnit(entity); + if (!tu) { + emit sessionChanged(); + return false; + } + + QString existingTex = QString::fromStdString(tu->getTextureName()); + bool loadedExisting = false; + if (!existingTex.isEmpty()) { + auto existing = Ogre::TextureManager::getSingleton().getByName( + existingTex.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + if (existing) { + try { + Ogre::Image img; + existing->convertToImage(img); + const int w = static_cast(img.getWidth()); + const int h = static_cast(img.getHeight()); + if (w > 0 && h > 0) { + m_buffer.resize(w, h); + // PF_BYTE_RGBA == 4 bytes/pixel + Ogre::PixelBox srcBox = img.getPixelBox(); + Ogre::PixelBox dstBox(w, h, 1, Ogre::PF_BYTE_RGBA, m_buffer.data().data()); + Ogre::PixelUtil::bulkPixelConversion(srcBox, dstBox); + m_buffer.clearDirty(); + loadedExisting = true; + } + } catch (const Ogre::Exception&) { + // Fall through to blank buffer. + } + } + } + + if (!loadedExisting) { + const int res = std::max(16, resolution); + m_buffer.resize(res, res); + m_buffer.clear(Ogre::ColourValue::White); + m_buffer.clearDirty(); + } + + static unsigned int s_unique = 0; + QString hint = QStringLiteral("QMEPaint_%1_%2") + .arg(QString::fromStdString(entity->getName())) + .arg(++s_unique); + if (!createOgreTextureFromBuffer(entity, hint)) { + m_buffer = TexturePaintBuffer(); + m_textureName.clear(); + m_sessionEntity = nullptr; + emit sessionChanged(); + return false; + } + + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint session: %1Γ—%2 on %3 (existing tex: %4)") + .arg(m_buffer.width()).arg(m_buffer.height()) + .arg(QString::fromStdString(entity->getName())) + .arg(loadedExisting ? "yes" : "no")); + + emit sessionChanged(); + return true; +} + +bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, const QString& nameHint) +{ + if (!entity || m_buffer.width() <= 0 || m_buffer.height() <= 0) return false; + auto* tu = findOrCreateDiffuseTextureUnit(entity); + if (!tu) return false; + + const std::string texName = nameHint.toStdString(); + const std::string group = Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + try { + auto& tm = Ogre::TextureManager::getSingleton(); + if (auto existing = tm.getByName(texName, group)) + tm.remove(existing); + m_ogreTexture = tm.createManual( + texName, group, Ogre::TEX_TYPE_2D, + m_buffer.width(), m_buffer.height(), 0, + Ogre::PF_BYTE_RGBA, Ogre::TU_DYNAMIC_WRITE_ONLY); + if (!m_ogreTexture) return false; + // Initial upload + auto buf = m_ogreTexture->getBuffer(); + if (!buf) return false; + Ogre::PixelBox pb(m_buffer.width(), m_buffer.height(), 1, + Ogre::PF_BYTE_RGBA, m_buffer.data().data()); + buf->blitFromMemory(pb); + tu->setTextureName(texName); + m_textureName = QString::fromStdString(texName); + return true; + } catch (const Ogre::Exception&) { + return false; + } catch (...) { + return false; + } +} + +void TexturePaintController::flushDirtyToOgre() +{ + if (!m_ogreTexture) return; + const auto& dirty = m_buffer.dirtyRect(); + if (dirty.empty()) return; + try { + auto buf = m_ogreTexture->getBuffer(); + if (!buf) return; + const int W = m_buffer.width(); + const int rectW = dirty.width(); + const int rectH = dirty.height(); + // Build a contiguous slice from the dirty rect. + std::vector slice(static_cast(rectW) * static_cast(rectH) * 4u); + const auto& src = m_buffer.data(); + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) + + static_cast(dirty.x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(slice.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); + } + Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, slice.data()); + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + buf->blitFromMemory(pb, dst); + } catch (const Ogre::Exception&) { + // Best-effort β€” skip this flush. + } + m_buffer.clearDirty(); +} + +bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const +{ + auto* edit = EditModeController::instance(); + if (!edit || !edit->isEditModeActive()) return false; + auto* mesh = edit->currentMesh(); + auto* entity = edit->editEntity(); + if (!mesh || !entity || !widget) return false; + + auto* spaceCam = widget->getSpaceCamera(); + auto* camera = spaceCam ? spaceCam->getCamera() : nullptr; + if (!camera) return false; + + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return false; + + const int triHit = edit->hitTestFace(screenPos, camera, vw, vh); + if (triHit < 0) return false; + + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); + + Ogre::SceneNode* node = entity->getParentSceneNode(); + Ogre::Affine3 worldToLocal = node ? node->_getFullTransform().inverse() : Ogre::Affine3::IDENTITY; + Ogre::Vector3 localOrigin = worldToLocal * ray.getOrigin(); + Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); + localDir.normalise(); + + int globalTriOffset = 0; + for (const auto& sub : mesh->subMeshes()) { + for (size_t ti = 0; ti < sub.triangles.size(); ++ti) { + if (globalTriOffset + static_cast(ti) != triHit) continue; + const auto& tri = sub.triangles[ti]; + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + // MΓΆller–Trumbore for explicit barycentric coords. + const Ogre::Vector3 e1 = v1.position - v0.position; + const Ogre::Vector3 e2 = v2.position - v0.position; + const Ogre::Vector3 pvec = localDir.crossProduct(e2); + const Ogre::Real det = e1.dotProduct(pvec); + if (std::abs(det) < 1e-8f) return false; + const Ogre::Real invDet = 1.0f / det; + const Ogre::Vector3 tvec = localOrigin - v0.position; + const Ogre::Real u = tvec.dotProduct(pvec) * invDet; + if (u < 0.0f || u > 1.0f) return false; + const Ogre::Vector3 qvec = tvec.crossProduct(e1); + const Ogre::Real v = localDir.dotProduct(qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) return false; + // u,v are barycentric coords for v1,v2; w for v0. + const Ogre::Real w = 1.0f - u - v; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) return false; + outUV = v0.uv * w + v1.uv * u + v2.uv * v; + return true; + } + globalTriOffset += static_cast(sub.triangles.size()); + } + return false; +} + +bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_paintEnabled || m_strokeActive) return false; + if (!hasActiveSession()) { + if (!ensurePaintableTexture(m_buffer.width() > 0 ? m_buffer.width() : 1024)) + return false; + } + m_strokeActive = true; + m_strokePreSnapshot = snapshotPixels(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint stroke begin (radius=%1 strength=%2 color=%3)") + .arg(m_radiusUV, 0, 'f', 3) + .arg(m_strength, 0, 'f', 3) + .arg(m_color.name(QColor::HexRgb))); + updateStroke(widget, screenPos); + return true; +} + +void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_strokeActive || !m_paintEnabled) return; + Ogre::Vector2 uv; + if (!hitTestUV(screenPos, widget, uv)) return; + const Ogre::ColourValue paint( + static_cast(m_color.redF()), + static_cast(m_color.greenF()), + static_cast(m_color.blueF()), + static_cast(m_color.alphaF())); + const int painted = m_buffer.paintBrush(uv, + static_cast(m_radiusUV), + paint, + static_cast(m_strength), + static_cast(m_falloff)); + if (painted > 0) + flushDirtyToOgre(); +} + +void TexturePaintController::endStroke() +{ + if (!m_strokeActive) return; + m_strokeActive = false; + // If nothing changed, drop the snapshot. + auto after = snapshotPixels(); + if (after == m_strokePreSnapshot) { + m_strokePreSnapshot.clear(); + SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (no changes)"); + return; + } + auto* cmd = new TexturePaintStrokeCommand( + this, + std::move(m_strokePreSnapshot), + std::move(after), + m_buffer.width(), m_buffer.height(), + m_textureName); + UndoManager::getSingleton()->push(cmd); + SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (committed)"); +} + +std::vector TexturePaintController::snapshotPixels() const +{ + return m_buffer.data(); +} + +bool TexturePaintController::savePaintBuffer(const QString& path) const +{ + if (path.isEmpty()) return false; + if (!hasActiveSession()) return false; + QFileInfo fi(path); + QDir().mkpath(fi.absolutePath()); + return m_buffer.save(path.toStdString()); +} + +bool TexturePaintController::loadPaintBuffer(const QString& path) +{ + if (path.isEmpty()) return false; + if (!m_buffer.load(path.toStdString())) return false; + auto* entity = activeEntity(); + if (entity) { + QFileInfo fi(path); + QString hint = QStringLiteral("QMEPaintLoad_%1").arg(fi.completeBaseName()); + m_sessionEntity = entity; + // Re-create the Ogre texture at the new resolution. + m_ogreTexture.reset(); + if (!createOgreTextureFromBuffer(entity, hint)) return false; + } + emit sessionChanged(); + return true; +} + +int TexturePaintController::bakeVertexColorsToTexture(int resolution, + int dilation, + const QString& savePath) +{ + auto* edit = EditModeController::instance(); + if (!edit || !edit->isEditModeActive() || !edit->currentMesh()) + return -1; + const int res = resolution > 0 ? resolution + : (m_buffer.width() > 0 ? m_buffer.width() : 1024); + VertexColorBaker::Options opts; + opts.resolution = res; + opts.dilationPixels = std::max(0, dilation); + const int painted = VertexColorBaker::bake(*edit->currentMesh(), m_buffer, opts); + + // Push the baked buffer to a fresh Ogre texture so the viewport sees it. + auto* entity = edit->editEntity(); + if (entity) { + m_sessionEntity = entity; + m_ogreTexture.reset(); + static unsigned int s_bakeUnique = 0; + QString hint = QStringLiteral("QMEBake_%1_%2") + .arg(QString::fromStdString(entity->getName())) + .arg(++s_bakeUnique); + createOgreTextureFromBuffer(entity, hint); + } + if (!savePath.isEmpty()) + m_buffer.save(savePath.toStdString()); + + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Vertexβ†’Texture bake: %1Γ—%1 (%2 pixels, dilation=%3)") + .arg(res).arg(painted).arg(opts.dilationPixels)); + + emit sessionChanged(); + return painted; +} + +void TexturePaintController::applyPixelSnapshot(const std::vector& pixels) +{ + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) return; + if (pixels.size() != m_buffer.data().size()) return; + std::memcpy(m_buffer.data().data(), pixels.data(), pixels.size()); + m_buffer.markDirty(0, 0, m_buffer.width(), m_buffer.height()); + flushDirtyToOgre(); +} + +void TexturePaintController::closeSession() +{ + if (m_strokeActive) endStroke(); + if (m_ogreTexture) { + try { + Ogre::TextureManager::getSingleton().remove(m_ogreTexture); + } catch (...) {} + m_ogreTexture.reset(); + } + m_buffer = TexturePaintBuffer(); + m_textureName.clear(); + m_sessionEntity = nullptr; + m_strokePreSnapshot.clear(); + emit sessionChanged(); +} diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h new file mode 100644 index 000000000..a050599f9 --- /dev/null +++ b/src/TexturePaintController.h @@ -0,0 +1,200 @@ +#ifndef TEXTUREPAINTCONTROLLER_H +#define TEXTUREPAINTCONTROLLER_H + +#include "TexturePaintBuffer.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +class OgreWidget; + +namespace Ogre { +class Entity; +class Pass; +class TextureUnitState; +} // namespace Ogre + +/** + * @brief QML_SINGLETON managing texture painting on the active edit-mode entity. + * + * Mirrors the EditModeController vertex-paint flow: enable a mode, hook + * into the existing TransformOperator mouse pipeline, hit-test a ray + * against the EditableMesh, interpolate UV from the hit triangle, and + * paint into a CPU-side TexturePaintBuffer. Each stroke uploads only + * the dirty rect to a live Ogre::Texture so the viewport sees changes + * with no full re-upload. + * + * Save / load round-trips the buffer to PNG (or any QImage-supported + * format). Bake builds the same buffer from EditableMesh vertex colors + * and is exposed as a Q_INVOKABLE so the QML "Bake" button hits the + * same code path as the CLI. + * + * The controller does **not** own the Ogre::Texture once it has been + * created β€” Ogre's TextureManager retains ownership, and the buffer + * is a CPU-side mirror. Closing edit mode flushes the buffer to the + * texture one last time and resets the session. + */ +class TexturePaintController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool texturePaintEnabled READ texturePaintEnabled WRITE setTexturePaintEnabled NOTIFY texturePaintChanged) + Q_PROPERTY(QColor texturePaintColor READ texturePaintColor WRITE setTexturePaintColor NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintRadius READ texturePaintRadius WRITE setTexturePaintRadius NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintStrength READ texturePaintStrength WRITE setTexturePaintStrength NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintFalloff READ texturePaintFalloff WRITE setTexturePaintFalloff NOTIFY texturePaintChanged) + Q_PROPERTY(int textureResolution READ textureResolution NOTIFY sessionChanged) + Q_PROPERTY(QString currentTextureName READ currentTextureName NOTIFY sessionChanged) + Q_PROPERTY(bool hasActiveSession READ hasActiveSession NOTIFY sessionChanged) + +public: + static TexturePaintController* instance(); + static TexturePaintController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// @name Paint-mode toggle (mirrors vertex-paint toggle on EditMode) + /// @{ + bool texturePaintEnabled() const { return m_paintEnabled; } + void setTexturePaintEnabled(bool enabled); + /// @} + + /// @name Brush parameters + /// @{ + QColor texturePaintColor() const { return m_color; } + void setTexturePaintColor(const QColor& c); + Q_INVOKABLE void setTexturePaintColorHex(const QString& cssColor); + + double texturePaintRadius() const { return m_radiusUV; } + void setTexturePaintRadius(double r); + + double texturePaintStrength() const { return m_strength; } + void setTexturePaintStrength(double s); + + double texturePaintFalloff() const { return m_falloff; } + void setTexturePaintFalloff(double f); + /// @} + + /// @name Session state + /// @{ + int textureResolution() const { return m_buffer.width(); } + QString currentTextureName() const { return m_textureName; } + bool hasActiveSession() const { return m_buffer.width() > 0 && !m_textureName.isEmpty(); } + /// @} + + /// @name Stroke API (called from TransformOperator mouse pipeline) + /// @{ + bool beginStroke(OgreWidget* widget, const QPoint& screenPos); + void updateStroke(OgreWidget* widget, const QPoint& screenPos); + void endStroke(); + /// @} + + /** + * @brief Create or attach a paintable texture for the active edit entity. + * + * If the entity's first submesh material has an existing diffuse + * texture, that texture is loaded into the paint buffer. Otherwise + * a blank `resolution`x`resolution` opaque-white buffer is created + * and bound under a generated texture name (`QMEPaint_`). + * + * @return true if a paint session is now active. + */ + Q_INVOKABLE bool ensurePaintableTexture(int resolution = 1024); + + /** + * @brief Save the current paint buffer to disk. + * + * @param path Absolute path. Extension determines format (PNG by default). + * @return true on success. + */ + Q_INVOKABLE bool savePaintBuffer(const QString& path) const; + + /** + * @brief Load an image into the paint buffer (replaces contents) and + * binds it as the active texture on the entity's first submesh. + */ + Q_INVOKABLE bool loadPaintBuffer(const QString& path); + + /** + * @brief Bake the active EditableMesh's vertex colors into the texture buffer. + * + * @param resolution Square output resolution (defaults to current + * buffer size if non-zero, else 1024). + * @param dilation Edge dilation in pixels after rasterization. + * @param savePath Optional path β€” if non-empty, the result is also + * written to disk. + * @return Pixel count rasterized (before dilation), or -1 on failure. + */ + Q_INVOKABLE int bakeVertexColorsToTexture(int resolution = 0, + int dilation = 4, + const QString& savePath = QString()); + + /// @brief End the session β€” release the paint buffer. + Q_INVOKABLE void closeSession(); + + /// Read-only access for tests. + const TexturePaintBuffer& buffer() const { return m_buffer; } + TexturePaintBuffer& mutableBuffer() { return m_buffer; } + + /// Internal: replace the buffer pixel data and re-upload to the + /// live Ogre texture. Used by the undo command. Width/height must + /// match the current buffer. + void applyPixelSnapshot(const std::vector& pixels); + +signals: + void texturePaintChanged(); + void sessionChanged(); + +private: + explicit TexturePaintController(QObject* parent = nullptr); + ~TexturePaintController() override; + + /// Try to look up the active edit-mode entity from EditModeController. + /// Returns nullptr if no edit session is active. + Ogre::Entity* activeEntity() const; + + /// Find/create the diffuse texture unit on the entity's first submesh. + Ogre::TextureUnitState* findOrCreateDiffuseTextureUnit(Ogre::Entity* entity); + + /// Hit-test screen position against the active editable mesh and recover + /// the barycentric-interpolated UV at the hit point. Returns false on miss. + bool hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const; + + /// Allocate a new manual Ogre::Texture with current buffer dimensions + /// and bind it onto the entity's first submesh material. + bool createOgreTextureFromBuffer(Ogre::Entity* entity, const QString& nameHint); + + /// Upload buffer.dirtyRect() into the live Ogre texture and clear it. + void flushDirtyToOgre(); + + /// One-time deep-copy snapshot of pixel buffer for undo. + std::vector snapshotPixels() const; + + bool m_paintEnabled = false; + QColor m_color = QColor(255, 0, 0, 255); + double m_radiusUV = 0.05; // 5% of UV space + double m_strength = 0.75; + double m_falloff = 0.5; + + TexturePaintBuffer m_buffer; + QString m_textureName; + Ogre::TexturePtr m_ogreTexture; + Ogre::Entity* m_sessionEntity = nullptr; + + bool m_strokeActive = false; + std::vector m_strokePreSnapshot; // for undo + static TexturePaintController* s_instance; +}; + +#endif // TEXTUREPAINTCONTROLLER_H diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index aca60dc08..add0af004 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -25,6 +25,7 @@ #include "commands/BoneTransformCommand.h" #include "BoneDragRelease.h" #include "EditModeController.h" +#include "TexturePaintController.h" #include "AnimationControlController.h" #include "PropertiesPanelController.h" #include "SkeletonDebug.h" @@ -1014,6 +1015,16 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) // Paint mode on: do not start box / component selection on miss. return; } + auto* texPaint = TexturePaintController::instance(); + if (texPaint->texturePaintEnabled()) { + if (texPaint->beginStroke(m_pActiveWidget, e->pos())) { + mTexturePaintDragActive = true; + SentryReporter::addBreadcrumb("ui.action", "Texture paint: stroke begin"); + return; + } + // Texture paint mode on: don't start box selection on miss. + return; + } mScreenStart = e->pos(); m_pSelectionBox->clear(); m_pSelectionBox->setVisible(true); @@ -1266,6 +1277,13 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) editCtrl->updateVertexPaintPreview(m_pActiveWidget, e->pos()); } + // Texture paint drag: update on every move while LMB is held. + if (mTexturePaintDragActive && editCtrl->isEditModeActive() + && (e->buttons() & Qt::LeftButton) && m_pActiveWidget) + { + TexturePaintController::instance()->updateStroke(m_pActiveWidget, e->pos()); + } + // Knife hover preview: cheap to update on every move while the session // is active, and draws the ghost segment from the last confirmed // point to the cursor. Does not consume the event β€” other handlers @@ -1791,6 +1809,13 @@ void TransformOperator::mouseReleaseEvent(QMouseEvent *e) return; } + if (mTexturePaintDragActive && e->button() == Qt::LeftButton) { + TexturePaintController::instance()->endStroke(); + mTexturePaintDragActive = false; + SentryReporter::addBreadcrumb("ui.action", "Texture paint: stroke end"); + return; + } + // End bevel drag but keep the session open (user can re-grab or click // elsewhere to commit). if (mBevelDragActive && e->button() == Qt::LeftButton) { diff --git a/src/TransformOperator.h b/src/TransformOperator.h index e2b932776..94a4b8a31 100755 --- a/src/TransformOperator.h +++ b/src/TransformOperator.h @@ -221,6 +221,7 @@ public slots: // Vertex paint drag state (edit mode only). bool mVertexPaintDragActive = false; + bool mTexturePaintDragActive = false; // Bone-gizmo drag state. Active when a bone is the current selection // (per AnimationControlController::selectedBonePtr) and the user diff --git a/src/VertexColorBaker.cpp b/src/VertexColorBaker.cpp new file mode 100644 index 000000000..ecb891cf6 --- /dev/null +++ b/src/VertexColorBaker.cpp @@ -0,0 +1,184 @@ +#include "VertexColorBaker.h" + +#include +#include + +namespace { + +inline float edgeFn(const Ogre::Vector2& a, const Ogre::Vector2& b, const Ogre::Vector2& c) +{ + return (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x); +} + +} // namespace + +int VertexColorBaker::rasterizeTriangle(TexturePaintBuffer& buffer, + const Ogre::Vector2& uv0, + const Ogre::Vector2& uv1, + const Ogre::Vector2& uv2, + const Ogre::ColourValue& c0, + const Ogre::ColourValue& c1, + const Ogre::ColourValue& c2) +{ + const int W = buffer.width(); + const int H = buffer.height(); + if (W <= 0 || H <= 0) return 0; + + auto toPix = [&](const Ogre::Vector2& uv) { + return Ogre::Vector2(uv.x * W, (1.0f - uv.y) * H); + }; + + const Ogre::Vector2 p0 = toPix(uv0); + const Ogre::Vector2 p1 = toPix(uv1); + const Ogre::Vector2 p2 = toPix(uv2); + + const float minX = std::min({p0.x, p1.x, p2.x}); + const float maxX = std::max({p0.x, p1.x, p2.x}); + const float minY = std::min({p0.y, p1.y, p2.y}); + const float maxY = std::max({p0.y, p1.y, p2.y}); + + int x0 = std::max(0, static_cast(std::floor(minX))); + int x1 = std::min(W, static_cast(std::ceil(maxX))); + int y0 = std::max(0, static_cast(std::floor(minY))); + int y1 = std::min(H, static_cast(std::ceil(maxY))); + if (x0 >= x1 || y0 >= y1) return 0; + + const float area = edgeFn(p0, p1, p2); + if (std::abs(area) < 1e-7f) return 0; + const float invArea = 1.0f / area; + const bool flip = area < 0.0f; + + int painted = 0; + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + const Ogre::Vector2 sample(static_cast(x) + 0.5f, static_cast(y) + 0.5f); + float w0 = edgeFn(p1, p2, sample); + float w1 = edgeFn(p2, p0, sample); + float w2 = edgeFn(p0, p1, sample); + if (flip) { w0 = -w0; w1 = -w1; w2 = -w2; } + if (w0 < 0.0f || w1 < 0.0f || w2 < 0.0f) continue; + const float b0 = w0 * (flip ? -invArea : invArea); + const float b1 = w1 * (flip ? -invArea : invArea); + const float b2 = 1.0f - b0 - b1; + const Ogre::ColourValue color( + c0.r * b0 + c1.r * b1 + c2.r * b2, + c0.g * b0 + c1.g * b1 + c2.g * b2, + c0.b * b0 + c1.b * b1 + c2.b * b2, + c0.a * b0 + c1.a * b1 + c2.a * b2); + buffer.setPixel(x, y, color); + ++painted; + } + } + return painted; +} + +int VertexColorBaker::dilate(TexturePaintBuffer& buffer, + std::vector& coverage, + int iterations) +{ + const int W = buffer.width(); + const int H = buffer.height(); + if (W <= 0 || H <= 0 || iterations <= 0) return 0; + if (static_cast(coverage.size()) != W * H) return 0; + + int totalFlipped = 0; + std::vector nextCov(coverage); + auto& pixels = buffer.data(); + std::vector nextPixels(pixels); + + for (int it = 0; it < iterations; ++it) { + int flippedThisPass = 0; + for (int y = 0; y < H; ++y) { + for (int x = 0; x < W; ++x) { + const int idx = y * W + x; + if (coverage[idx]) continue; + // Find first filled neighbor. + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + if (dx == 0 && dy == 0) continue; + const int nx = x + dx; + const int ny = y + dy; + if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + const int nidx = ny * W + nx; + if (!coverage[nidx]) continue; + const size_t srcOff = static_cast(nidx) * 4u; + const size_t dstOff = static_cast(idx) * 4u; + nextPixels[dstOff + 0] = pixels[srcOff + 0]; + nextPixels[dstOff + 1] = pixels[srcOff + 1]; + nextPixels[dstOff + 2] = pixels[srcOff + 2]; + nextPixels[dstOff + 3] = pixels[srcOff + 3]; + nextCov[idx] = 1; + ++flippedThisPass; + dx = 2; // break inner loops + dy = 2; + } + } + } + } + if (flippedThisPass == 0) break; + pixels = nextPixels; + coverage = nextCov; + totalFlipped += flippedThisPass; + } + + if (totalFlipped > 0) + buffer.markDirty(0, 0, W, H); + return totalFlipped; +} + +int VertexColorBaker::bake(const EditableMesh& mesh, TexturePaintBuffer& buffer) +{ + return bake(mesh, buffer, Options{}); +} + +int VertexColorBaker::bake(const EditableMesh& mesh, + TexturePaintBuffer& buffer, + const Options& options) +{ + const int res = std::max(1, options.resolution); + buffer.resize(res, res); + buffer.clear(options.background); + + const auto& subs = mesh.subMeshes(); + if (subs.empty()) return 0; + + std::vector coverage(static_cast(res) * static_cast(res), 0); + + int totalPainted = 0; + for (const auto& sub : subs) { + for (const auto& tri : sub.triangles) { + if (tri.indices[0] >= sub.vertices.size() || + tri.indices[1] >= sub.vertices.size() || + tri.indices[2] >= sub.vertices.size()) + continue; + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) + continue; + Ogre::ColourValue c0 = v0.hasColor ? v0.color : Ogre::ColourValue::White; + Ogre::ColourValue c1 = v1.hasColor ? v1.color : Ogre::ColourValue::White; + Ogre::ColourValue c2 = v2.hasColor ? v2.color : Ogre::ColourValue::White; + totalPainted += rasterizeTriangle(buffer, v0.uv, v1.uv, v2.uv, c0, c1, c2); + } + } + + // Build coverage mask by scanning pixels that differ from background. + if (totalPainted > 0) { + auto& px = buffer.data(); + const uint8_t bgR = static_cast(std::lround(options.background.r * 255.0f)); + const uint8_t bgG = static_cast(std::lround(options.background.g * 255.0f)); + const uint8_t bgB = static_cast(std::lround(options.background.b * 255.0f)); + const uint8_t bgA = static_cast(std::lround(options.background.a * 255.0f)); + for (size_t i = 0; i < coverage.size(); ++i) { + const size_t off = i * 4u; + if (px[off + 0] != bgR || px[off + 1] != bgG || + px[off + 2] != bgB || px[off + 3] != bgA) { + coverage[i] = 1; + } + } + dilate(buffer, coverage, options.dilationPixels); + } + + return totalPainted; +} diff --git a/src/VertexColorBaker.h b/src/VertexColorBaker.h new file mode 100644 index 000000000..326c500c3 --- /dev/null +++ b/src/VertexColorBaker.h @@ -0,0 +1,81 @@ +#ifndef VERTEXCOLORBAKER_H +#define VERTEXCOLORBAKER_H + +#include "EditableMesh.h" +#include "TexturePaintBuffer.h" + +#include +#include +#include + +/** + * @brief Bake EditableMesh vertex colors into a TexturePaintBuffer. + * + * For each triangle, the bake walks UV-space pixels covered by the + * triangle, computes per-pixel barycentric coordinates from the UV + * verts, and writes the barycentric-interpolated vertex color into the + * buffer. The seam-dilation pass then "smears" the rasterized colors + * outward by N pixels to mask UV-island bleed at MIP-map time. + * + * Pure data β€” no Ogre runtime state beyond the math types on + * EditableMesh / TexturePaintBuffer. + */ +class VertexColorBaker +{ +public: + struct Options { + /// Output texture size (square). Buffer will be resized to this. + int resolution = 1024; + /// Pixels of edge-dilation applied after rasterization (0 = none). + /// Each iteration extends rasterized pixels outward by 1 px using + /// 8-neighbor majority sampling. + int dilationPixels = 4; + /// Background color for unrasterized pixels (alpha 0 means + /// transparent β€” but PNG savers will store this exactly, so + /// downstream consumers see a clear seam-mask). + Ogre::ColourValue background = Ogre::ColourValue(1.0f, 1.0f, 1.0f, 0.0f); + }; + + /** + * @brief Bake `mesh` into `outBuffer`. + * + * If `mesh` has no vertex colors and no submeshes are visited, the + * buffer is still resized and cleared to the background color. + * + * @return number of pixels written by rasterization (before dilation). + */ + static int bake(const EditableMesh& mesh, + TexturePaintBuffer& outBuffer, + const Options& options); + + /// Convenience: bake with default options. + static int bake(const EditableMesh& mesh, TexturePaintBuffer& outBuffer); + + /// Standalone rasterizer for a single triangle in UV space. + /// `uv0..uv2` are in [0..1]^2; `c0..c2` are colors at the verts. + /// Updates `outBuffer` and returns the number of pixels written. + static int rasterizeTriangle(TexturePaintBuffer& outBuffer, + const Ogre::Vector2& uv0, + const Ogre::Vector2& uv1, + const Ogre::Vector2& uv2, + const Ogre::ColourValue& c0, + const Ogre::ColourValue& c1, + const Ogre::ColourValue& c2); + + /** + * @brief Dilate rasterized pixels outward by `iterations` pixels. + * + * Reads `coverage` (true = pixel was filled by rasterization, false + * = background), then for every false pixel adjacent to a true + * pixel, copies the first non-background neighbor's color and flips + * coverage. Repeats `iterations` times. + * + * @return number of pixels flipped from background to filled across + * all iterations. + */ + static int dilate(TexturePaintBuffer& buffer, + std::vector& coverage, + int iterations); +}; + +#endif // VERTEXCOLORBAKER_H diff --git a/src/VertexColorBaker_test.cpp b/src/VertexColorBaker_test.cpp new file mode 100644 index 000000000..7eebff7be --- /dev/null +++ b/src/VertexColorBaker_test.cpp @@ -0,0 +1,203 @@ +#include + +#include "EditableMesh.h" +#include "TexturePaintBuffer.h" +#include "VertexColorBaker.h" + +namespace { + +EditableMesh makeUnitTriangleMesh(const Ogre::ColourValue& c0, + const Ogre::ColourValue& c1, + const Ogre::ColourValue& c2) +{ + EditableMesh mesh; + EditableSubMesh sub; + EditableVertex v0; v0.position = {0, 0, 0}; v0.uv = {0.0f, 0.0f}; v0.color = c0; + v0.hasUV = true; v0.hasColor = true; + EditableVertex v1; v1.position = {1, 0, 0}; v1.uv = {1.0f, 0.0f}; v1.color = c1; + v1.hasUV = true; v1.hasColor = true; + EditableVertex v2; v2.position = {0, 1, 0}; v2.uv = {0.0f, 1.0f}; v2.color = c2; + v2.hasUV = true; v2.hasColor = true; + sub.vertices = { v0, v1, v2 }; + EditableTriangle tri; tri.indices[0] = 0; tri.indices[1] = 1; tri.indices[2] = 2; + sub.triangles = { tri }; + mesh.subMeshes() = { sub }; + return mesh; +} + +} // namespace + +TEST(VertexColorBakerTest, RasterizeFlatTriangleSolidColor) +{ + TexturePaintBuffer buf(64, 64); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); // transparent black bg + const int painted = VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::ColourValue::Red, Ogre::ColourValue::Red, Ogre::ColourValue::Red); + EXPECT_GT(painted, 1000); // ~half of 64*64 ~= 2048 + // Pixel near vertex 0 (uv 0,0 β†’ x=0, y=63) should be red. + const auto p0 = buf.pixel(2, 61); + EXPECT_NEAR(p0.r, 1.0f, 0.05f); + EXPECT_NEAR(p0.g, 0.0f, 0.05f); + // Pixel inside the triangle, near center. + const auto pc = buf.pixel(10, 50); + EXPECT_NEAR(pc.r, 1.0f, 0.05f); +} + +TEST(VertexColorBakerTest, RasterizeBarycentricInterpolatesColors) +{ + TexturePaintBuffer buf(128, 128); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); + VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::ColourValue(1.0f, 0.0f, 0.0f, 1.0f), // red at (0,0) + Ogre::ColourValue(0.0f, 1.0f, 0.0f, 1.0f), // green at (1,0) + Ogre::ColourValue(0.0f, 0.0f, 1.0f, 1.0f)); // blue at (0,1) + + // Pixel near v0 (uv 0,0 β†’ x=0, y=127 in V-flipped pixel coords). + // Use small offsets to stay inside the triangle. + int x0=0, y0=0; buf.uvToPixel(Ogre::Vector2(0.02f, 0.02f), x0, y0); + const auto cNearV0 = buf.pixel(x0, y0); + EXPECT_GT(cNearV0.r, 0.85f); + EXPECT_LT(cNearV0.g, 0.15f); + EXPECT_LT(cNearV0.b, 0.15f); + + // Pixel near v1 (uv 1,0). + int x1=0, y1=0; buf.uvToPixel(Ogre::Vector2(0.95f, 0.02f), x1, y1); + const auto cNearV1 = buf.pixel(x1, y1); + EXPECT_LT(cNearV1.r, 0.15f); + EXPECT_GT(cNearV1.g, 0.80f); + EXPECT_LT(cNearV1.b, 0.15f); + + // Pixel near v2 (uv 0,1). + int x2=0, y2=0; buf.uvToPixel(Ogre::Vector2(0.02f, 0.95f), x2, y2); + const auto cNearV2 = buf.pixel(x2, y2); + EXPECT_LT(cNearV2.r, 0.15f); + EXPECT_LT(cNearV2.g, 0.15f); + EXPECT_GT(cNearV2.b, 0.80f); +} + +TEST(VertexColorBakerTest, RasterizeDegenerateTriangleIsNoop) +{ + TexturePaintBuffer buf(32, 32); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); + // Collinear points (uv-space line, not a triangle). + const int painted = VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(0.5f, 0.5f), + Ogre::Vector2(1.0f, 1.0f), + Ogre::ColourValue::Red, + Ogre::ColourValue::Green, + Ogre::ColourValue::Blue); + EXPECT_EQ(painted, 0); +} + +TEST(VertexColorBakerTest, RasterizeFlippedWindingStillCoversTriangle) +{ + TexturePaintBuffer buf(64, 64); + buf.clear(Ogre::ColourValue(0, 0, 0, 0)); + // CCW vs CW: the rasterizer should handle both consistently. + const int paintedCcw = VertexColorBaker::rasterizeTriangle( + buf, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::ColourValue::Red, Ogre::ColourValue::Red, Ogre::ColourValue::Red); + EXPECT_GT(paintedCcw, 100); + + TexturePaintBuffer buf2(64, 64); + buf2.clear(Ogre::ColourValue(0, 0, 0, 0)); + // Same triangle, swapped winding. + const int paintedCw = VertexColorBaker::rasterizeTriangle( + buf2, + Ogre::Vector2(0.0f, 0.0f), + Ogre::Vector2(0.0f, 1.0f), + Ogre::Vector2(1.0f, 0.0f), + Ogre::ColourValue::Red, Ogre::ColourValue::Red, Ogre::ColourValue::Red); + // Should cover the same number of pixels (within rounding). + EXPECT_NEAR(paintedCw, paintedCcw, paintedCcw / 10); +} + +TEST(VertexColorBakerTest, BakeProducesNonEmptyOutput) +{ + EditableMesh mesh = makeUnitTriangleMesh( + Ogre::ColourValue::Red, + Ogre::ColourValue::Green, + Ogre::ColourValue::Blue); + TexturePaintBuffer buf; + VertexColorBaker::Options opts; + opts.resolution = 128; + opts.dilationPixels = 0; + const int painted = VertexColorBaker::bake(mesh, buf, opts); + EXPECT_GT(painted, 1000); + EXPECT_EQ(buf.width(), 128); + EXPECT_EQ(buf.height(), 128); +} + +TEST(VertexColorBakerTest, BakeDefaultsToWhiteBackground) +{ + EditableMesh mesh; // empty mesh + TexturePaintBuffer buf; + VertexColorBaker::Options opts; + opts.resolution = 64; + opts.background = Ogre::ColourValue(0.25f, 0.25f, 0.25f, 1.0f); + const int painted = VertexColorBaker::bake(mesh, buf, opts); + EXPECT_EQ(painted, 0); + EXPECT_EQ(buf.width(), 64); + // All pixels should be the background color. + const auto p = buf.pixel(0, 0); + EXPECT_NEAR(p.r, 0.25f, 0.02f); + EXPECT_NEAR(p.g, 0.25f, 0.02f); +} + +TEST(VertexColorBakerTest, DilationExpandsRasterizedRegion) +{ + EditableMesh mesh = makeUnitTriangleMesh( + Ogre::ColourValue::Red, + Ogre::ColourValue::Red, + Ogre::ColourValue::Red); + + TexturePaintBuffer bufNoDilate; + VertexColorBaker::Options optsNo; + optsNo.resolution = 128; + optsNo.dilationPixels = 0; + optsNo.background = Ogre::ColourValue(0, 0, 0, 0); + const int paintedNo = VertexColorBaker::bake(mesh, bufNoDilate, optsNo); + + TexturePaintBuffer bufDilate; + VertexColorBaker::Options optsYes = optsNo; + optsYes.dilationPixels = 6; + const int paintedYes = VertexColorBaker::bake(mesh, bufDilate, optsYes); + + // Count red pixels in each. + auto countRed = [](const TexturePaintBuffer& b) { + int n = 0; + for (int y = 0; y < b.height(); ++y) + for (int x = 0; x < b.width(); ++x) + if (b.pixel(x, y).r > 0.5f) ++n; + return n; + }; + const int nNo = countRed(bufNoDilate); + const int nYes = countRed(bufDilate); + EXPECT_GT(nYes, nNo) << "Dilation must expand the rasterized region"; + EXPECT_EQ(paintedNo, paintedYes) << "Rasterized count (before dilation) should match"; +} + +TEST(VertexColorBakerTest, BakeConvenienceOverloadUsesDefaults) +{ + EditableMesh mesh = makeUnitTriangleMesh( + Ogre::ColourValue::White, + Ogre::ColourValue::White, + Ogre::ColourValue::White); + TexturePaintBuffer buf; + const int painted = VertexColorBaker::bake(mesh, buf); + EXPECT_GT(painted, 100000); // 1024x1024 default has ~half-million rasterized pixels for this tri + EXPECT_EQ(buf.width(), 1024); +} diff --git a/src/main.cpp b/src/main.cpp index efe205ae3..82ddb0aa1 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -91,7 +91,7 @@ int main(int argc, char *argv[]) || arg == "normal-from-height" || arg == "memory" || arg == "analyze" || arg == "vertex-cache" || arg == "decimate" || arg == "atlas" || arg == "atlas-apply" - || arg == "optimize") + || arg == "optimize" || arg == "bake-vertex-colors") cliMode = true; break; // first non-flag arg determines mode } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 46ab056ad..e0efaa067 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -71,6 +71,7 @@ #include "WelcomeScreenController.h" #include "AssetBrowserController.h" #include "EditModeController.h" +#include "TexturePaintController.h" #include "EditorModeController.h" #include #include @@ -531,6 +532,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return EditModeController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "TexturePaintController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return TexturePaintController::qmlInstance(engine, nullptr); + }); m_propertiesPanel->setSource(QUrl("qrc:/PropertiesPanel/PropertiesPanel.qml")); if (auto* root = m_propertiesPanel->rootObject()) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 78b3729b8..336b6b554 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,9 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureChannelPacker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureAtlasPacker.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintBuffer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ApplyAtlas.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EmbeddedTextureCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp diff --git a/website/src/data/content.js b/website/src/data/content.js index d98cac696..b472e40a4 100644 --- a/website/src/data/content.js +++ b/website/src/data/content.js @@ -187,6 +187,10 @@ export const highlightFeatures = [ { title: 'MCP / AI agent integration', body: 'Expose pipeline tools through MCP for scripted and agent-driven workflows.' + }, + { + title: 'Paint tools', + body: 'Vertex-color polypaint and BaseColor texture painting on the mesh surface, plus a bake step that writes vertex colors into a UV-space PNG with seam dilation.' } ]; From c9c4b1be9c84fc3ae3d29943e547f840285138a5 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:26:09 -0400 Subject: [PATCH 02/40] feat(paint): standalone EditableMesh, slot picker, brush tools, live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major refactor that unblocks texture paint in Material Mode and adds several requested workflow features. **Fixes the mode-bounce bug.** Texture paint no longer calls EditModeController::enterEditMode() β€” that flipped the workspace to Edit Mode and silently disabled the brush via the Material-Mode visibility hook. TexturePaintController now owns its own EditableMesh built from the active entity (via EditableMesh::loadFromEntity), decoupled from any Edit-Mode UI state. **Texture slot picker** in the Material-Mode Texture Paint panel. Enumerates every diffuse-like TUS (`albedo`, `diffuse_map`, or unnamed first-TUS fallback) on every submesh of the selected entity. A ThemedComboBox lets the user choose which slot to paint. Selection changes are observed via SelectionSet::selectionChanged. **Brush tool modes**: Paint, Erase, Fill, Pick (color picker), Smudge. Five-button row at the top of the panel; the current tool is sticky. Fill and Pick fire once per stroke (single-stamp ops). Smudge tracks previous-stamp UV and blends pixels in the brush direction. **Live preview** of the active paint buffer as a 256Γ—256 data-URI image. Regenerated on every dirty-rect flush so strokes show in the panel in real time. Clicking and dragging on the preview paints directly in UV space via TexturePaintController::beginStrokeUV / updateStrokeUV β€” same brush, same Ogre upload path. **Brush ring overlay on the mesh.** A red ring drawn at the cursor hit point in 3D, plus the same ring driven by 2D-panel hover via a reverse UV β†’ 3D position lookup (findMeshPointForUV). Hovering the texture preview shows where on the model the user is pointing. **Brush radius scaled to mesh size.** The shared toolbar brush radius is in local mesh units; texture paint divides it by the mesh bounding-box extent before applying, so a 0.25 brush is reasonable on both a unit cube and a 100Γ—100 character. **Imported PBR materials**: rebinding logic from the previous commit (walk every TUS named `albedo` / `diffuse_map`) is now slot-aware β€” the chosen slot wins, and only its texture is rebound. Also extracted floodFill onto TexturePaintBuffer as a pure-data helper so it's testable independently. Added Google Tests for erase behavior, hard-edge brush stamp, and flood-fill region/no-op cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 420 +++++++++++----- src/TexturePaintBuffer.cpp | 46 +- src/TexturePaintBuffer.h | 9 + src/TexturePaintBuffer_test.cpp | 71 ++- src/TexturePaintController.cpp | 855 ++++++++++++++++++++++++++++---- src/TexturePaintController.h | 198 ++++++-- src/TransformOperator.cpp | 38 +- src/VertexColorBaker.cpp | 3 +- src/VertexColorBaker_test.cpp | 8 +- src/mainwindow.cpp | 49 +- 10 files changed, 1424 insertions(+), 273 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index a6b1e6a4f..48cfe03b6 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1,7 +1,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import QtQuick.Dialogs import PropertiesPanel 1.0 import AnimationControl 1.0 import EditorMode 1.0 @@ -283,13 +282,23 @@ Rectangle { Component.onCompleted: content = editModeToolsComponent } - // ---- Texture Paint ---- + // ---- Texture Paint (Material mode) ---- + CollapsibleSection { + title: "Paint Brush" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.MaterialMode, + true) + expanded: true + + Component.onCompleted: content = paintBrushComponent + } + CollapsibleSection { title: "Texture Paint" sectionVisible: root.modeToolSectionVisible( - EditorModeController.EditMode, - EditModeController.editModeActive) - expanded: false + EditorModeController.MaterialMode, + true) + expanded: true Component.onCompleted: content = texturePaintComponent } @@ -946,42 +955,177 @@ Rectangle { } } - // ---- Texture Paint Content ---- + // ---- Paint Brush Content (shared by vertex paint + texture paint) ---- Component { - id: texturePaintComponent + id: paintBrushComponent Column { - id: texPaintCol + id: brushCol width: parent ? parent.width : 200 padding: 8 spacing: 6 - property bool paintOn: TexturePaintController.texturePaintEnabled property color brushColor: TexturePaintController.texturePaintColor property real brushRadius: TexturePaintController.texturePaintRadius property real brushStrength: TexturePaintController.texturePaintStrength property real brushFalloff: TexturePaintController.texturePaintFalloff + + Connections { + target: TexturePaintController + function onTexturePaintChanged() { + brushCol.brushColor = TexturePaintController.texturePaintColor + brushCol.brushRadius = TexturePaintController.texturePaintRadius + brushCol.brushStrength = TexturePaintController.texturePaintStrength + brushCol.brushFalloff = TexturePaintController.texturePaintFalloff + } + } + + Text { + width: parent.width - 16 + text: "Shared brush settings for Vertex Paint and Texture Paint. " + + "The toolbar Vertex Paint popup uses these same values." + color: PropertiesPanelController.textColor + font.pixelSize: 10; opacity: 0.7 + wrapMode: Text.Wrap + } + + // Color picker (native dialog) + Row { + spacing: 6 + Text { + text: "Color" + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 70 + } + Rectangle { + width: 28; height: 22; radius: 3 + color: brushCol.brushColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.pickBrushColorInteractive() + } + } + } + + // Radius slider + Row { + spacing: 6 + Text { + text: "Radius"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.02; to: 2.0; stepSize: 0.01 + value: brushCol.brushRadius + onMoved: TexturePaintController.setBrushRadius(value) + } + Text { + text: brushCol.brushRadius.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Strength slider + Row { + spacing: 6 + Text { + text: "Strength"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.0; to: 1.0; stepSize: 0.01 + value: brushCol.brushStrength + onMoved: TexturePaintController.setBrushStrength(value) + } + Text { + text: brushCol.brushStrength.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Falloff slider + Row { + spacing: 6 + Text { + text: "Falloff"; width: 70 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: 140 + from: 0.0; to: 1.0; stepSize: 0.01 + value: brushCol.brushFalloff + onMoved: TexturePaintController.setBrushFalloff(value) + } + Text { + text: brushCol.brushFalloff.toFixed(2) + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + + // ---- Texture Paint Content ---- + Component { + id: texturePaintComponent + + Column { + id: texPaintCol + width: parent ? parent.width : 200 + padding: 8 + spacing: 8 + + property bool paintOn: TexturePaintController.texturePaintEnabled property bool hasSession: TexturePaintController.hasActiveSession property int sessionRes: TexturePaintController.textureResolution + property var slots: TexturePaintController.textureSlots + property int activeSlot: TexturePaintController.activeSlotIndex + property int brushTool: TexturePaintController.brushTool + property string previewUri: TexturePaintController.previewDataUri + // Live hover position in UV space, fed by hoveredUVChanged. + property real hoverU: -1 + property real hoverV: -1 Connections { target: TexturePaintController function onTexturePaintChanged() { texPaintCol.paintOn = TexturePaintController.texturePaintEnabled - texPaintCol.brushColor = TexturePaintController.texturePaintColor - texPaintCol.brushRadius = TexturePaintController.texturePaintRadius - texPaintCol.brushStrength = TexturePaintController.texturePaintStrength - texPaintCol.brushFalloff = TexturePaintController.texturePaintFalloff } function onSessionChanged() { texPaintCol.hasSession = TexturePaintController.hasActiveSession texPaintCol.sessionRes = TexturePaintController.textureResolution } + function onSlotsChanged() { + texPaintCol.slots = TexturePaintController.textureSlots + texPaintCol.activeSlot = TexturePaintController.activeSlotIndex + } + function onPreviewChanged() { + texPaintCol.previewUri = TexturePaintController.previewDataUri + } + function onBrushToolChanged() { + texPaintCol.brushTool = TexturePaintController.brushTool + } + function onHoveredUVChanged(u, v) { + texPaintCol.hoverU = u + texPaintCol.hoverV = v + } } Text { width: parent.width - 16 - text: "Paint directly into a BaseColor texture using mesh UVs." + text: "Paint directly into a BaseColor texture. " + + "The brush color/radius/strength/falloff comes from the " + + "Paint Brush section above (same brush used by the toolbar " + + "Vertex Paint popup)." color: PropertiesPanelController.textColor font.pixelSize: 10; opacity: 0.7 wrapMode: Text.Wrap @@ -1008,18 +1152,155 @@ Rectangle { } } + // Tool selector \u2014 Paint, Erase, Fill, Picker, Smudge + Row { + spacing: 4 + Repeater { + model: [ + { tool: 0, label: "Paint", glyph: "\u270f" }, + { tool: 1, label: "Erase", glyph: "\u232b" }, + { tool: 2, label: "Fill", glyph: "\u29c9" }, + { tool: 3, label: "Pick", glyph: "\u22b0" }, + { tool: 4, label: "Smudge", glyph: "\u223f" } + ] + Rectangle { + width: 52; height: 26; radius: 3 + color: texPaintCol.brushTool === modelData.tool + ? PropertiesPanelController.highlightColor + : (toolMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor) + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.glyph + " " + modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: toolMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.brushTool = modelData.tool + } + } + } + } + + // Texture slot picker \u2014 populated by selection + Row { + spacing: 6 + width: parent.width - 16 + Text { + text: "Slot:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 36 + } + ThemedComboBox { + id: slotCombo + width: 220 + enabled: texPaintCol.slots.length > 0 + model: { + const labels = [] + for (let i = 0; i < texPaintCol.slots.length; ++i) + labels.push(texPaintCol.slots[i].label || ("slot " + i)) + return labels.length === 0 ? ["(no texture slots \u2014 select a mesh)"] : labels + } + currentIndex: Math.max(0, texPaintCol.activeSlot) + onActivated: function(index) { + if (texPaintCol.slots.length > 0) + TexturePaintController.activeSlotIndex = index + } + } + } + // Session info Text { width: parent.width - 16 text: texPaintCol.hasSession ? ("Active texture: " + texPaintCol.sessionRes + "\u00d7" + texPaintCol.sessionRes) - : "No texture session \u2014 click \"Create / Attach Texture\" to start." + : "No texture session \u2014 enable paint or click \"Create / Attach Texture\"." color: texPaintCol.hasSession ? "#60c060" : PropertiesPanelController.textColor font.pixelSize: 10 opacity: texPaintCol.hasSession ? 1.0 : 0.7 wrapMode: Text.Wrap } + // ---- 2D preview / paint surface ---- + // Live image of the paint buffer; clicking and dragging + // paints into the texture in UV space. Crosshair indicator + // mirrors the 3D-mesh hover position. + Rectangle { + width: 256; height: 256 + color: "#222" + border.color: PropertiesPanelController.borderColor + border.width: 1 + visible: texPaintCol.hasSession + + Image { + id: previewImg + anchors.fill: parent + anchors.margins: 1 + source: texPaintCol.previewUri + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + // Bust the cache when source string changes + onSourceChanged: previewImg.update() + } + + // Crosshair indicator at hover UV + Rectangle { + visible: texPaintCol.hoverU >= 0 && texPaintCol.hoverV >= 0 + width: 1; height: parent.height - 2 + color: "#ff3030" + x: 1 + Math.round(texPaintCol.hoverU * (parent.width - 2)) + y: 1 + } + Rectangle { + visible: texPaintCol.hoverU >= 0 && texPaintCol.hoverV >= 0 + width: parent.width - 2; height: 1 + color: "#ff3030" + x: 1 + y: 1 + Math.round(texPaintCol.hoverV * (parent.height - 2)) + } + + MouseArea { + id: paintArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.CrossCursor + property bool dragging: false + + function toUV(mx, my) { + const W = paintArea.width + const H = paintArea.height + return Qt.point(Math.max(0, Math.min(1, mx / W)), + Math.max(0, Math.min(1, my / H))) + } + onPositionChanged: function(m) { + const uv = toUV(m.x, m.y) + if (dragging) { + TexturePaintController.updateStrokeUV(uv.x, uv.y) + } else { + TexturePaintController.setHoveredUV(uv.x, uv.y) + } + } + onExited: TexturePaintController.clearHoveredUV() + onPressed: function(m) { + if (m.button !== Qt.LeftButton) return + const uv = toUV(m.x, m.y) + if (TexturePaintController.beginStrokeUV(uv.x, uv.y)) + dragging = true + } + onReleased: function(m) { + if (dragging) { + TexturePaintController.endStrokeUV() + dragging = false + } + } + } + } + // Action row 1: create, save, load Flow { width: parent.width - 16 @@ -1045,7 +1326,7 @@ Rectangle { MouseArea { id: saveMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor enabled: texPaintCol.hasSession - onClicked: texPaintSaveDialog.open() + onClicked: TexturePaintController.savePaintBufferInteractive() } } @@ -1056,7 +1337,7 @@ Rectangle { Text { anchors.centerIn: parent; text: "Load\u2026"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } MouseArea { id: loadMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor - onClicked: texPaintLoadDialog.open() + onClicked: TexturePaintController.loadPaintBufferInteractive() } } } @@ -1077,111 +1358,6 @@ Rectangle { } } } - - // Brush color picker - Row { - spacing: 6 - Text { - text: "Color" - color: PropertiesPanelController.textColor; font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - width: 70 - } - Rectangle { - width: 28; height: 22; radius: 3 - color: texPaintCol.brushColor - border.color: PropertiesPanelController.borderColor; border.width: 1 - MouseArea { - anchors.fill: parent; cursorShape: Qt.PointingHandCursor - onClicked: texPaintColorDialog.open() - } - } - } - - // Radius slider - Row { - spacing: 6 - Text { - text: "Radius"; width: 70 - color: PropertiesPanelController.textColor; font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - Slider { - width: 140 - from: 0.005; to: 0.5; stepSize: 0.005 - value: texPaintCol.brushRadius - onMoved: TexturePaintController.texturePaintRadius = value - } - Text { - text: texPaintCol.brushRadius.toFixed(3) - color: PropertiesPanelController.textColor; font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - } - - // Strength slider - Row { - spacing: 6 - Text { - text: "Strength"; width: 70 - color: PropertiesPanelController.textColor; font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - Slider { - width: 140 - from: 0.0; to: 1.0; stepSize: 0.01 - value: texPaintCol.brushStrength - onMoved: TexturePaintController.texturePaintStrength = value - } - Text { - text: texPaintCol.brushStrength.toFixed(2) - color: PropertiesPanelController.textColor; font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - } - - // Falloff slider - Row { - spacing: 6 - Text { - text: "Falloff"; width: 70 - color: PropertiesPanelController.textColor; font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - Slider { - width: 140 - from: 0.0; to: 1.0; stepSize: 0.01 - value: texPaintCol.brushFalloff - onMoved: TexturePaintController.texturePaintFalloff = value - } - Text { - text: texPaintCol.brushFalloff.toFixed(2) - color: PropertiesPanelController.textColor; font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - } - - ColorDialog { - id: texPaintColorDialog - title: "Texture paint color" - onAccepted: TexturePaintController.texturePaintColor = selectedColor - } - - FileDialog { - id: texPaintSaveDialog - title: "Save painted texture" - fileMode: FileDialog.SaveFile - nameFilters: ["PNG image (*.png)", "JPEG image (*.jpg *.jpeg)", "TGA image (*.tga)"] - onAccepted: TexturePaintController.savePaintBuffer(selectedFile.toString().replace(/^file:\/\//, '')) - } - - FileDialog { - id: texPaintLoadDialog - title: "Load texture into paint buffer" - fileMode: FileDialog.OpenFile - nameFilters: ["Image files (*.png *.jpg *.jpeg *.tga *.bmp)"] - onAccepted: TexturePaintController.loadPaintBuffer(selectedFile.toString().replace(/^file:\/\//, '')) - } } } diff --git a/src/TexturePaintBuffer.cpp b/src/TexturePaintBuffer.cpp index d33983939..0d75f439b 100644 --- a/src/TexturePaintBuffer.cpp +++ b/src/TexturePaintBuffer.cpp @@ -79,8 +79,9 @@ void TexturePaintBuffer::setPixel(int x, int y, const Ogre::ColourValue& color) void TexturePaintBuffer::uvToPixel(const Ogre::Vector2& uv, int& outX, int& outY) const { + // UV origin = top-left (Ogre + Qt convention). U β†’ X, V β†’ Y, both direct. outX = static_cast(std::floor(uv.x * static_cast(m_width))); - outY = static_cast(std::floor((1.0f - uv.y) * static_cast(m_height))); + outY = static_cast(std::floor(uv.y * static_cast(m_height))); } Ogre::Vector2 TexturePaintBuffer::pixelToUV(int x, int y) const @@ -88,7 +89,7 @@ Ogre::Vector2 TexturePaintBuffer::pixelToUV(int x, int y) const if (m_width <= 0 || m_height <= 0) return Ogre::Vector2::ZERO; const float u = (static_cast(x) + 0.5f) / static_cast(m_width); - const float v = 1.0f - (static_cast(y) + 0.5f) / static_cast(m_height); + const float v = (static_cast(y) + 0.5f) / static_cast(m_height); return Ogre::Vector2(u, v); } @@ -107,7 +108,7 @@ int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, const float radiusXf = radiusUV * static_cast(m_width); const float radiusYf = radiusUV * static_cast(m_height); const float centerXf = uv.x * static_cast(m_width); - const float centerYf = (1.0f - uv.y) * static_cast(m_height); + const float centerYf = uv.y * static_cast(m_height); int x0 = static_cast(std::floor(centerXf - radiusXf)); int x1 = static_cast(std::ceil(centerXf + radiusXf)); @@ -159,6 +160,45 @@ int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, return affected; } +int TexturePaintBuffer::floodFill(int sx, int sy, const Ogre::ColourValue& fill) +{ + if (m_width <= 0 || m_height <= 0) return 0; + if (sx < 0 || sy < 0 || sx >= m_width || sy >= m_height) return 0; + const Ogre::ColourValue seed = pixel(sx, sy); + const float eps = 4.0f / 255.0f; + auto sameColor = [&seed, eps](const Ogre::ColourValue& other) { + return std::abs(other.r - seed.r) <= eps + && std::abs(other.g - seed.g) <= eps + && std::abs(other.b - seed.b) <= eps + && std::abs(other.a - seed.a) <= eps; + }; + if (sameColor(fill)) return 0; + + std::vector> stack; + stack.push_back({sx, sy}); + std::vector visited(static_cast(m_width) * m_height, 0); + int affected = 0; + int tx0 = m_width, tx1 = 0, ty0 = m_height, ty1 = 0; + while (!stack.empty()) { + auto [x, y] = stack.back(); + stack.pop_back(); + if (x < 0 || y < 0 || x >= m_width || y >= m_height) continue; + const size_t idx = static_cast(y) * m_width + x; + if (visited[idx]) continue; + if (!sameColor(pixel(x, y))) continue; + visited[idx] = 1; + setPixel(x, y, fill); + ++affected; + tx0 = std::min(tx0, x); ty0 = std::min(ty0, y); + tx1 = std::max(tx1, x + 1); ty1 = std::max(ty1, y + 1); + stack.push_back({x + 1, y}); + stack.push_back({x - 1, y}); + stack.push_back({x, y + 1}); + stack.push_back({x, y - 1}); + } + return affected; +} + bool TexturePaintBuffer::save(const std::string& path) const { if (m_width <= 0 || m_height <= 0) return false; diff --git a/src/TexturePaintBuffer.h b/src/TexturePaintBuffer.h index 163a6d9b8..241ca46dd 100644 --- a/src/TexturePaintBuffer.h +++ b/src/TexturePaintBuffer.h @@ -59,6 +59,15 @@ class TexturePaintBuffer /// raw `data()` array (e.g. VertexColorBaker dilation). void markDirty(int x0, int y0, int x1, int y1) { expandDirty(x0, y0, x1, y1); } + /** + * @brief Flood-fill connected pixels at (sx, sy) with `fill`. + * + * 4-connected scan, tolerance Ξ΅=4/255 per channel. Stops at any + * pixel whose color differs from the seed. Returns the pixel count + * filled, 0 if the seed already matches `fill`. + */ + int floodFill(int sx, int sy, const Ogre::ColourValue& fill); + /// Read pixel. Out-of-bounds returns black-transparent. Ogre::ColourValue pixel(int x, int y) const; diff --git a/src/TexturePaintBuffer_test.cpp b/src/TexturePaintBuffer_test.cpp index 57f1c1cf4..a6bb592d5 100644 --- a/src/TexturePaintBuffer_test.cpp +++ b/src/TexturePaintBuffer_test.cpp @@ -169,6 +169,7 @@ TEST(TexturePaintBufferTest, PaintBrushClampsToBufferBounds) TEST(TexturePaintBufferTest, UvToPixelRoundTrip) { + // UV origin = top-left (Ogre + Qt convention). TexturePaintBuffer buf(64, 32); int x = 0, y = 0; buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), x, y); @@ -176,13 +177,13 @@ TEST(TexturePaintBufferTest, UvToPixelRoundTrip) EXPECT_EQ(y, 16); buf.uvToPixel(Ogre::Vector2(0.0f, 0.0f), x, y); EXPECT_EQ(x, 0); - EXPECT_EQ(y, 32); // V flipped + EXPECT_EQ(y, 0); buf.uvToPixel(Ogre::Vector2(1.0f, 1.0f), x, y); // 1.0 * 64 = 64 (out of bounds upper) β€” the helper doesn't clamp; that's // the consumer's job. Verify the computed value rather than asserting // in-bounds. EXPECT_EQ(x, 64); - EXPECT_EQ(y, 0); + EXPECT_EQ(y, 32); } TEST(TexturePaintBufferTest, SaveAndLoadRoundTripPreservesPixels) @@ -212,6 +213,72 @@ TEST(TexturePaintBufferTest, LoadOnNonExistentFileFails) EXPECT_FALSE(buf.load("/definitely/does/not/exist/asdf.png")); } +// ---- Erase behavior ---- +// Erase = paintBrush(color={0,0,0,0}) β€” lerps current pixel toward +// transparent. Verify by stamping red, then erasing the same spot. +TEST(TexturePaintBufferTest, EraseStampReducesAlpha) +{ + TexturePaintBuffer buf(32, 32); + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.1f, + Ogre::ColourValue::Red, 1.0f, 0.0f); + int cx = 0, cy = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + EXPECT_NEAR(buf.pixel(cx, cy).a, 1.0f, 0.02f); + // Erase: full strength, hard falloff, transparent black target. + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.1f, + Ogre::ColourValue(0, 0, 0, 0), 1.0f, 0.0f); + EXPECT_NEAR(buf.pixel(cx, cy).a, 0.0f, 0.02f); +} + +// ---- Flood fill ---- +TEST(TexturePaintBufferTest, FloodFillReplacesContiguousRegion) +{ + TexturePaintBuffer buf(8, 8); + // Default is all opaque white. Drop a vertical green stripe down the middle. + for (int y = 0; y < 8; ++y) + buf.setPixel(4, y, Ogre::ColourValue::Green); + buf.clearDirty(); + + // Fill the left half (white) with red starting from (0,0). + const int n = buf.floodFill(0, 0, Ogre::ColourValue::Red); + EXPECT_GT(n, 30); // 8 columns Γ— 4 rows-ish = 32 + EXPECT_NEAR(buf.pixel(0, 0).r, 1.0f, 0.02f); + EXPECT_NEAR(buf.pixel(3, 3).r, 1.0f, 0.02f); + // Green stripe still green (the fill stopped at color boundary). + EXPECT_NEAR(buf.pixel(4, 3).g, 1.0f, 0.02f); + // Right half still white (separated from left by the green stripe). + EXPECT_NEAR(buf.pixel(7, 3).r, 1.0f, 0.02f); + EXPECT_NEAR(buf.pixel(7, 3).g, 1.0f, 0.02f); +} + +TEST(TexturePaintBufferTest, FloodFillSameColorIsNoop) +{ + TexturePaintBuffer buf(4, 4); + buf.clearDirty(); + const int n = buf.floodFill(1, 1, Ogre::ColourValue::White); + EXPECT_EQ(n, 0); +} + +TEST(TexturePaintBufferTest, FloodFillSeedOutOfBoundsReturnsZero) +{ + TexturePaintBuffer buf(4, 4); + EXPECT_EQ(buf.floodFill(-1, 0, Ogre::ColourValue::Red), 0); + EXPECT_EQ(buf.floodFill(0, 99, Ogre::ColourValue::Red), 0); +} + +// ---- Hard-edge fill region replacement (single brush) ---- +TEST(TexturePaintBufferTest, HardBrushReplacesPixelExactly) +{ + TexturePaintBuffer buf(8, 8); + buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.5f, + Ogre::ColourValue::Blue, 1.0f, 0.0f); + int cx = 0, cy = 0; + buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + const auto c = buf.pixel(cx, cy); + EXPECT_NEAR(c.r, 0.0f, 0.02f); + EXPECT_NEAR(c.b, 1.0f, 0.02f); +} + TEST(TexturePaintBufferTest, MarkDirtyExpandsRectExternally) { TexturePaintBuffer buf(16, 16); diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index a70f82f3c..5dcf61c18 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -9,20 +9,30 @@ #include "UndoManager.h" #include "VertexColorBaker.h" +#include +#include +#include +#include #include +#include #include +#include #include +#include +#include #include #include #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -31,6 +41,7 @@ #include #include +#include TexturePaintController* TexturePaintController::s_instance = nullptr; @@ -111,6 +122,23 @@ void TexturePaintController::kill() TexturePaintController::TexturePaintController(QObject* parent) : QObject(parent) { + // Mirror the toolbar brush settings β€” texture paint and vertex paint + // share one source of truth so the user isn't juggling two sets of + // controls. EditModeController owns the canonical values; we just + // forward its change signal so the QML "Texture Paint" panel can + // re-render the live brush preview. + if (auto* em = EditModeController::instance()) { + connect(em, &EditModeController::vertexPaintChanged, + this, &TexturePaintController::texturePaintChanged); + } + + // Refresh the texture slot list whenever the selection changes β€” + // the user expects "select a mesh β†’ see its textures" without any + // explicit refresh action. + if (auto* sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, + this, &TexturePaintController::refreshSlots); + } } TexturePaintController::~TexturePaintController() = default; @@ -119,6 +147,19 @@ void TexturePaintController::setTexturePaintEnabled(bool enabled) { if (m_paintEnabled == enabled) return; m_paintEnabled = enabled; + if (enabled) { + // Texture paint needs a private EditableMesh built from the + // selected entity. NB: we deliberately don't call + // EditModeController::enterEditMode() β€” that would flip the + // workspace mode to EditMode, kick the user out of Material + // Mode, and (via the visibility hooks) silently disable us. + refreshSlots(); + // Make sure a session exists so the first click actually + // paints β€” without this the user clicks, nothing happens, + // they have to find the "Create / Attach Texture" button. + if (!hasActiveSession()) + ensurePaintableTexture(1024); + } if (!enabled && m_strokeActive) endStroke(); SentryReporter::addBreadcrumb("ui.action", @@ -126,58 +167,114 @@ void TexturePaintController::setTexturePaintEnabled(bool enabled) emit texturePaintChanged(); } -void TexturePaintController::setTexturePaintColor(const QColor& c) +void TexturePaintController::setBrushTool(int tool) { - if (!c.isValid()) return; - QColor rgb = c; - rgb.setAlpha(255); - if (m_color.rgba() == rgb.rgba()) return; - m_color = rgb; - emit texturePaintChanged(); + BrushTool t = static_cast(tool); + if (t == m_tool) return; + m_tool = t; + m_smudgeHavePrev = false; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: tool = %1").arg(tool)); + emit brushToolChanged(); +} + +void TexturePaintController::setActiveSlotIndex(int index) +{ + if (index < 0 || index >= m_slots.size()) return; + if (m_activeSlot == index) return; + m_activeSlot = index; + // Switching slots resets the buffer to that slot's texture. + closeSession(); + ensurePaintableTexture(1024); + emit slotsChanged(); } -void TexturePaintController::setTexturePaintColorHex(const QString& cssColor) +QColor TexturePaintController::texturePaintColor() const { - if (cssColor.isEmpty()) return; - QColor c(cssColor); - if (!c.isValid()) return; - setTexturePaintColor(c); + auto* em = EditModeController::instance(); + return em ? em->vertexPaintColor() : QColor(255, 0, 0); } -void TexturePaintController::setTexturePaintRadius(double r) +double TexturePaintController::texturePaintRadius() const { - if (r <= 0.0 || qFuzzyCompare(m_radiusUV, r)) return; - m_radiusUV = r; - emit texturePaintChanged(); + auto* em = EditModeController::instance(); + return em ? em->vertexPaintRadius() : 0.05; } -void TexturePaintController::setTexturePaintStrength(double s) +double TexturePaintController::texturePaintStrength() const { - const double clamped = std::clamp(s, 0.0, 1.0); - if (qFuzzyCompare(m_strength, clamped)) return; - m_strength = clamped; - emit texturePaintChanged(); + auto* em = EditModeController::instance(); + return em ? em->vertexPaintStrength() : 0.75; } -void TexturePaintController::setTexturePaintFalloff(double f) +double TexturePaintController::texturePaintFalloff() const { - const double clamped = std::clamp(f, 0.0, 1.0); - if (qFuzzyCompare(m_falloff, clamped)) return; - m_falloff = clamped; - emit texturePaintChanged(); + auto* em = EditModeController::instance(); + return em ? em->vertexPaintFalloff() : 0.5; } Ogre::Entity* TexturePaintController::activeEntity() const { - auto* edit = EditModeController::instance(); - if (!edit || !edit->isEditModeActive()) - return nullptr; - return edit->editEntity(); + // First selected entity in Material Mode = paint target. Edit Mode + // is no longer involved β€” we keep our own EditableMesh so painting + // works regardless of workspace mode. + auto* sel = SelectionSet::getSingleton(); + if (!sel) return nullptr; + auto entities = sel->getResolvedEntities(); + return entities.isEmpty() ? nullptr : entities.first(); } -Ogre::TextureUnitState* TexturePaintController::findOrCreateDiffuseTextureUnit(Ogre::Entity* entity) +bool TexturePaintController::ensureEditableMesh(Ogre::Entity* entity) +{ + if (!entity) return false; + if (m_paintMeshEntity == entity && m_paintMesh) return true; + auto mesh = std::make_unique(); + if (!mesh->loadFromEntity(entity)) { + m_paintMesh.reset(); + m_paintMeshEntity = nullptr; + return false; + } + m_paintMesh = std::move(mesh); + m_paintMeshEntity = entity; + return true; +} + +Ogre::TextureUnitState* TexturePaintController::findOrCreateActiveTextureUnit(Ogre::Entity* entity) { if (!entity || entity->getNumSubEntities() == 0) return nullptr; + + // If we have a slot model, use the slot's recorded submesh + + // texture name to find the matching TUS. + if (m_activeSlot >= 0 && m_activeSlot < m_slots.size()) { + auto m = m_slots.at(m_activeSlot).toMap(); + const int subIdx = m.value("submesh", -1).toInt(); + const std::string slotName = m.value("slot").toString().toStdString(); + if (subIdx >= 0 && subIdx < static_cast(entity->getNumSubEntities())) { + auto* subEnt = entity->getSubEntity(subIdx); + if (subEnt) { + Ogre::MaterialPtr mat = subEnt->getMaterial(); + if (mat && mat->getNumTechniques() > 0) { + auto* tech = mat->getTechnique(0); + if (tech && tech->getNumPasses() > 0) { + auto* pass = tech->getPass(0); + if (pass) { + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + if (tus->getName() == slotName) + return tus; + } + // Slot named in model but not found on the pass + // (rare β€” model out of sync). Fall through to + // first TUS / create. + } + } + } + } + } + } + + // Fallback: first submesh, first material, first pass, prefer + // canonical diffuse slot names; otherwise first TUS or create. auto* subEnt = entity->getSubEntity(0); if (!subEnt) return nullptr; Ogre::MaterialPtr mat = subEnt->getMaterial(); @@ -186,6 +283,12 @@ Ogre::TextureUnitState* TexturePaintController::findOrCreateDiffuseTextureUnit(O if (!tech || tech->getNumPasses() == 0) return nullptr; auto* pass = tech->getPass(0); if (!pass) return nullptr; + for (unsigned short i = 0; i < pass->getNumTextureUnitStates(); ++i) { + auto* tus = pass->getTextureUnitState(i); + const std::string& n = tus->getName(); + if (n == "albedo" || n == "diffuse_map") + return tus; + } if (pass->getNumTextureUnitStates() > 0) return pass->getTextureUnitState(0); return pass->createTextureUnitState(); @@ -200,6 +303,12 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) return false; } + if (!ensureEditableMesh(entity)) { + // No mesh data β†’ can't UV-hit-test β†’ can't paint. + emit sessionChanged(); + return false; + } + if (m_sessionEntity == entity && m_buffer.width() > 0 && !m_textureName.isEmpty()) return true; @@ -207,7 +316,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) closeSession(); m_sessionEntity = entity; - auto* tu = findOrCreateDiffuseTextureUnit(entity); + auto* tu = findOrCreateActiveTextureUnit(entity); if (!tu) { emit sessionChanged(); return false; @@ -265,6 +374,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) .arg(QString::fromStdString(entity->getName())) .arg(loadedExisting ? "yes" : "no")); + refreshPreviewUri(); emit sessionChanged(); return true; } @@ -272,9 +382,15 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, const QString& nameHint) { if (!entity || m_buffer.width() <= 0 || m_buffer.height() <= 0) return false; - auto* tu = findOrCreateDiffuseTextureUnit(entity); + auto* tu = findOrCreateActiveTextureUnit(entity); if (!tu) return false; + // Snapshot the original texture name on the chosen TUS *before* we + // overwrite anything. We use this to find every other TUS bound to + // the same source texture (e.g. `albedo` + `diffuse_map` aliasing + // on imported PBR materials), so all of them get rebound. + const std::string originalTexName = tu->getTextureName(); + const std::string texName = nameHint.toStdString(); const std::string group = Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; try { @@ -287,13 +403,65 @@ bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, c Ogre::PF_BYTE_RGBA, Ogre::TU_DYNAMIC_WRITE_ONLY); if (!m_ogreTexture) return false; // Initial upload - auto buf = m_ogreTexture->getBuffer(); - if (!buf) return false; + auto pixbuf = m_ogreTexture->getBuffer(); + if (!pixbuf) return false; Ogre::PixelBox pb(m_buffer.width(), m_buffer.height(), 1, Ogre::PF_BYTE_RGBA, m_buffer.data().data()); - buf->blitFromMemory(pb); - tu->setTextureName(texName); + pixbuf->blitFromMemory(pb); m_textureName = QString::fromStdString(texName); + + // Rebind every TUS on every submesh material that points at the + // original texture. Imported PBR materials alias the diffuse + // texture under both `diffuse_map` (TUS 0) and `albedo` (last + // TUS) β€” rebinding only TUS 0 leaves `albedo` pointing at the + // old texture, and whichever slot the renderer samples wins, so + // the user sees no change. Walk every submesh and rebind. + std::set touched; + for (unsigned int se = 0; se < entity->getNumSubEntities(); ++se) { + auto* sub = entity->getSubEntity(se); + if (!sub) continue; + Ogre::MaterialPtr mat = sub->getMaterial(); + if (!mat) continue; + bool changed = false; + for (auto* tech : mat->getTechniques()) { + for (unsigned short pi = 0; pi < tech->getNumPasses(); ++pi) { + auto* p = tech->getPass(pi); + for (unsigned short ti = 0; ti < p->getNumTextureUnitStates(); ++ti) { + auto* tusN = p->getTextureUnitState(ti); + const std::string n = tusN->getName(); + // Match either the original texture name or a + // canonical diffuse slot name. The slot-name + // path catches submeshes whose `albedo`/ + // `diffuse_map` was bound to a different + // texture than the one we sampled β€” every + // diffuse slot still gets the paint texture. + const bool nameMatch = !originalTexName.empty() + && tusN->getTextureName() == originalTexName; + const bool slotMatch = (n == "albedo" || n == "diffuse_map"); + if (nameMatch || slotMatch) { + tusN->setTextureName(texName); + changed = true; + } + } + } + } + if (changed) touched.insert(mat.get()); + } + + // Force RTSS / FFP lighting passes to drop their cached binding + // and re-sample our texture. Without compile()+reload() the + // renderer keeps using whatever sampler the shader was + // generated with, so the new texture stays invisible. + for (auto* mat : touched) { + try { + mat->compile(); + mat->reload(); + } catch (const Ogre::Exception&) { + // Best-effort: a reload failure shouldn't kill the + // paint session β€” the worst case is stale rendering + // until the next material edit. + } + } return true; } catch (const Ogre::Exception&) { return false; @@ -330,15 +498,16 @@ void TexturePaintController::flushDirtyToOgre() // Best-effort β€” skip this flush. } m_buffer.clearDirty(); + // Regenerate the 2D preview after every flush so the texture + // preview panel stays in sync with strokes in real time. + refreshPreviewUri(); } bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const { - auto* edit = EditModeController::instance(); - if (!edit || !edit->isEditModeActive()) return false; - auto* mesh = edit->currentMesh(); - auto* entity = edit->editEntity(); - if (!mesh || !entity || !widget) return false; + if (!m_paintMesh || !m_paintMeshEntity || !widget) return false; + auto* mesh = m_paintMesh.get(); + auto* entity = m_paintMeshEntity; auto* spaceCam = widget->getSpaceCamera(); auto* camera = spaceCam ? spaceCam->getCamera() : nullptr; @@ -348,9 +517,6 @@ bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widg widget->pixelSizeForCameraPicking(vw, vh); if (vw <= 0 || vh <= 0) return false; - const int triHit = edit->hitTestFace(screenPos, camera, vw, vh); - if (triHit < 0) return false; - const Ogre::Real nx = static_cast(screenPos.x()) / vw; const Ogre::Real ny = static_cast(screenPos.y()) / vh; const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); @@ -361,10 +527,14 @@ bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widg Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); localDir.normalise(); - int globalTriOffset = 0; + // Walk every triangle and keep the closest hit. (We don't have + // EditModeController's optimized bbox/octree, but for typical asset + // meshes the linear walk is fine.) + Ogre::Real bestT = std::numeric_limits::infinity(); + Ogre::Vector2 bestUV(0, 0); + bool found = false; for (const auto& sub : mesh->subMeshes()) { for (size_t ti = 0; ti < sub.triangles.size(); ++ti) { - if (globalTriOffset + static_cast(ti) != triHit) continue; const auto& tri = sub.triangles[ti]; const auto& v0 = sub.vertices[tri.indices[0]]; const auto& v1 = sub.vertices[tri.indices[1]]; @@ -374,23 +544,26 @@ bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widg const Ogre::Vector3 e2 = v2.position - v0.position; const Ogre::Vector3 pvec = localDir.crossProduct(e2); const Ogre::Real det = e1.dotProduct(pvec); - if (std::abs(det) < 1e-8f) return false; + if (std::abs(det) < 1e-8f) continue; const Ogre::Real invDet = 1.0f / det; const Ogre::Vector3 tvec = localOrigin - v0.position; const Ogre::Real u = tvec.dotProduct(pvec) * invDet; - if (u < 0.0f || u > 1.0f) return false; + if (u < 0.0f || u > 1.0f) continue; const Ogre::Vector3 qvec = tvec.crossProduct(e1); const Ogre::Real v = localDir.dotProduct(qvec) * invDet; - if (v < 0.0f || u + v > 1.0f) return false; - // u,v are barycentric coords for v1,v2; w for v0. + if (v < 0.0f || u + v > 1.0f) continue; + const Ogre::Real tHit = e2.dotProduct(qvec) * invDet; + if (tHit <= 0.0f || tHit >= bestT) continue; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) continue; const Ogre::Real w = 1.0f - u - v; - if (!v0.hasUV || !v1.hasUV || !v2.hasUV) return false; - outUV = v0.uv * w + v1.uv * u + v2.uv * v; - return true; + bestT = tHit; + bestUV = v0.uv * w + v1.uv * u + v2.uv * v; + found = true; } - globalTriOffset += static_cast(sub.triangles.size()); } - return false; + if (!found) return false; + outUV = bestUV; + return true; } bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& screenPos) @@ -401,12 +574,15 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree return false; } m_strokeActive = true; + m_strokeJustBegan = true; + m_smudgeHavePrev = false; m_strokePreSnapshot = snapshotPixels(); SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint stroke begin (radius=%1 strength=%2 color=%3)") - .arg(m_radiusUV, 0, 'f', 3) - .arg(m_strength, 0, 'f', 3) - .arg(m_color.name(QColor::HexRgb))); + QStringLiteral("Texture paint stroke begin (tool=%1 radius=%2 strength=%3 color=%4)") + .arg(static_cast(m_tool)) + .arg(texturePaintRadius(), 0, 'f', 3) + .arg(texturePaintStrength(), 0, 'f', 3) + .arg(texturePaintColor().name(QColor::HexRgb))); updateStroke(widget, screenPos); return true; } @@ -415,21 +591,147 @@ void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& scre { if (!m_strokeActive || !m_paintEnabled) return; Ogre::Vector2 uv; - if (!hitTestUV(screenPos, widget, uv)) return; - const Ogre::ColourValue paint( - static_cast(m_color.redF()), - static_cast(m_color.greenF()), - static_cast(m_color.blueF()), - static_cast(m_color.alphaF())); - const int painted = m_buffer.paintBrush(uv, - static_cast(m_radiusUV), - paint, - static_cast(m_strength), - static_cast(m_falloff)); - if (painted > 0) + if (!hitTestUV(screenPos, widget, uv)) { + clearHoveredUV(); + return; + } + emit hoveredUVChanged(uv.x, uv.y); + if (applyBrushAtUV(uv)) flushDirtyToOgre(); } +bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) +{ + if (m_buffer.width() <= 0) return false; + // The shared brush radius is in local mesh units. Map it to UV + // space by dividing by the mesh's bounding-box size β€” that's a + // reasonable first approximation when UVs are unwrapped onto a + // [0..1] square. Clamp to [0.005..1.0] so absurd sizes don't + // produce zero-pixel or whole-texture stamps. + float radius = static_cast(texturePaintRadius()); + if (m_paintMesh) { + const auto bbox = m_paintMesh->calculateBounds(); + if (bbox.isFinite()) { + const float meshExtent = bbox.getSize().length() * 0.5f; + if (meshExtent > 0.0f) { + radius = static_cast(texturePaintRadius()) / meshExtent; + } + } + } + radius = std::clamp(radius, 0.005f, 1.0f); + const float strength = static_cast(texturePaintStrength()); + const float falloff = static_cast(texturePaintFalloff()); + + switch (m_tool) { + case ToolPaint: { + const QColor c = texturePaintColor(); + const Ogre::ColourValue paint(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + return m_buffer.paintBrush(uv, radius, paint, strength, falloff) > 0; + } + case ToolErase: { + // Erase = paint transparent. Strength controls how much alpha + // the stamp removes. + const Ogre::ColourValue clear(0.0f, 0.0f, 0.0f, 0.0f); + return m_buffer.paintBrush(uv, radius, clear, strength, falloff) > 0; + } + case ToolFill: { + // Fill is a single-stamp operation β€” apply once per stroke + // start, not on every move. Most paint apps work this way. + // Suppress repeats by only flooding when the stroke just began. + if (!m_strokeJustBegan) return false; + m_strokeJustBegan = false; + return floodFillAtUV(uv); + } + case ToolColorPicker: { + if (!m_strokeJustBegan) return false; + m_strokeJustBegan = false; + pickColorAtUV(uv); + return false; + } + case ToolSmudge: { + // Sample previous stamp's pixels and bias toward them. + if (!m_smudgeHavePrev) { + m_smudgePrev = uv; + m_smudgeHavePrev = true; + return false; + } + // Walk pixels in the brush footprint; for each, sample at + // (uv + (prev - uv) * smudgeAmount) and lerp toward it. + const int W = m_buffer.width(); + const int H = m_buffer.height(); + const float radiusXf = radius * W; + const float radiusYf = radius * H; + const float cxF = uv.x * W; + const float cyF = uv.y * H; + const int x0 = std::max(0, static_cast(std::floor(cxF - radiusXf))); + const int x1 = std::min(W, static_cast(std::ceil (cxF + radiusXf))); + const int y0 = std::max(0, static_cast(std::floor(cyF - radiusYf))); + const int y1 = std::min(H, static_cast(std::ceil (cyF + radiusYf))); + if (x0 >= x1 || y0 >= y1) return false; + const float dxUV = m_smudgePrev.x - uv.x; + const float dyUV = m_smudgePrev.y - uv.y; + const float p = 1.0f + falloff * 3.0f; + const float invRx = 1.0f / std::max(radiusXf, 1e-6f); + const float invRy = 1.0f / std::max(radiusYf, 1e-6f); + bool changed = false; + int touchedX0 = x1, touchedX1 = x0, touchedY0 = y1, touchedY1 = y0; + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + const float dx = (x + 0.5f - cxF) * invRx; + const float dy = (y + 0.5f - cyF) * invRy; + const float r2 = dx * dx + dy * dy; + if (r2 >= 1.0f) continue; + const float w = std::pow(1.0f - r2, p); + const float blend = strength * w; + if (blend <= 0.0f) continue; + const Ogre::Vector2 sampleUV( + (x + 0.5f) / W + dxUV, + (y + 0.5f) / H + dyUV); + int sx = 0, sy = 0; + m_buffer.uvToPixel(sampleUV, sx, sy); + const auto src = m_buffer.pixel(sx, sy); + const auto dst = m_buffer.pixel(x, y); + if (src.a <= 0.0f) continue; + const Ogre::ColourValue blended( + dst.r + (src.r - dst.r) * blend, + dst.g + (src.g - dst.g) * blend, + dst.b + (src.b - dst.b) * blend, + dst.a + (src.a - dst.a) * blend); + m_buffer.setPixel(x, y, blended); + changed = true; + touchedX0 = std::min(touchedX0, x); + touchedY0 = std::min(touchedY0, y); + touchedX1 = std::max(touchedX1, x + 1); + touchedY1 = std::max(touchedY1, y + 1); + } + } + m_smudgePrev = uv; + return changed; + } + } + return false; +} + +bool TexturePaintController::floodFillAtUV(const Ogre::Vector2& uv) +{ + int sx = 0, sy = 0; + m_buffer.uvToPixel(uv, sx, sy); + const QColor c = texturePaintColor(); + const Ogre::ColourValue fill(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + return m_buffer.floodFill(sx, sy, fill) > 0; +} + +void TexturePaintController::pickColorAtUV(const Ogre::Vector2& uv) +{ + int x = 0, y = 0; + m_buffer.uvToPixel(uv, x, y); + const auto c = m_buffer.pixel(x, y); + QColor qc; + qc.setRgbF(c.r, c.g, c.b, 1.0f); + if (auto* em = EditModeController::instance()) + em->setVertexPaintColor(qc); +} + void TexturePaintController::endStroke() { if (!m_strokeActive) return; @@ -482,31 +784,109 @@ bool TexturePaintController::loadPaintBuffer(const QString& path) return true; } +QString TexturePaintController::savePaintBufferInteractive() +{ + if (!hasActiveSession()) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: save dialog skipped (no session)"); + return QString(); + } + QApplication::processEvents(); + QWidget* parent = QApplication::activeWindow(); + const QString path = QFileDialog::getSaveFileName( + parent, + QStringLiteral("Save painted texture"), + QDir::currentPath() + "/paint.png", + QStringLiteral("PNG image (*.png);;JPEG image (*.jpg *.jpeg);;TGA image (*.tga);;BMP image (*.bmp)"), + nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons); + if (path.isEmpty()) return QString(); + if (!savePaintBuffer(path)) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: save failed (%1)").arg(path)); + return QString(); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: saved to %1").arg(path)); + return path; +} + +void TexturePaintController::pickBrushColorInteractive() +{ + auto* em = EditModeController::instance(); + if (!em) return; + QApplication::processEvents(); + QWidget* parent = QApplication::activeWindow(); + const QColor picked = QColorDialog::getColor( + em->vertexPaintColor(), parent, QStringLiteral("Brush color")); + if (picked.isValid()) + em->setVertexPaintColor(picked); +} + +void TexturePaintController::setBrushRadius(double r) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintRadius(r); +} + +void TexturePaintController::setBrushStrength(double s) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintStrength(s); +} + +void TexturePaintController::setBrushFalloff(double f) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintFalloff(f); +} + +void TexturePaintController::setBrushColor(const QColor& c) +{ + if (auto* em = EditModeController::instance()) em->setVertexPaintColor(c); +} + +QString TexturePaintController::loadPaintBufferInteractive() +{ + QApplication::processEvents(); + QWidget* parent = QApplication::activeWindow(); + const QString path = QFileDialog::getOpenFileName( + parent, + QStringLiteral("Load texture into paint buffer"), + QDir::currentPath(), + QStringLiteral("Image files (*.png *.jpg *.jpeg *.tga *.bmp);;All files (*)"), + nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons); + if (path.isEmpty()) return QString(); + if (!loadPaintBuffer(path)) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: load failed (%1)").arg(path)); + return QString(); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: loaded %1").arg(path)); + return path; +} + int TexturePaintController::bakeVertexColorsToTexture(int resolution, int dilation, const QString& savePath) { - auto* edit = EditModeController::instance(); - if (!edit || !edit->isEditModeActive() || !edit->currentMesh()) - return -1; + auto* entity = activeEntity(); + if (!entity) return -1; + if (!ensureEditableMesh(entity)) return -1; const int res = resolution > 0 ? resolution : (m_buffer.width() > 0 ? m_buffer.width() : 1024); VertexColorBaker::Options opts; opts.resolution = res; opts.dilationPixels = std::max(0, dilation); - const int painted = VertexColorBaker::bake(*edit->currentMesh(), m_buffer, opts); + const int painted = VertexColorBaker::bake(*m_paintMesh, m_buffer, opts); + + m_sessionEntity = entity; + m_ogreTexture.reset(); + static unsigned int s_bakeUnique = 0; + const QString hint = QStringLiteral("QMEBake_%1_%2") + .arg(QString::fromStdString(entity->getName())) + .arg(++s_bakeUnique); + createOgreTextureFromBuffer(entity, hint); - // Push the baked buffer to a fresh Ogre texture so the viewport sees it. - auto* entity = edit->editEntity(); - if (entity) { - m_sessionEntity = entity; - m_ogreTexture.reset(); - static unsigned int s_bakeUnique = 0; - QString hint = QStringLiteral("QMEBake_%1_%2") - .arg(QString::fromStdString(entity->getName())) - .arg(++s_bakeUnique); - createOgreTextureFromBuffer(entity, hint); - } if (!savePath.isEmpty()) m_buffer.save(savePath.toStdString()); @@ -514,6 +894,7 @@ int TexturePaintController::bakeVertexColorsToTexture(int resolution, QStringLiteral("Vertexβ†’Texture bake: %1Γ—%1 (%2 pixels, dilation=%3)") .arg(res).arg(painted).arg(opts.dilationPixels)); + refreshPreviewUri(); emit sessionChanged(); return painted; } @@ -536,9 +917,309 @@ void TexturePaintController::closeSession() } catch (...) {} m_ogreTexture.reset(); } + if (m_ringObj && m_paintMeshEntity) { + try { + auto* mgr = m_paintMeshEntity->_getManager(); + if (mgr) { + if (m_ringNode) { + m_ringNode->detachAllObjects(); + mgr->getRootSceneNode()->removeChild(m_ringNode); + mgr->destroySceneNode(m_ringNode); + m_ringNode = nullptr; + } + mgr->destroyManualObject(m_ringObj); + m_ringObj = nullptr; + } + } catch (...) {} + } + m_paintMesh.reset(); + m_paintMeshEntity = nullptr; m_buffer = TexturePaintBuffer(); m_textureName.clear(); m_sessionEntity = nullptr; m_strokePreSnapshot.clear(); + m_previewUri.clear(); + emit previewChanged(); emit sessionChanged(); } + +// --------------------------------------------------------------------------- +// UV-based stroke API (driven by the texture preview panel) +// --------------------------------------------------------------------------- + +bool TexturePaintController::beginStrokeUV(double u, double v) +{ + if (!m_paintEnabled || m_strokeActive) return false; + if (!hasActiveSession()) + if (!ensurePaintableTexture(1024)) return false; + m_strokeActive = true; + m_strokeJustBegan = true; + m_smudgeHavePrev = false; + m_strokePreSnapshot = snapshotPixels(); + emit hoveredUVChanged(u, v); + updateStrokeUV(u, v); + return true; +} + +void TexturePaintController::updateStrokeUV(double u, double v) +{ + if (!m_strokeActive || !m_paintEnabled) return; + const Ogre::Vector2 uv(static_cast(u), static_cast(v)); + emit hoveredUVChanged(u, v); + if (applyBrushAtUV(uv)) + flushDirtyToOgre(); +} + +void TexturePaintController::endStrokeUV() +{ + endStroke(); +} + +void TexturePaintController::setHoveredUV(double u, double v) +{ + emit hoveredUVChanged(u, v); + // Reverse-lookup: find the 3D point on the mesh that maps to this + // UV, then draw the brush ring there. Lets the user "scrub" the + // texture panel and see the corresponding location on the model. + if (!m_paintEnabled || !m_paintMesh || !m_paintMeshEntity) return; + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(Ogre::Vector2(u, v), localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); +} + +void TexturePaintController::clearHoveredUV() +{ + emit hoveredUVChanged(-1.0, -1.0); +} + +// --------------------------------------------------------------------------- +// Texture slot enumeration +// --------------------------------------------------------------------------- + +void TexturePaintController::refreshSlots() +{ + QVariantList newSlots; + auto* entity = activeEntity(); + if (entity) { + for (unsigned int si = 0; si < entity->getNumSubEntities(); ++si) { + auto* subEnt = entity->getSubEntity(si); + if (!subEnt) continue; + Ogre::MaterialPtr mat = subEnt->getMaterial(); + if (!mat || mat->getNumTechniques() == 0) continue; + auto* tech = mat->getTechnique(0); + if (!tech || tech->getNumPasses() == 0) continue; + auto* pass = tech->getPass(0); + if (!pass) continue; + for (unsigned short ti = 0; ti < pass->getNumTextureUnitStates(); ++ti) { + auto* tus = pass->getTextureUnitState(ti); + const std::string& n = tus->getName(); + // Show diffuse-like slots only (paintable BaseColor). + if (!(n.empty() || n == "albedo" || n == "diffuse_map")) + continue; + QVariantMap m; + const QString labelN = QString::fromStdString(n.empty() ? "diffuse" : n); + m["label"] = QStringLiteral("sub %1 β€” %2").arg(si).arg(labelN); + m["submesh"] = static_cast(si); + m["slot"] = QString::fromStdString(n); + m["textureName"] = QString::fromStdString(tus->getTextureName()); + newSlots.append(m); + } + } + } + if (newSlots == m_slots) return; + m_slots = newSlots; + if (m_activeSlot >= m_slots.size()) + m_activeSlot = m_slots.isEmpty() ? -1 : 0; + emit slotsChanged(); +} + +// --------------------------------------------------------------------------- +// Preview URI +// --------------------------------------------------------------------------- + +void TexturePaintController::refreshPreviewUri() +{ + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) { + if (!m_previewUri.isEmpty()) { + m_previewUri.clear(); + emit previewChanged(); + } + return; + } + QImage img(const_cast(m_buffer.data().data()), + m_buffer.width(), m_buffer.height(), + m_buffer.width() * 4, QImage::Format_RGBA8888); + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + img.save(&qbuf, "PNG"); + const QString next = QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(bytes.toBase64()); + if (next != m_previewUri) { + m_previewUri = next; + emit previewChanged(); + } +} + +// --------------------------------------------------------------------------- +// 3D-mesh hover ring overlay +// --------------------------------------------------------------------------- + +void TexturePaintController::updateMeshHover(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_paintEnabled) { clearMeshHover(); return; } + Ogre::Vector2 uv; + if (!hitTestUV(screenPos, widget, uv)) { + clearMeshHover(); + return; + } + emit hoveredUVChanged(uv.x, uv.y); + + // Compute the 3D hit point in local space and a surface normal. + // We piggyback on the same hit-test math: re-fire a ray and find + // the closest triangle (same logic as hitTestUV; we keep it inline + // here to also recover the world-space hit position and normal). + auto* mesh = m_paintMesh.get(); + auto* entity = m_paintMeshEntity; + if (!mesh || !entity) return; + auto* spaceCam = widget->getSpaceCamera(); + auto* camera = spaceCam ? spaceCam->getCamera() : nullptr; + if (!camera) return; + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return; + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); + Ogre::SceneNode* node = entity->getParentSceneNode(); + Ogre::Affine3 worldToLocal = node ? node->_getFullTransform().inverse() : Ogre::Affine3::IDENTITY; + Ogre::Vector3 localOrigin = worldToLocal * ray.getOrigin(); + Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); + localDir.normalise(); + + Ogre::Vector3 hitLocal(0,0,0); + Ogre::Vector3 hitNormal(0,1,0); + Ogre::Real bestT = std::numeric_limits::infinity(); + for (const auto& sub : mesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + const Ogre::Vector3 e1 = v1.position - v0.position; + const Ogre::Vector3 e2 = v2.position - v0.position; + const Ogre::Vector3 pvec = localDir.crossProduct(e2); + const Ogre::Real det = e1.dotProduct(pvec); + if (std::abs(det) < 1e-8f) continue; + const Ogre::Real invDet = 1.0f / det; + const Ogre::Vector3 tvec = localOrigin - v0.position; + const Ogre::Real u = tvec.dotProduct(pvec) * invDet; + if (u < 0.0f || u > 1.0f) continue; + const Ogre::Vector3 qvec = tvec.crossProduct(e1); + const Ogre::Real v = localDir.dotProduct(qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) continue; + const Ogre::Real tHit = e2.dotProduct(qvec) * invDet; + if (tHit <= 0.0f || tHit >= bestT) continue; + bestT = tHit; + hitLocal = localOrigin + localDir * tHit; + hitNormal = e1.crossProduct(e2); + if (!hitNormal.isZeroLength()) hitNormal.normalise(); + } + } + if (bestT == std::numeric_limits::infinity()) { + clearMeshHover(); + return; + } + drawHoverRingAt(hitLocal, hitNormal); +} + +void TexturePaintController::clearMeshHover() +{ + if (m_ringObj) m_ringObj->clear(); + emit hoveredUVChanged(-1.0, -1.0); +} + +bool TexturePaintController::findMeshPointForUV(const Ogre::Vector2& uv, + Ogre::Vector3& outLocal, + Ogre::Vector3& outNormal) const +{ + if (!m_paintMesh) return false; + // Walk every triangle, find the one whose UV-space contains `uv` + // (barycentric test on UV triangle), then interpolate the 3D + // position with those same barycentrics. + for (const auto& sub : m_paintMesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) continue; + // Barycentric coords for `uv` in the UV triangle (v0.uv, v1.uv, v2.uv). + const Ogre::Vector2 e1 = v1.uv - v0.uv; + const Ogre::Vector2 e2 = v2.uv - v0.uv; + const Ogre::Vector2 dp = uv - v0.uv; + const float denom = e1.x * e2.y - e2.x * e1.y; + if (std::abs(denom) < 1e-10f) continue; + const float u = (dp.x * e2.y - e2.x * dp.y) / denom; + const float v = (e1.x * dp.y - dp.x * e1.y) / denom; + const float w = 1.0f - u - v; + const float eps = 1e-4f; + if (u < -eps || v < -eps || w < -eps) continue; + outLocal = v0.position * w + v1.position * u + v2.position * v; + Ogre::Vector3 n = (v1.position - v0.position) + .crossProduct(v2.position - v0.position); + if (!n.isZeroLength()) n.normalise(); + else n = Ogre::Vector3::UNIT_Y; + outNormal = n; + return true; + } + } + return false; +} + +void TexturePaintController::drawHoverRingAt(const Ogre::Vector3& localPos, + const Ogre::Vector3& localNormal) +{ + auto* entity = m_paintMeshEntity; + if (!entity) return; + auto* sceneMgr = entity->_getManager(); + if (!sceneMgr) return; + if (!m_ringNode) m_ringNode = sceneMgr->getRootSceneNode()->createChildSceneNode(); + if (!m_ringObj) { + m_ringObj = sceneMgr->createManualObject("TexturePaint_HoverRing"); + m_ringObj->setDynamic(true); + m_ringObj->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY); + m_ringNode->attachObject(m_ringObj); + } + auto* node = entity->getParentSceneNode(); + if (node) { + m_ringNode->setPosition(node->_getDerivedPosition()); + m_ringNode->setOrientation(node->_getDerivedOrientation()); + m_ringNode->setScale(node->_getDerivedScale()); + } + m_ringObj->clear(); + + Ogre::Vector3 normal = localNormal; + if (normal.squaredLength() < 1e-6f) normal = Ogre::Vector3::UNIT_Y; + Ogre::Vector3 tangent = normal.perpendicular(); + tangent.normalise(); + Ogre::Vector3 bitangent = normal.crossProduct(tangent); + bitangent.normalise(); + + const QColor c = texturePaintColor(); + const Ogre::ColourValue ringCol(c.redF(), c.greenF(), c.blueF(), 0.95f); + constexpr int kSegments = 64; + const Ogre::AxisAlignedBox bbox = m_paintMesh ? m_paintMesh->calculateBounds() + : Ogre::AxisAlignedBox::BOX_NULL; + const Ogre::Real meshScale = bbox.isFinite() ? bbox.getSize().length() * 0.5f : 1.0f; + const float radius = static_cast(texturePaintRadius()) * meshScale * 0.8f; + const Ogre::Vector3 center = localPos + normal * 0.001f; + m_ringObj->begin("EditMode/EdgeSelection", Ogre::RenderOperation::OT_LINE_STRIP); + for (int i = 0; i <= kSegments; ++i) { + const float t = static_cast(i) / kSegments; + const float a = Ogre::Math::TWO_PI * t; + const Ogre::Vector3 p = center + + (tangent * Ogre::Math::Cos(a) + bitangent * Ogre::Math::Sin(a)) * radius; + m_ringObj->position(p); + m_ringObj->colour(ringCol); + } + m_ringObj->end(); +} diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index a050599f9..fc965f957 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include @@ -16,6 +18,7 @@ #include #include +class EditableMesh; class OgreWidget; namespace Ogre { @@ -51,15 +54,41 @@ class TexturePaintController : public QObject QML_SINGLETON Q_PROPERTY(bool texturePaintEnabled READ texturePaintEnabled WRITE setTexturePaintEnabled NOTIFY texturePaintChanged) - Q_PROPERTY(QColor texturePaintColor READ texturePaintColor WRITE setTexturePaintColor NOTIFY texturePaintChanged) - Q_PROPERTY(double texturePaintRadius READ texturePaintRadius WRITE setTexturePaintRadius NOTIFY texturePaintChanged) - Q_PROPERTY(double texturePaintStrength READ texturePaintStrength WRITE setTexturePaintStrength NOTIFY texturePaintChanged) - Q_PROPERTY(double texturePaintFalloff READ texturePaintFalloff WRITE setTexturePaintFalloff NOTIFY texturePaintChanged) + // Brush params are mirrored from EditModeController so the toolbar brush + // popup is the single source of truth β€” read-only here, used by QML for + // live preview text in the Texture Paint section. + Q_PROPERTY(QColor texturePaintColor READ texturePaintColor NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintRadius READ texturePaintRadius NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintStrength READ texturePaintStrength NOTIFY texturePaintChanged) + Q_PROPERTY(double texturePaintFalloff READ texturePaintFalloff NOTIFY texturePaintChanged) Q_PROPERTY(int textureResolution READ textureResolution NOTIFY sessionChanged) Q_PROPERTY(QString currentTextureName READ currentTextureName NOTIFY sessionChanged) Q_PROPERTY(bool hasActiveSession READ hasActiveSession NOTIFY sessionChanged) + // Brush tool β€” paint / erase / fill / picker. + Q_PROPERTY(int brushTool READ brushTool WRITE setBrushTool NOTIFY brushToolChanged) + + // Live preview of the current paint buffer as a data URI, so the QML + // preview panel can render it via Image { source: ... }. Emitted on + // every dirty-rect flush so the preview stays in sync with strokes. + Q_PROPERTY(QString previewDataUri READ previewDataUri NOTIFY previewChanged) + + // Texture slots on the currently-selected entity. Each entry is a + // map: { label, submesh, slot, textureName }. QML reads this to + // populate the slot picker. + Q_PROPERTY(QVariantList textureSlots READ textureSlots NOTIFY slotsChanged) + Q_PROPERTY(int activeSlotIndex READ activeSlotIndex WRITE setActiveSlotIndex NOTIFY slotsChanged) + public: + enum BrushTool { + ToolPaint = 0, ///< Lerp pixels toward brush color. + ToolErase = 1, ///< Paint transparent (alpha 0). Reveals layer below if any. + ToolFill = 2, ///< Flood-fill connected pixels under cursor. + ToolColorPicker = 3, ///< Sample color at hit UV into the brush color. + ToolSmudge = 4, ///< Drag pixels in the brush direction. + }; + Q_ENUM(BrushTool) + static TexturePaintController* instance(); static TexturePaintController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); static void kill(); @@ -70,20 +99,12 @@ class TexturePaintController : public QObject void setTexturePaintEnabled(bool enabled); /// @} - /// @name Brush parameters + /// @name Brush parameters (read-only mirror of EditModeController) /// @{ - QColor texturePaintColor() const { return m_color; } - void setTexturePaintColor(const QColor& c); - Q_INVOKABLE void setTexturePaintColorHex(const QString& cssColor); - - double texturePaintRadius() const { return m_radiusUV; } - void setTexturePaintRadius(double r); - - double texturePaintStrength() const { return m_strength; } - void setTexturePaintStrength(double s); - - double texturePaintFalloff() const { return m_falloff; } - void setTexturePaintFalloff(double f); + QColor texturePaintColor() const; + double texturePaintRadius() const; + double texturePaintStrength() const; + double texturePaintFalloff() const; /// @} /// @name Session state @@ -93,6 +114,41 @@ class TexturePaintController : public QObject bool hasActiveSession() const { return m_buffer.width() > 0 && !m_textureName.isEmpty(); } /// @} + /// @name Brush tool + /// @{ + int brushTool() const { return static_cast(m_tool); } + void setBrushTool(int tool); + /// @} + + /// @name Texture slot enumeration (selection-driven) + /// @{ + QVariantList textureSlots() const { return m_slots; } + int activeSlotIndex() const { return m_activeSlot; } + void setActiveSlotIndex(int index); + /// Recompute the texture slot list from the current selection. + Q_INVOKABLE void refreshSlots(); + /// @} + + /// Preview data URI (PNG, base64) regenerated on every dirty flush. + QString previewDataUri() const { return m_previewUri; } + + /// Paint via a UV coordinate directly (driven by the texture preview + /// panel's mouse area). The preview ↔ 3D mesh hover indicator is + /// driven by emitting hoveredUVChanged at the same time. + Q_INVOKABLE bool beginStrokeUV(double u, double v); + Q_INVOKABLE void updateStrokeUV(double u, double v); + Q_INVOKABLE void endStrokeUV(); + /// Update the "hovered UV" without painting. Drives the brush ring + /// overlay on the 3D mesh from the 2D preview panel. + Q_INVOKABLE void setHoveredUV(double u, double v); + Q_INVOKABLE void clearHoveredUV(); + + /// Hover update from the 3D viewport β€” does a hit-test, emits the + /// hovered UV signal, and draws the brush-ring overlay at the hit + /// point in mesh-local space. + void updateMeshHover(OgreWidget* widget, const QPoint& screenPos); + void clearMeshHover(); + /// @name Stroke API (called from TransformOperator mouse pipeline) /// @{ bool beginStroke(OgreWidget* widget, const QPoint& screenPos); @@ -120,12 +176,32 @@ class TexturePaintController : public QObject */ Q_INVOKABLE bool savePaintBuffer(const QString& path) const; + /// Show a native save dialog and write the paint buffer to the chosen path. + /// Returns the path on success or empty on cancel/failure. + Q_INVOKABLE QString savePaintBufferInteractive(); + /** * @brief Load an image into the paint buffer (replaces contents) and * binds it as the active texture on the entity's first submesh. */ Q_INVOKABLE bool loadPaintBuffer(const QString& path); + /// Show a native open dialog and load the chosen image. Returns the + /// path on success or empty on cancel/failure. + Q_INVOKABLE QString loadPaintBufferInteractive(); + + /// Show a native color picker for the (shared) brush color. Writes + /// back to EditModeController on accept. + Q_INVOKABLE void pickBrushColorInteractive(); + + /// Setters that mirror through to EditModeController so the toolbar + /// brush popup and the Material-mode Paint Brush panel both stay + /// in sync. + Q_INVOKABLE void setBrushRadius(double r); + Q_INVOKABLE void setBrushStrength(double s); + Q_INVOKABLE void setBrushFalloff(double f); + Q_INVOKABLE void setBrushColor(const QColor& c); + /** * @brief Bake the active EditableMesh's vertex colors into the texture buffer. * @@ -155,45 +231,109 @@ class TexturePaintController : public QObject signals: void texturePaintChanged(); void sessionChanged(); + void brushToolChanged(); + void slotsChanged(); + void previewChanged(); + /// Emitted when the mouse hovers over a UV-mapped triangle (from + /// the 3D mesh or from the 2D texture preview panel). u,v in [0..1]; + /// (-1, -1) means "no hover". + void hoveredUVChanged(double u, double v); private: explicit TexturePaintController(QObject* parent = nullptr); ~TexturePaintController() override; - /// Try to look up the active edit-mode entity from EditModeController. - /// Returns nullptr if no edit session is active. + /// Currently-selected entity (or Edit Mode's active entity if Edit + /// Mode happens to be on). Painting no longer requires Edit Mode β€” + /// we keep our own private EditableMesh built from this entity. Ogre::Entity* activeEntity() const; - /// Find/create the diffuse texture unit on the entity's first submesh. - Ogre::TextureUnitState* findOrCreateDiffuseTextureUnit(Ogre::Entity* entity); + /// Ensure the private EditableMesh is built for the active entity. + /// Called on session creation and whenever the selection changes. + bool ensureEditableMesh(Ogre::Entity* entity); - /// Hit-test screen position against the active editable mesh and recover - /// the barycentric-interpolated UV at the hit point. Returns false on miss. + /// Find the texture unit corresponding to the currently-active slot, + /// creating one if no diffuse-like TUS exists on the selected submesh. + Ogre::TextureUnitState* findOrCreateActiveTextureUnit(Ogre::Entity* entity); + + /// Hit-test screen position against the private editable mesh and + /// recover the barycentric-interpolated UV at the hit point. Returns + /// false on miss. bool hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const; /// Allocate a new manual Ogre::Texture with current buffer dimensions - /// and bind it onto the entity's first submesh material. + /// and bind it onto the entity's active slot. bool createOgreTextureFromBuffer(Ogre::Entity* entity, const QString& nameHint); /// Upload buffer.dirtyRect() into the live Ogre texture and clear it. void flushDirtyToOgre(); + /// Regenerate `m_previewUri` from the buffer (PNG, base64). Emits + /// previewChanged when the URI actually changed. + void refreshPreviewUri(); + /// One-time deep-copy snapshot of pixel buffer for undo. std::vector snapshotPixels() const; - bool m_paintEnabled = false; - QColor m_color = QColor(255, 0, 0, 255); - double m_radiusUV = 0.05; // 5% of UV space - double m_strength = 0.75; - double m_falloff = 0.5; + /// Apply the brush stamp at a UV coord using the current tool. + /// Returns true if any pixel changed. + bool applyBrushAtUV(const Ogre::Vector2& uv); + + /// Walk every UV-mapped triangle and return the local-space + /// position+normal at `uv`. Used by the 2D-panel β†’ 3D-mesh hover + /// indicator so the user sees a brush ring on the model when they + /// hover the texture preview. + bool findMeshPointForUV(const Ogre::Vector2& uv, + Ogre::Vector3& outLocal, + Ogre::Vector3& outNormal) const; + + /// Draw the hover ring on the mesh at a given local position + + /// normal. Shared between viewport-driven and panel-driven hover. + void drawHoverRingAt(const Ogre::Vector3& localPos, + const Ogre::Vector3& localNormal); + + /// Flood-fill connected pixels at UV with the brush color. + bool floodFillAtUV(const Ogre::Vector2& uv); + /// Sample the buffer color at a UV and set it as the brush color. + void pickColorAtUV(const Ogre::Vector2& uv); + + bool m_paintEnabled = false; TexturePaintBuffer m_buffer; QString m_textureName; Ogre::TexturePtr m_ogreTexture; Ogre::Entity* m_sessionEntity = nullptr; bool m_strokeActive = false; + bool m_strokeJustBegan = false; ///< Fill/picker tools fire only once per stroke. std::vector m_strokePreSnapshot; // for undo + BrushTool m_tool = ToolPaint; + + // Private EditableMesh β€” built from the active entity so painting + // doesn't depend on the user-facing Edit Mode workspace. + std::unique_ptr m_paintMesh; + Ogre::Entity* m_paintMeshEntity = nullptr; + + // Texture slot model. Populated from the selected entity's + // materials. The active slot drives which TUS the painter writes + // back to and which texture name the QML preview shows. + QVariantList m_slots; + int m_activeSlot = 0; + + // Smudge state: hold the previous stamp's pre-paint sample so the + // next stamp can copy it forward. + Ogre::Vector2 m_smudgePrev = Ogre::Vector2::ZERO; + bool m_smudgeHavePrev = false; + + // Brush-ring overlay on the 3D mesh surface β€” drawn at the + // current hover hit point so the user sees brush size in world + // units while painting. + Ogre::SceneNode* m_ringNode = nullptr; + Ogre::ManualObject* m_ringObj = nullptr; + + // Preview PNG cache. + QString m_previewUri; + static TexturePaintController* s_instance; }; diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index add0af004..f19057b05 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -1002,6 +1002,21 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) return; } + // Texture paint takes priority over selection/box pick: it works + // in Material Mode without entering Edit Mode, so we handle it + // before the Edit-Mode-gated branch below. + { + auto* texPaint = TexturePaintController::instance(); + if (texPaint->texturePaintEnabled() && mTransformState == TS_SELECT) { + if (texPaint->beginStroke(m_pActiveWidget, e->pos())) { + mTexturePaintDragActive = true; + SentryReporter::addBreadcrumb("ui.action", "Texture paint: stroke begin"); + return; + } + // Cursor missed the mesh β€” fall through to normal selection. + } + } + // In edit mode, delegate selection to EditModeController if (EditModeController::instance()->isEditModeActive() && mTransformState == TS_SELECT) { @@ -1015,16 +1030,6 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) // Paint mode on: do not start box / component selection on miss. return; } - auto* texPaint = TexturePaintController::instance(); - if (texPaint->texturePaintEnabled()) { - if (texPaint->beginStroke(m_pActiveWidget, e->pos())) { - mTexturePaintDragActive = true; - SentryReporter::addBreadcrumb("ui.action", "Texture paint: stroke begin"); - return; - } - // Texture paint mode on: don't start box selection on miss. - return; - } mScreenStart = e->pos(); m_pSelectionBox->clear(); m_pSelectionBox->setVisible(true); @@ -1277,11 +1282,16 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) editCtrl->updateVertexPaintPreview(m_pActiveWidget, e->pos()); } - // Texture paint drag: update on every move while LMB is held. - if (mTexturePaintDragActive && editCtrl->isEditModeActive() - && (e->buttons() & Qt::LeftButton) && m_pActiveWidget) + // Texture paint: while LMB held, update stroke; otherwise update + // the hover preview so the user sees the brush ring even before + // clicking. Decoupled from Edit Mode β€” texture paint owns its own + // EditableMesh now. + auto* texPaint = TexturePaintController::instance(); + if (mTexturePaintDragActive && (e->buttons() & Qt::LeftButton) && m_pActiveWidget) { - TexturePaintController::instance()->updateStroke(m_pActiveWidget, e->pos()); + texPaint->updateStroke(m_pActiveWidget, e->pos()); + } else if (texPaint->texturePaintEnabled() && m_pActiveWidget) { + texPaint->updateMeshHover(m_pActiveWidget, e->pos()); } // Knife hover preview: cheap to update on every move while the session diff --git a/src/VertexColorBaker.cpp b/src/VertexColorBaker.cpp index ecb891cf6..98197c0b9 100644 --- a/src/VertexColorBaker.cpp +++ b/src/VertexColorBaker.cpp @@ -24,8 +24,9 @@ int VertexColorBaker::rasterizeTriangle(TexturePaintBuffer& buffer, const int H = buffer.height(); if (W <= 0 || H <= 0) return 0; + // UV origin = top-left (matches TexturePaintBuffer::uvToPixel). auto toPix = [&](const Ogre::Vector2& uv) { - return Ogre::Vector2(uv.x * W, (1.0f - uv.y) * H); + return Ogre::Vector2(uv.x * W, uv.y * H); }; const Ogre::Vector2 p0 = toPix(uv0); diff --git a/src/VertexColorBaker_test.cpp b/src/VertexColorBaker_test.cpp index 7eebff7be..2ca257103 100644 --- a/src/VertexColorBaker_test.cpp +++ b/src/VertexColorBaker_test.cpp @@ -38,12 +38,12 @@ TEST(VertexColorBakerTest, RasterizeFlatTriangleSolidColor) Ogre::Vector2(0.0f, 1.0f), Ogre::ColourValue::Red, Ogre::ColourValue::Red, Ogre::ColourValue::Red); EXPECT_GT(painted, 1000); // ~half of 64*64 ~= 2048 - // Pixel near vertex 0 (uv 0,0 β†’ x=0, y=63) should be red. - const auto p0 = buf.pixel(2, 61); + // Pixel near vertex 0 (uv 0,0 β†’ top-left pixel (0,0)) should be red. + const auto p0 = buf.pixel(2, 2); EXPECT_NEAR(p0.r, 1.0f, 0.05f); EXPECT_NEAR(p0.g, 0.0f, 0.05f); // Pixel inside the triangle, near center. - const auto pc = buf.pixel(10, 50); + const auto pc = buf.pixel(10, 10); EXPECT_NEAR(pc.r, 1.0f, 0.05f); } @@ -60,7 +60,7 @@ TEST(VertexColorBakerTest, RasterizeBarycentricInterpolatesColors) Ogre::ColourValue(0.0f, 1.0f, 0.0f, 1.0f), // green at (1,0) Ogre::ColourValue(0.0f, 0.0f, 1.0f, 1.0f)); // blue at (0,1) - // Pixel near v0 (uv 0,0 β†’ x=0, y=127 in V-flipped pixel coords). + // Pixel near v0 (uv 0,0 β†’ pixel (0,0) since UV origin is top-left). // Use small offsets to stay inside the triangle. int x0=0, y0=0; buf.uvToPixel(Ogre::Vector2(0.02f, 0.02f), x0, y0); const auto cNearV0 = buf.pixel(x0, y0); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e0efaa067..2357e73b0 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1234,7 +1234,8 @@ void MainWindow::initToolBar() vertexPaintButton->setCheckable(true); vertexPaintButton->setIcon(makeVertexPaintBrushIcon()); vertexPaintButton->setIconSize(QSize(18, 18)); - vertexPaintButton->setToolTip(tr("Vertex paint β€” paint on mesh (Select tool). Arrow: brush settings.")); + vertexPaintButton->setToolTip(tr("Paint brush β€” toggles vertex + texture paint together " + "(Material Mode). Arrow: brush settings.")); vertexPaintButton->setFont(topoFont); vertexPaintButton->setStyleSheet(topoBtnStyle); vertexPaintButton->setPopupMode(QToolButton::MenuButtonPopup); @@ -1361,18 +1362,48 @@ void MainWindow::initToolBar() connect(vertexPaintButton, &QToolButton::toggled, this, [this](bool on) { SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Toolbar: Vertex paint %1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); + QStringLiteral("Toolbar: Paint brush %1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); + // One button toggles both vertex and texture paint. They share a + // brush; enabling them together gives the user "the brush is on" + // semantics regardless of whether they're painting vertex colors + // or into a texture. EditModeController::instance()->setVertexPaintEnabled(on); + TexturePaintController::instance()->setTexturePaintEnabled(on); if (on) setTransformState(TransformOperator::TS_SELECT); }); - connect(EditModeController::instance(), &EditModeController::vertexPaintChanged, this, [vertexPaintButton]() { + auto syncPaintBtnChecked = [vertexPaintButton]() { + const bool on = EditModeController::instance()->vertexPaintEnabled() + || TexturePaintController::instance()->texturePaintEnabled(); QSignalBlocker b(vertexPaintButton); - vertexPaintButton->setChecked(EditModeController::instance()->vertexPaintEnabled()); - }); + vertexPaintButton->setChecked(on); + }; + connect(EditModeController::instance(), &EditModeController::vertexPaintChanged, + this, syncPaintBtnChecked); + connect(TexturePaintController::instance(), &TexturePaintController::texturePaintChanged, + this, syncPaintBtnChecked); QAction* vertexPaintAction = ui->objectsToolbar->addWidget(vertexPaintButton); - vertexPaintAction->setObjectName("modeEditVertexPaintAction"); + vertexPaintAction->setObjectName("modeMaterialPaintBrushAction"); + + // Material Mode owns the paint brush (vertex + texture). Hide the + // toolbar button outside Material Mode so it doesn't crowd the + // Edit-mode topology row. + auto refreshPaintBrushVisibility = [vertexPaintAction, vertexPaintButton]() { + const bool material = EditorModeController::instance()->currentMode() + == EditorModeController::MaterialMode; + vertexPaintAction->setVisible(material); + vertexPaintButton->setEnabled(material); + if (!material) { + // Switching out of Material Mode turns the brush off so the + // user doesn't end up painting blindly in another mode. + EditModeController::instance()->setVertexPaintEnabled(false); + TexturePaintController::instance()->setTexturePaintEnabled(false); + } + }; + refreshPaintBrushVisibility(); + connect(EditorModeController::instance(), &EditorModeController::modeChanged, + this, refreshPaintBrushVisibility); // Context-aware visibility + enabled: // - Hidden entirely when NOT in edit mode. @@ -1381,10 +1412,8 @@ void MainWindow::initToolBar() // non-empty selection. auto refreshTopoButtons = [extrudeButton, bevelButton, knifeButton, mergeButton, deleteButton, subdivideButton, fillButton, loopCutButton, convertToQuadsButton, - vertexPaintButton, extrudeAction, bevelAction, knifeAction, mergeAction, deleteAction, - subdivideAction, fillAction, loopCutAction, convertToQuadsAction, - vertexPaintAction]() { + subdivideAction, fillAction, loopCutAction, convertToQuadsAction]() { auto* c = EditModeController::instance(); const bool active = c->isEditModeActive(); extrudeAction->setVisible(active); @@ -1396,7 +1425,6 @@ void MainWindow::initToolBar() fillAction->setVisible(active); loopCutAction->setVisible(active); convertToQuadsAction->setVisible(active); - vertexPaintAction->setVisible(active); if (!active) return; const int mode = c->selectionMode(); // 0 vertex, 1 edge, 2 face const bool hasFaces = c->selectedFaceCount() > 0; @@ -1434,7 +1462,6 @@ void MainWindow::initToolBar() // some quad) still qualify β€” the tri-only submeshes can still // be merged. (CodeRabbit follow-up on PR #347.) convertToQuadsButton->setEnabled(c->canConvertToQuads()); - vertexPaintButton->setEnabled(true); }; refreshTopoButtons(); connect(editCtrlForTopo, &EditModeController::editModeChanged, From 9636f6927d861059bfc2e9bab1ccf6cacf3016f0 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:31:51 -0400 Subject: [PATCH 03/40] test(paint): add controller + flood-fill tolerance coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TexturePaintController_test.cpp: reverse-UV lookup against an in-memory unit triangle (uv (0,0) β†’ vertex 0, etc.) and the brush-tool change-signal contract. - TexturePaintBuffer_test.cpp: flood-fill respects 4/255 per-channel tolerance so a near-white pixel is bridged. Also draw the brush ring on the mesh during active strokes (both screen-space and UV-space drivers), so the user sees the paint location continuously, not just on hover. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintBuffer_test.cpp | 14 +++++ src/TexturePaintController.cpp | 28 +++++++++- src/TexturePaintController_test.cpp | 82 +++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/TexturePaintController_test.cpp diff --git a/src/TexturePaintBuffer_test.cpp b/src/TexturePaintBuffer_test.cpp index a6bb592d5..36c23b8be 100644 --- a/src/TexturePaintBuffer_test.cpp +++ b/src/TexturePaintBuffer_test.cpp @@ -279,6 +279,20 @@ TEST(TexturePaintBufferTest, HardBrushReplacesPixelExactly) EXPECT_NEAR(c.b, 1.0f, 0.02f); } +// ---- floodFill stops at color boundaries with the 4-pixel tolerance ---- +TEST(TexturePaintBufferTest, FloodFillRespectsTolerance) +{ + TexturePaintBuffer buf(4, 4); + // Plant a near-white pixel (slight off-white). With the 4/255 + // tolerance per channel, the fill from a white seed should treat + // it as same-color and fill it too. + buf.setPixel(2, 2, Ogre::ColourValue(254.0f/255.0f, 254.0f/255.0f, + 254.0f/255.0f, 1.0f)); + buf.clearDirty(); + const int n = buf.floodFill(0, 0, Ogre::ColourValue::Red); + EXPECT_EQ(n, 16); // entire 4x4 swept +} + TEST(TexturePaintBufferTest, MarkDirtyExpandsRectExternally) { TexturePaintBuffer buf(16, 16); diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 5dcf61c18..6707e64bf 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -596,6 +596,10 @@ void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& scre return; } emit hoveredUVChanged(uv.x, uv.y); + // Keep the brush-ring overlay tracking the cursor during a stroke. + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(uv, localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); if (applyBrushAtUV(uv)) flushDirtyToOgre(); } @@ -966,6 +970,11 @@ void TexturePaintController::updateStrokeUV(double u, double v) if (!m_strokeActive || !m_paintEnabled) return; const Ogre::Vector2 uv(static_cast(u), static_cast(v)); emit hoveredUVChanged(u, v); + // Update brush-ring overlay on the mesh so the user sees their + // painting location even when driving the brush from the 2D panel. + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(uv, localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); if (applyBrushAtUV(uv)) flushDirtyToOgre(); } @@ -1182,6 +1191,23 @@ void TexturePaintController::drawHoverRingAt(const Ogre::Vector3& localPos, if (!entity) return; auto* sceneMgr = entity->_getManager(); if (!sceneMgr) return; + + // Ensure the hover-line material exists. It's normally created + // when entering Edit Mode (EditModeController::createOverlayMaterials); + // we may need to draw the ring before the user has ever entered + // Edit Mode, so set it up here too. + static const char* kMatName = "TexturePaint/HoverRing"; + auto& matMgr = Ogre::MaterialManager::getSingleton(); + if (!matMgr.getByName(kMatName)) { + auto mat = matMgr.create(kMatName, + Ogre::ResourceGroupManager::INTERNAL_RESOURCE_GROUP_NAME); + auto* pass = mat->getTechnique(0)->getPass(0); + pass->setLightingEnabled(false); + pass->setVertexColourTracking(Ogre::TVC_DIFFUSE); + pass->setDepthCheckEnabled(false); + pass->setDepthWriteEnabled(false); + pass->setLineWidth(2.0f); + } if (!m_ringNode) m_ringNode = sceneMgr->getRootSceneNode()->createChildSceneNode(); if (!m_ringObj) { m_ringObj = sceneMgr->createManualObject("TexturePaint_HoverRing"); @@ -1212,7 +1238,7 @@ void TexturePaintController::drawHoverRingAt(const Ogre::Vector3& localPos, const Ogre::Real meshScale = bbox.isFinite() ? bbox.getSize().length() * 0.5f : 1.0f; const float radius = static_cast(texturePaintRadius()) * meshScale * 0.8f; const Ogre::Vector3 center = localPos + normal * 0.001f; - m_ringObj->begin("EditMode/EdgeSelection", Ogre::RenderOperation::OT_LINE_STRIP); + m_ringObj->begin(kMatName, Ogre::RenderOperation::OT_LINE_STRIP); for (int i = 0; i <= kSegments; ++i) { const float t = static_cast(i) / kSegments; const float a = Ogre::Math::TWO_PI * t; diff --git a/src/TexturePaintController_test.cpp b/src/TexturePaintController_test.cpp new file mode 100644 index 000000000..5a4c538ce --- /dev/null +++ b/src/TexturePaintController_test.cpp @@ -0,0 +1,82 @@ +#include + +#include + +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" +#include "TexturePaintBuffer.h" +#include "TexturePaintController.h" + +#include +#include +#include +#include + +// Reverse UVβ†’3D lookup: ask for UV (0,0) and verify we get the +// position of vertex 0 (which carries UV (0,0)) on the test triangle. +TEST(TexturePaintControllerTest, FindMeshPointForUVHitsCorrectTriangle) +{ + if (!tryInitOgre()) GTEST_SKIP(); + auto* mgr = Manager::getSingleton(); + ASSERT_NE(mgr, nullptr); + auto* scene = mgr->getSceneMgr(); + ASSERT_NE(scene, nullptr); + auto mesh = createInMemoryTriangleMesh("TPC_FindMeshPointForUV"); + auto* entity = scene->createEntity("TPC_TestEntity", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + SelectionSet::getSingleton()->clear(); + SelectionSet::getSingleton()->append(entity); + + auto* ctrl = TexturePaintController::instance(); + ctrl->refreshSlots(); + // Need a paint buffer so `m_paintMesh` is built. + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + + Ogre::Vector3 pos, normal; + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 0.0f), pos, normal)); + EXPECT_NEAR(pos.x, 0.0f, 1e-4); + EXPECT_NEAR(pos.y, 0.0f, 1e-4); + + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(1.0f, 0.0f), pos, normal)); + EXPECT_NEAR(pos.x, 1.0f, 1e-4); + EXPECT_NEAR(pos.y, 0.0f, 1e-4); + + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 1.0f), pos, normal)); + EXPECT_NEAR(pos.x, 0.0f, 1e-4); + EXPECT_NEAR(pos.y, 1.0f, 1e-4); + + // Outside the triangle in UV space β†’ no hit. + EXPECT_FALSE(ctrl->findMeshPointForUV(Ogre::Vector2(0.9f, 0.9f), pos, normal)); + + ctrl->closeSession(); + SelectionSet::getSingleton()->clear(); + scene->getRootSceneNode()->removeAndDestroyChild(node); + scene->destroyEntity(entity); + Ogre::MeshManager::getSingleton().remove(mesh); +} + +TEST(TexturePaintControllerTest, BrushToolDefaultIsPaint) +{ + auto* ctrl = TexturePaintController::instance(); + // Reset to a known value via the public path so test order doesn't + // matter. + ctrl->setBrushTool(TexturePaintController::ToolPaint); + EXPECT_EQ(ctrl->brushTool(), static_cast(TexturePaintController::ToolPaint)); +} + +TEST(TexturePaintControllerTest, SetBrushToolEmitsOnceAndSticks) +{ + auto* ctrl = TexturePaintController::instance(); + ctrl->setBrushTool(TexturePaintController::ToolPaint); + QSignalSpy spy(ctrl, &TexturePaintController::brushToolChanged); + ctrl->setBrushTool(TexturePaintController::ToolErase); + EXPECT_EQ(ctrl->brushTool(), static_cast(TexturePaintController::ToolErase)); + EXPECT_EQ(spy.count(), 1); + // Same value should not re-emit. + ctrl->setBrushTool(TexturePaintController::ToolErase); + EXPECT_EQ(spy.count(), 1); + ctrl->setBrushTool(TexturePaintController::ToolPaint); +} From 8ecf45abce70233d2aff5bee1b4668c2626c1ca9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:33:42 -0400 Subject: [PATCH 04/40] perf(paint): debounce preview refresh + show all paintable slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Debounce previewDataUri regeneration to 60ms intervals. A 1024Γ—1024 PNG + base64 encode is ~150ms of CPU work; doing that on every stroke move made dragging hitchy. Drift between buffer and preview during the debounce window is invisible. - Slot enumeration now includes every non-empty TUS, not just diffuse-like ones. Labelled "sub N β€” slot" so the user can pick which texture to paint when a material has multiple bindings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 26 ++++++++++++++++++-------- src/TexturePaintController.h | 3 +++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 6707e64bf..19b66998b 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -498,9 +499,17 @@ void TexturePaintController::flushDirtyToOgre() // Best-effort β€” skip this flush. } m_buffer.clearDirty(); - // Regenerate the 2D preview after every flush so the texture - // preview panel stays in sync with strokes in real time. - refreshPreviewUri(); + // Debounce the 2D preview refresh β€” encoding a 1024Γ—1024 PNG + + // base64 on every stroke move is ~150ms of CPU work, which makes + // dragging hitchy. Schedule one refresh per ~60ms; the buffer ↔ + // preview drift during that window is invisible to the user. + if (!m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } } bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const @@ -1022,15 +1031,16 @@ void TexturePaintController::refreshSlots() for (unsigned short ti = 0; ti < pass->getNumTextureUnitStates(); ++ti) { auto* tus = pass->getTextureUnitState(ti); const std::string& n = tus->getName(); - // Show diffuse-like slots only (paintable BaseColor). - if (!(n.empty() || n == "albedo" || n == "diffuse_map")) - continue; + const std::string tex = tus->getTextureName(); + // Skip entirely-empty TUSes (no name AND no texture) β€” + // they're usually placeholders. + if (n.empty() && tex.empty()) continue; QVariantMap m; - const QString labelN = QString::fromStdString(n.empty() ? "diffuse" : n); + const QString labelN = QString::fromStdString(n.empty() ? "TUS " + std::to_string(ti) : n); m["label"] = QStringLiteral("sub %1 β€” %2").arg(si).arg(labelN); m["submesh"] = static_cast(si); m["slot"] = QString::fromStdString(n); - m["textureName"] = QString::fromStdString(tus->getTextureName()); + m["textureName"] = QString::fromStdString(tex); newSlots.append(m); } } diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index fc965f957..b0fb74510 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -333,6 +333,9 @@ class TexturePaintController : public QObject // Preview PNG cache. QString m_previewUri; + /// Debounce flag: prevents stroke moves from regenerating the + /// base64 PNG on every dirty flush (expensive at 1024Γ—1024). + bool m_previewRefreshScheduled = false; static TexturePaintController* s_instance; }; From bc365a55ebc65dd20e49088041e350c6a95a6abe Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:34:45 -0400 Subject: [PATCH 05/40] fix(paint): auto-create texture session on selection change When paint is enabled and the user changes selection, transparently ensure a session is set up for the new entity. Without this they had to manually click "Create / Attach Texture" each time. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 19b66998b..02fbf559d 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1016,6 +1016,16 @@ void TexturePaintController::clearHoveredUV() void TexturePaintController::refreshSlots() { + // If paint is enabled and the user just changed selection, try to + // re-establish the session against the new entity so they don't + // have to click "Create / Attach Texture" again. Skip if the + // session is already valid for the current entity. + if (m_paintEnabled) { + auto* e = activeEntity(); + if (e && e != m_sessionEntity && !hasActiveSession()) + ensurePaintableTexture(1024); + } + QVariantList newSlots; auto* entity = activeEntity(); if (entity) { From e60300b1530ebacf290e3b2d8d33a4faaf0cc61e Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:36:12 -0400 Subject: [PATCH 06/40] docs(paint): update Paint Tools quickstart for the new workflow - Material Mode is now the home for texture paint (decoupled from Edit Mode). - Documents the new slot picker, brush tools, 2D preview panel, and bidirectional hover. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 64 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 86eaed36e..037e00c61 100755 --- a/README.md +++ b/README.md @@ -175,25 +175,38 @@ qtmesh bake-vertex-colors model.fbx -o color_map.png --resolution 1024 --dilatio ### 🎨 Paint Tools -ZBrush-style polypaint and BaseColor texture painting, with a one-click bake -that turns vertex colors into a UV-space texture. - -**Quick start (GUI):** - -1. Select a mesh entity. Press **Tab** to enter Edit Mode. -2. In the Inspector β†’ **Edit Mode Tools** section, tick *Vertex Color Preview* - to see vertex colors live on the mesh. -3. Click the **Vertex Paint** brush button in the toolbar. Pick a color, - radius, strength, and falloff. Left-click-drag on the mesh to paint. - Strokes go through Undo/Redo (Ctrl+Z / Ctrl+Shift+Z). -4. To paint into a texture instead, open the Inspector β†’ **Texture Paint** - section. Click *Create / Attach Texture*, tick *Enable texture paint - mode*, then left-click-drag on the mesh. The painted texture is - uploaded to the GPU with per-stroke dirty-rect updates β€” no full - re-upload β€” so live painting stays responsive on large textures. -5. **Bake Vertex Colors β†’ Texture** rasterizes the active mesh's vertex - colors into a UV-space PNG via barycentric interpolation, then dilates - the result outward by N pixels to mask UV-seam bleed at MIP-map time. +ZBrush-style polypaint and BaseColor texture painting, with a 2D preview +panel, multiple brush tools (paint, erase, fill, color picker, smudge), +texture-slot picker per submesh, and a one-click bake that turns vertex +colors into a UV-space texture. + +**Quick start (texture paint, GUI):** + +1. Switch to **Material Mode** (mode bar at the top). +2. Select a mesh entity. The Inspector's **Texture Paint** section + shows a slot picker (every paintable TUS across submeshes) and a 256Γ—256 + live preview of the active texture. +3. Click the **paint brush** button in the toolbar to enable painting. The + first click on the mesh auto-creates a paint session against the active + slot (or you can pre-create with *Create / Attach Texture*). +4. Pick a tool from the row at the top of the panel: ✏ Paint, ⌫ Erase, ⧉ Fill, + ⊰ Pick (eyedropper), ∿ Smudge. Brush color/radius/strength/falloff comes + from the shared Paint Brush section above the toolbar's brush popup. +5. Left-click-drag on **either** the 3D mesh or the 2D preview panel. Both + drive the same paint buffer; the 2D preview updates in real time and a + brush ring shows where the cursor maps on the 3D surface (and vice versa). +6. *Save…* / *Load…* round-trips the buffer to disk as PNG (or any format + QImage can write). + +**Vertex paint (GUI):** + +Same flow, but vertex paint operates in **Edit Mode** instead. Press **Tab** +on a selected mesh to enter Edit Mode, then tick *Vertex Color Preview* in +the Edit Mode Tools section to see vertex colors live. + +**Bake Vertex Colors β†’ Texture** rasterizes the active mesh's vertex colors +into a UV-space PNG via barycentric interpolation, then dilates the result +outward by N pixels to mask UV-seam bleed at MIP-map time. **Headless (CLI):** @@ -204,11 +217,16 @@ qtmesh bake-vertex-colors model.fbx -o color_map.png --resolution 1024 --dilatio **Export.** Vertex colors are preserved on export to formats that support them (glTF preferred β€” verified round-trip). -**Limitations (MVP).** +**Notes / limitations.** -- Texture paint operates on the **first submesh's diffuse texture only**. - Multi-material per-submesh paint will land in a follow-up. -- Texture paint requires Edit Mode (vertex paint conventions). +- Brush size is in local mesh units (shared with vertex paint). For texture + paint we divide by mesh bounding-box extent and clamp to a UV-friendly + range, so 0.25 produces a sensible stamp on both a unit cube and a + 100-unit character. +- Painting auto-rebinds every TUS named `albedo` or `diffuse_map` on the + active material β€” imported PBR materials alias the diffuse texture under + both, and rebinding only one would leave the other pointing at the + original. - Bake uses fan triangulation of n-gon faces; concave faces should be pre-triangulated. From 8ecf0e28c74cde7b1e22eb29c80e4ccf9cca421d Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:39:26 -0400 Subject: [PATCH 07/40] fix(paint): close session in controller destructor closeSession() detaches the brush-ring scene node and drops the manual object before Ogre tears down. The default destructor would leak both during process shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 02fbf559d..f042091a1 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -142,7 +142,12 @@ TexturePaintController::TexturePaintController(QObject* parent) } } -TexturePaintController::~TexturePaintController() = default; +TexturePaintController::~TexturePaintController() +{ + // Drop the manual objects on the scene before Ogre destructors + // race us on shutdown. closeSession is safe to call repeatedly. + closeSession(); +} void TexturePaintController::setTexturePaintEnabled(bool enabled) { From 99fd42b7ed10b5b5e0b99ceee8bd9b6fcecbf865 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:40:44 -0400 Subject: [PATCH 08/40] feat(paint): resolution picker for new textures and bake output 256/512/1024/2048/4096 dropdown. The Create / Attach Texture and Bake Vertex Colors buttons both read from it, so the user can pick texture quality before either operation. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 48cfe03b6..da599a8ed 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1306,14 +1306,25 @@ Rectangle { width: parent.width - 16 spacing: 4 + // Resolution picker for fresh textures. + ThemedComboBox { + id: resCombo + width: 80 + model: ["256", "512", "1024", "2048", "4096"] + currentIndex: 2 // 1024 + } + Rectangle { - width: 140; height: 24; radius: 3 + width: 130; height: 24; radius: 3 color: createMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor border.color: PropertiesPanelController.borderColor; border.width: 1 Text { anchors.centerIn: parent; text: "Create / Attach Texture"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } MouseArea { id: createMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.ensurePaintableTexture(1024) + onClicked: { + const res = parseInt(resCombo.model[resCombo.currentIndex]) + TexturePaintController.ensurePaintableTexture(res) + } } } @@ -1354,7 +1365,10 @@ Rectangle { Text { anchors.centerIn: parent; text: "Bake Vertex Colors \u2192 Texture"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } MouseArea { id: bakeMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.bakeVertexColorsToTexture(1024, 4, "") + onClicked: { + const res = parseInt(resCombo.model[resCombo.currentIndex]) + TexturePaintController.bakeVertexColorsToTexture(res, 4, "") + } } } } From d699bebc9e675d1e3327bf8a339f7a4a87dcc449 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:43:38 -0400 Subject: [PATCH 09/40] feat(paint): UV-island wireframe overlay on the 2D preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle (UV checkbox next to the slot picker) draws every UV-mapped triangle's outline at texture resolution as a transparent PNG layered over the paint preview. Lets the user see exactly where each submesh maps before painting. The overlay is generated lazily on first toggle, then refreshed on session create (resolution change rebuilds it). Cost: ~tris Γ— 3 QPainter drawLine calls at session-create time; ~zero cost during painting since the overlay doesn't change with strokes. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 38 +++++++++++++++++++- src/TexturePaintController.cpp | 63 ++++++++++++++++++++++++++++++++++ src/TexturePaintController.h | 21 ++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index da599a8ed..67e9e5120 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1197,7 +1197,7 @@ Rectangle { } ThemedComboBox { id: slotCombo - width: 220 + width: 180 enabled: texPaintCol.slots.length > 0 model: { const labels = [] @@ -1211,6 +1211,30 @@ Rectangle { TexturePaintController.activeSlotIndex = index } } + // UV island overlay toggle + Rectangle { + width: 18; height: 18; radius: 3 + color: TexturePaintController.uvOverlayVisible + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: TexturePaintController.uvOverlayVisible ? "\u2713" : "" + color: "white"; font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.uvOverlayVisible = !TexturePaintController.uvOverlayVisible + } + } + Text { + text: "UV" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } } // Session info @@ -1247,6 +1271,18 @@ Rectangle { // Bust the cache when source string changes onSourceChanged: previewImg.update() } + // UV-island wireframe overlay (toggleable). + Image { + id: uvOverlayImg + anchors.fill: parent + anchors.margins: 1 + visible: TexturePaintController.uvOverlayVisible + opacity: 0.7 + source: TexturePaintController.uvOverlayDataUri + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + } // Crosshair indicator at hover UV Rectangle { diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index f042091a1..f8c3ee6e2 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -11,6 +11,8 @@ #include #include +#include +#include #include #include #include @@ -381,6 +383,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) .arg(loadedExisting ? "yes" : "no")); refreshPreviewUri(); + if (m_uvOverlayVisible) refreshUvOverlay(); emit sessionChanged(); return true; } @@ -1071,6 +1074,66 @@ void TexturePaintController::refreshSlots() // Preview URI // --------------------------------------------------------------------------- +void TexturePaintController::setUvOverlayVisible(bool on) +{ + if (m_uvOverlayVisible == on) return; + m_uvOverlayVisible = on; + if (on && m_uvOverlayUri.isEmpty()) refreshUvOverlay(); + emit uvOverlayChanged(); +} + +void TexturePaintController::refreshUvOverlay() +{ + if (!m_paintMesh || m_buffer.width() <= 0 || m_buffer.height() <= 0) { + if (!m_uvOverlayUri.isEmpty()) { + m_uvOverlayUri.clear(); + emit uvOverlayChanged(); + } + return; + } + const int W = m_buffer.width(); + const int H = m_buffer.height(); + QImage img(W, H, QImage::Format_RGBA8888); + img.fill(Qt::transparent); + QPainter p(&img); + p.setRenderHint(QPainter::Antialiasing, false); + QPen pen(QColor(255, 255, 255, 200)); + // Keep the wireframe a single pixel wide at any resolution. + pen.setWidth(1); + pen.setCosmetic(true); + p.setPen(pen); + + auto toPx = [&](const Ogre::Vector2& uv) { + return QPointF(uv.x * W, uv.y * H); + }; + for (const auto& sub : m_paintMesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) continue; + const QPointF p0 = toPx(v0.uv); + const QPointF p1 = toPx(v1.uv); + const QPointF p2 = toPx(v2.uv); + p.drawLine(p0, p1); + p.drawLine(p1, p2); + p.drawLine(p2, p0); + } + } + p.end(); + + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + img.save(&qbuf, "PNG"); + const QString next = QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(bytes.toBase64()); + if (next != m_uvOverlayUri) { + m_uvOverlayUri = next; + emit uvOverlayChanged(); + } +} + void TexturePaintController::refreshPreviewUri() { if (m_buffer.width() <= 0 || m_buffer.height() <= 0) { diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index b0fb74510..b2104cc2b 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -132,6 +132,17 @@ class TexturePaintController : public QObject /// Preview data URI (PNG, base64) regenerated on every dirty flush. QString previewDataUri() const { return m_previewUri; } + /// PNG data URI of the UV wireframe (white triangles on transparent + /// background) at the current texture resolution. Lets the QML + /// preview panel overlay UV islands on the texture so the user can + /// see where their strokes are going relative to the unwrap. + Q_PROPERTY(QString uvOverlayDataUri READ uvOverlayDataUri NOTIFY uvOverlayChanged) + QString uvOverlayDataUri() const { return m_uvOverlayUri; } + + Q_PROPERTY(bool uvOverlayVisible READ uvOverlayVisible WRITE setUvOverlayVisible NOTIFY uvOverlayChanged) + bool uvOverlayVisible() const { return m_uvOverlayVisible; } + void setUvOverlayVisible(bool on); + /// Paint via a UV coordinate directly (driven by the texture preview /// panel's mouse area). The preview ↔ 3D mesh hover indicator is /// driven by emitting hoveredUVChanged at the same time. @@ -234,6 +245,7 @@ class TexturePaintController : public QObject void brushToolChanged(); void slotsChanged(); void previewChanged(); + void uvOverlayChanged(); /// Emitted when the mouse hovers over a UV-mapped triangle (from /// the 3D mesh or from the 2D texture preview panel). u,v in [0..1]; /// (-1, -1) means "no hover". @@ -272,6 +284,10 @@ class TexturePaintController : public QObject /// previewChanged when the URI actually changed. void refreshPreviewUri(); + /// Regenerate `m_uvOverlayUri` by drawing every UV-mapped triangle + /// outline at the current texture resolution into a transparent PNG. + void refreshUvOverlay(); + /// One-time deep-copy snapshot of pixel buffer for undo. std::vector snapshotPixels() const; @@ -337,6 +353,11 @@ class TexturePaintController : public QObject /// base64 PNG on every dirty flush (expensive at 1024Γ—1024). bool m_previewRefreshScheduled = false; + // UV wireframe overlay (data: URI of a transparent PNG with the + // triangle outlines drawn at the texture resolution). + QString m_uvOverlayUri; + bool m_uvOverlayVisible = false; + static TexturePaintController* s_instance; }; From cc62ee2bde99574c916b536d331268b49ef680bc Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:47:28 -0400 Subject: [PATCH 10/40] fix(paint): preserve resolution across slot switches Without this, picking a new slot dropped back to 1024 even after the user explicitly created the session at 2048+. We now snapshot the current buffer width and pass it through to the post-switch ensurePaintableTexture call. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index f8c3ee6e2..399f51050 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -190,10 +190,14 @@ void TexturePaintController::setActiveSlotIndex(int index) { if (index < 0 || index >= m_slots.size()) return; if (m_activeSlot == index) return; + // Preserve the user's current buffer resolution across slot + // switches β€” without this, jumping slots always falls back to 1024 + // even after the user picked 2048. + const int preservedRes = m_buffer.width() > 0 ? m_buffer.width() : 1024; m_activeSlot = index; - // Switching slots resets the buffer to that slot's texture. + // Switching slots resets the buffer to the new slot's texture. closeSession(); - ensurePaintableTexture(1024); + ensurePaintableTexture(preservedRes); emit slotsChanged(); } From ea97cc4c8c4d53b40499c3446b6cabbdd2b4942b Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:49:05 -0400 Subject: [PATCH 11/40] fix(paint): clear UV overlay on session close Otherwise the stale wireframe sits in the QML preview even after the buffer is empty, confusing the user about whether a session is active. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 399f51050..436527b62 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -963,6 +963,10 @@ void TexturePaintController::closeSession() m_textureName.clear(); m_sessionEntity = nullptr; m_strokePreSnapshot.clear(); + if (!m_uvOverlayUri.isEmpty()) { + m_uvOverlayUri.clear(); + emit uvOverlayChanged(); + } m_previewUri.clear(); emit previewChanged(); emit sessionChanged(); From 2f5539b6118e0fa729a87b86206196ab5732483a Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 03:50:01 -0400 Subject: [PATCH 12/40] fix(paint): brush ring uses local-unit radius, not mesh-scaled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ring multiplied the toolbar radius by the mesh bounding-box extent, which made it grow proportionally to mesh size β€” a 0.25 local-unit brush on a 100-unit character drew a 10-unit ring on the surface. The toolbar slider is already in local units; just plot the ring at that radius directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 436527b62..3f2e4d521 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1329,10 +1329,10 @@ void TexturePaintController::drawHoverRingAt(const Ogre::Vector3& localPos, const QColor c = texturePaintColor(); const Ogre::ColourValue ringCol(c.redF(), c.greenF(), c.blueF(), 0.95f); constexpr int kSegments = 64; - const Ogre::AxisAlignedBox bbox = m_paintMesh ? m_paintMesh->calculateBounds() - : Ogre::AxisAlignedBox::BOX_NULL; - const Ogre::Real meshScale = bbox.isFinite() ? bbox.getSize().length() * 0.5f : 1.0f; - const float radius = static_cast(texturePaintRadius()) * meshScale * 0.8f; + // The brush radius is in local mesh units (shared with vertex paint). + // 0.8 narrows the ring slightly so it visually matches the painted + // footprint (a softly-falloff brush doesn't quite touch the ring edge). + const float radius = static_cast(texturePaintRadius()) * 0.8f; const Ogre::Vector3 center = localPos + normal * 0.001f; m_ringObj->begin(kMatName, Ogre::RenderOperation::OT_LINE_STRIP); for (int i = 0; i <= kSegments; ++i) { From ab095886f7caa192ca1933a92f34ffde3d95a2ec Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 09:07:48 -0400 Subject: [PATCH 13/40] fix(paint): kill TexturePaintController before Manager teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller's destructor calls closeSession() which touches the Ogre SceneManager. Without an explicit kill() before Manager::kill() in MainWindow::~MainWindow, the static singleton destructor runs at process exit β€” after Ogre is gone β€” and segfaults (exit code 139). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mainwindow.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 2357e73b0..3c6557ca5 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -329,6 +329,10 @@ MainWindow::~MainWindow() EditorModeController::kill(); Manager* manager = Manager::getSingletonPtr(); if (manager) { + // Paint controller holds an EditableMesh + ring overlay objects + // owned by the SceneManager. Kill it before Manager teardown + // so its destructor runs against a live Ogre. + TexturePaintController::kill(); EditModeController::kill(); SubEntityHighlight::kill(); AnimationBlender::kill(); From 75b7638159d06a10d8c6bea568c37b0e0b1bfe34 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 09:25:56 -0400 Subject: [PATCH 14/40] fix(paint): restore original TUS textures on session close + scoped rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three big behavior changes to fix: - "Model goes textureless on slot select" β€” was caused by a fallback to all-white when convertToImage failed, plus a too-broad rebind that clobbered every diffuse-slot TUS regardless of which submesh the user picked. Now only TUSes pointing at the user's original slot texture get rebound. - "Crashes on second slot switch" β€” m_boundSlots stored raw material pointers that could dangle across closeβ†’reopen cycles. Switched to storing material name + looking up via MaterialManager on restore. - "Painting not visible" β€” added explicit isLoaded()/load() before convertToImage so a deferred-load texture actually has pixels available to copy into the paint buffer. Switched to TU_DYNAMIC_WRITE_ONLY_DISCARDABLE for the GL upload path. Adds Sentry breadcrumbs for: rebound TUS count, source texture name, and blit failures so we can diagnose any remaining cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 127 ++++++++++++++++++++++++++------- src/TexturePaintController.h | 12 ++++ 2 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 3f2e4d521..19dcff885 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -336,28 +336,35 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) QString existingTex = QString::fromStdString(tu->getTextureName()); bool loadedExisting = false; + QString loadError; if (!existingTex.isEmpty()) { auto existing = Ogre::TextureManager::getSingleton().getByName( existingTex.toStdString(), Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); if (existing) { try { + // Make sure the texture is loaded (Ogre defers loading). + if (!existing->isLoaded()) existing->load(); Ogre::Image img; existing->convertToImage(img); const int w = static_cast(img.getWidth()); const int h = static_cast(img.getHeight()); if (w > 0 && h > 0) { m_buffer.resize(w, h); - // PF_BYTE_RGBA == 4 bytes/pixel Ogre::PixelBox srcBox = img.getPixelBox(); - Ogre::PixelBox dstBox(w, h, 1, Ogre::PF_BYTE_RGBA, m_buffer.data().data()); + Ogre::PixelBox dstBox(w, h, 1, Ogre::PF_BYTE_RGBA, + m_buffer.data().data()); Ogre::PixelUtil::bulkPixelConversion(srcBox, dstBox); m_buffer.clearDirty(); loadedExisting = true; } - } catch (const Ogre::Exception&) { - // Fall through to blank buffer. + } catch (const Ogre::Exception& e) { + loadError = QString::fromStdString(e.getDescription()); + } catch (...) { + loadError = QStringLiteral("unknown exception"); } + } else { + loadError = QStringLiteral("texture not found in TextureManager"); } } @@ -366,6 +373,13 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) m_buffer.resize(res, res); m_buffer.clear(Ogre::ColourValue::White); m_buffer.clearDirty(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: starting from blank %1Γ—%1 (tex='%2', err='%3')") + .arg(res).arg(existingTex).arg(loadError)); + } else { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: loaded existing %1Γ—%2 from '%3'") + .arg(m_buffer.width()).arg(m_buffer.height()).arg(existingTex)); } static unsigned int s_unique = 0; @@ -413,7 +427,8 @@ bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, c m_ogreTexture = tm.createManual( texName, group, Ogre::TEX_TYPE_2D, m_buffer.width(), m_buffer.height(), 0, - Ogre::PF_BYTE_RGBA, Ogre::TU_DYNAMIC_WRITE_ONLY); + Ogre::PF_BYTE_RGBA, + Ogre::TU_DYNAMIC_WRITE_ONLY_DISCARDABLE); if (!m_ogreTexture) return false; // Initial upload auto pixbuf = m_ogreTexture->getBuffer(); @@ -423,43 +438,68 @@ bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, c pixbuf->blitFromMemory(pb); m_textureName = QString::fromStdString(texName); - // Rebind every TUS on every submesh material that points at the - // original texture. Imported PBR materials alias the diffuse - // texture under both `diffuse_map` (TUS 0) and `albedo` (last - // TUS) β€” rebinding only TUS 0 leaves `albedo` pointing at the - // old texture, and whichever slot the renderer samples wins, so - // the user sees no change. Walk every submesh and rebind. + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: rebind base = '%1' β†’ '%2'") + .arg(QString::fromStdString(originalTexName)) + .arg(QString::fromStdString(texName))); + // Rebind every TUS pointing at the user's original slot + // texture. Track each rebind so closeSession() restores them. + // + // Imported PBR materials alias the diffuse texture under both + // `diffuse_map` (TUS 0) and `albedo` (last TUS) β€” we rebind + // both copies that share originalTexName. + // + // If originalTexName is empty (the slot had no texture bound + // yet β€” e.g. a freshly-created session on a flat-color + // material), we bind the *chosen slot only* so the user has + // SOMETHING to paint against and see the result. + m_boundSlots.clear(); std::set touched; + const int chosenSubmesh = + (m_activeSlot >= 0 && m_activeSlot < m_slots.size()) + ? m_slots.at(m_activeSlot).toMap().value("submesh", -1).toInt() + : 0; for (unsigned int se = 0; se < entity->getNumSubEntities(); ++se) { auto* sub = entity->getSubEntity(se); if (!sub) continue; Ogre::MaterialPtr mat = sub->getMaterial(); if (!mat) continue; bool changed = false; - for (auto* tech : mat->getTechniques()) { + const auto& techs = mat->getTechniques(); + for (size_t tIdx = 0; tIdx < techs.size(); ++tIdx) { + auto* tech = techs[tIdx]; for (unsigned short pi = 0; pi < tech->getNumPasses(); ++pi) { auto* p = tech->getPass(pi); for (unsigned short ti = 0; ti < p->getNumTextureUnitStates(); ++ti) { auto* tusN = p->getTextureUnitState(ti); - const std::string n = tusN->getName(); - // Match either the original texture name or a - // canonical diffuse slot name. The slot-name - // path catches submeshes whose `albedo`/ - // `diffuse_map` was bound to a different - // texture than the one we sampled β€” every - // diffuse slot still gets the paint texture. - const bool nameMatch = !originalTexName.empty() - && tusN->getTextureName() == originalTexName; - const bool slotMatch = (n == "albedo" || n == "diffuse_map"); - if (nameMatch || slotMatch) { - tusN->setTextureName(texName); - changed = true; + const std::string currentTex = tusN->getTextureName(); + bool shouldBind = false; + if (!originalTexName.empty()) { + shouldBind = (currentTex == originalTexName); + } else if (static_cast(se) == chosenSubmesh) { + // Empty-source fallback: bind the exact TUS + // the user picked. We approximate "the + // user's TUS" as the matching slot/index on + // the chosen submesh. + shouldBind = (tusN == tu); } + if (!shouldBind) continue; + BoundSlot s; + s.materialName = mat->getName(); + s.techIdx = static_cast(tIdx); + s.passIdx = pi; + s.tusIdx = ti; + s.originalTexture = currentTex; + m_boundSlots.push_back(s); + tusN->setTextureName(texName); + changed = true; } } } if (changed) touched.insert(mat.get()); } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: bound %1 TUSes").arg(m_boundSlots.size())); // Force RTSS / FFP lighting passes to drop their cached binding // and re-sample our texture. Without compile()+reload() the @@ -507,8 +547,10 @@ void TexturePaintController::flushDirtyToOgre() Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, slice.data()); Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); buf->blitFromMemory(pb, dst); - } catch (const Ogre::Exception&) { - // Best-effort β€” skip this flush. + } catch (const Ogre::Exception& e) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: blit failed β€” %1") + .arg(QString::fromStdString(e.getDescription()))); } m_buffer.clearDirty(); // Debounce the 2D preview refresh β€” encoding a 1024Γ—1024 PNG + @@ -936,6 +978,37 @@ void TexturePaintController::applyPixelSnapshot(const std::vector& pixe void TexturePaintController::closeSession() { if (m_strokeActive) endStroke(); + + // Restore every TUS we rebound. Must do this BEFORE removing the + // paint texture from TextureManager, otherwise the next render + // tries to sample a freed handle. Look the material up by name + // each time so a destroyed/reloaded material doesn't dangle. + std::set toReload; + for (const auto& s : m_boundSlots) { + try { + if (s.materialName.empty()) continue; + auto matPtr = Ogre::MaterialManager::getSingleton().getByName( + s.materialName); + if (!matPtr) continue; + const auto& techs = matPtr->getTechniques(); + if (s.techIdx >= techs.size()) continue; + auto* tech = techs[s.techIdx]; + if (s.passIdx >= tech->getNumPasses()) continue; + auto* p = tech->getPass(s.passIdx); + if (s.tusIdx >= p->getNumTextureUnitStates()) continue; + auto* tusN = p->getTextureUnitState(s.tusIdx); + tusN->setTextureName(s.originalTexture); + toReload.insert(s.materialName); + } catch (...) {} + } + m_boundSlots.clear(); + for (const auto& mname : toReload) { + try { + auto matPtr = Ogre::MaterialManager::getSingleton().getByName(mname); + if (matPtr) { matPtr->compile(); matPtr->reload(); } + } catch (...) {} + } + if (m_ogreTexture) { try { Ogre::TextureManager::getSingleton().remove(m_ogreTexture); diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index b2104cc2b..44ab6d43f 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -325,6 +325,18 @@ class TexturePaintController : public QObject std::vector m_strokePreSnapshot; // for undo BrushTool m_tool = ToolPaint; + /// Track every TUS we rebound to the paint texture so closeSession() + /// can restore the originals. We keep the *material name* (not a + /// raw pointer) so a destroyed/reloaded material doesn't dangle. + struct BoundSlot { + std::string materialName; + unsigned short techIdx = 0; + unsigned short passIdx = 0; + unsigned short tusIdx = 0; + std::string originalTexture; + }; + std::vector m_boundSlots; + // Private EditableMesh β€” built from the active entity so painting // doesn't depend on the user-facing Edit Mode workspace. std::unique_ptr m_paintMesh; From 560b5d048ca028792dac8bc22cd1285eef9004ed Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 09:33:44 -0400 Subject: [PATCH 15/40] fix(paint): non-destructive paint enable + 3 strategies for texture readback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major UX change to fix "model goes textureless on paint enable": - setTexturePaintEnabled() no longer auto-creates a session. Toggling the brush is now non-destructive β€” the model's render is unchanged until the user actually paints (which lazily creates a session in beginStroke). Disabling paint also closes the session, restoring the original TUS bindings. - Texture readback now tries three strategies in order: 1. TextureManager::getByName β†’ convertToImage (works for most in-memory textures Ogre has uploaded). 2. Texture::getBuffer()->blitToMemory (works for GPU-resident textures whose source Image was discarded post-upload β€” the common case for imported meshes). 3. Ogre::Image::load(name, group) β€” works when the texture name is also a filename in a registered resource location. - If all three fail, we now start from a blank buffer with a breadcrumb explaining why (rather than the previous behavior of silently binding a white texture which made the user think the model went textureless). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 102 ++++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 15 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 19dcff885..67b6f5af8 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -161,15 +161,21 @@ void TexturePaintController::setTexturePaintEnabled(bool enabled) // EditModeController::enterEditMode() β€” that would flip the // workspace mode to EditMode, kick the user out of Material // Mode, and (via the visibility hooks) silently disable us. + // + // We do NOT auto-create a session here. Creating a session + // rebinds the model's diffuse TUS to a paint texture, which + // visibly changes the model (white if the existing texture + // couldn't be read back). Instead, the session is lazily + // created on the first stroke (beginStroke / beginStrokeUV) + // so toggling the brush button is non-destructive. refreshSlots(); - // Make sure a session exists so the first click actually - // paints β€” without this the user clicks, nothing happens, - // they have to find the "Create / Attach Texture" button. - if (!hasActiveSession()) - ensurePaintableTexture(1024); } - if (!enabled && m_strokeActive) - endStroke(); + if (!enabled) { + if (m_strokeActive) endStroke(); + // Disabling paint also tears down any active session so the + // model's original textures snap back into the render. + if (hasActiveSession()) closeSession(); + } SentryReporter::addBreadcrumb("ui.action", enabled ? "Texture paint: enabled" : "Texture paint: disabled"); emit texturePaintChanged(); @@ -338,12 +344,21 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) bool loadedExisting = false; QString loadError; if (!existingTex.isEmpty()) { - auto existing = Ogre::TextureManager::getSingleton().getByName( - existingTex.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + // Try multiple strategies because imported PBR textures can + // come from inline FBX embeds (no disk file), legacy on-disk + // files, or auto-generated render targets. Each strategy + // succeeds for a different source. + // + // 1. TextureManager β†’ convertToImage (works when Ogre keeps + // pixels in an Image buffer beside the GPU upload). + Ogre::TexturePtr existing; + try { + existing = Ogre::TextureManager::getSingleton().getByName( + existingTex.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + } catch (...) {} if (existing) { try { - // Make sure the texture is loaded (Ogre defers loading). if (!existing->isLoaded()) existing->load(); Ogre::Image img; existing->convertToImage(img); @@ -361,11 +376,60 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) } catch (const Ogre::Exception& e) { loadError = QString::fromStdString(e.getDescription()); } catch (...) { - loadError = QStringLiteral("unknown exception"); + loadError = QStringLiteral("convertToImage exception"); } } else { loadError = QStringLiteral("texture not found in TextureManager"); } + + // 2. Read from GPU directly via blitToMemory. Works when the + // texture is on the GPU but its source Image buffer was + // discarded post-upload (common for static textures). + if (!loadedExisting && existing) { + try { + if (!existing->isLoaded()) existing->load(); + const int w = static_cast(existing->getWidth()); + const int h = static_cast(existing->getHeight()); + if (w > 0 && h > 0) { + m_buffer.resize(w, h); + Ogre::PixelBox pb(w, h, 1, Ogre::PF_BYTE_RGBA, + m_buffer.data().data()); + auto pixbuf = existing->getBuffer(); + if (pixbuf) { + pixbuf->blitToMemory(pb); + m_buffer.clearDirty(); + loadedExisting = true; + loadError.clear(); + } + } + } catch (...) { + // Try strategy 3. + } + } + + // 3. QImage reading via Ogre::Image::load β€” works when the + // texture name is also a filename in a registered location. + if (!loadedExisting) { + try { + Ogre::Image img; + img.load(existingTex.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + const int w = static_cast(img.getWidth()); + const int h = static_cast(img.getHeight()); + if (w > 0 && h > 0) { + m_buffer.resize(w, h); + Ogre::PixelBox srcBox = img.getPixelBox(); + Ogre::PixelBox dstBox(w, h, 1, Ogre::PF_BYTE_RGBA, + m_buffer.data().data()); + Ogre::PixelUtil::bulkPixelConversion(srcBox, dstBox); + m_buffer.clearDirty(); + loadedExisting = true; + loadError.clear(); + } + } catch (...) { + // All three failed. + } + } } if (!loadedExisting) { @@ -374,7 +438,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) m_buffer.clear(Ogre::ColourValue::White); m_buffer.clearDirty(); SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: starting from blank %1Γ—%1 (tex='%2', err='%3')") + QStringLiteral("Texture paint: starting from blank %1Γ—%1 (existing tex='%2', err='%3')") .arg(res).arg(existingTex).arg(loadError)); } else { SentryReporter::addBreadcrumb("ui.action", @@ -631,10 +695,18 @@ bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widg bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& screenPos) { - if (!m_paintEnabled || m_strokeActive) return false; + if (!m_paintEnabled || m_strokeActive) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: beginStroke skipped (enabled=%1 active=%2)") + .arg(m_paintEnabled).arg(m_strokeActive)); + return false; + } if (!hasActiveSession()) { - if (!ensurePaintableTexture(m_buffer.width() > 0 ? m_buffer.width() : 1024)) + if (!ensurePaintableTexture(m_buffer.width() > 0 ? m_buffer.width() : 1024)) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: beginStroke aborted β€” no session could be created"); return false; + } } m_strokeActive = true; m_strokeJustBegan = true; From 9ff6c50c09734f87bee0059f35b5dd4adece66aa Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 09:43:55 -0400 Subject: [PATCH 16/40] fix(paint): brush works in both Material+Edit modes; deferred rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct user-reported breakages, fixed together: 1. Vertex paint button stopped working β€” visibility hook hid the brush outside Material Mode, so Edit Mode had no toolbar affordance for vertex paint. Now the brush appears in BOTH Material Mode AND Edit Mode and dispatches to the right kind of paint based on current mode (texture vs vertex). 2. Enabling texture paint wiped the model's diffuse β€” the toolbar toggle path called refreshSlots() β†’ ensurePaintableTexture() β†’ immediate rebind, even though we'd "removed" the auto-create from setTexturePaintEnabled itself. The auto-create was hiding inside refreshSlots. Now refreshSlots is pure metadata (no side effects) AND createOgreTextureFromBuffer defers the material rebind until the first dirty-rect flush in flushDirtyToOgre. Toggling the brush button is now non-destructive; the model only changes when the user actually paints something visible. Explicit user actions (Bake Vertex Colors β†’ Texture, Load Texture) still rebind immediately since the user wants to see the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 183 +++++++++++++++++---------------- src/TexturePaintController.h | 21 +++- src/mainwindow.cpp | 50 +++++---- 3 files changed, 139 insertions(+), 115 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 67b6f5af8..5f7f8810e 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -470,7 +470,9 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) return true; } -bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, const QString& nameHint) +bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, + const QString& nameHint, + bool rebindToModel) { if (!entity || m_buffer.width() <= 0 || m_buffer.height() <= 0) return false; auto* tu = findOrCreateActiveTextureUnit(entity); @@ -502,83 +504,11 @@ bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, c pixbuf->blitFromMemory(pb); m_textureName = QString::fromStdString(texName); - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: rebind base = '%1' β†’ '%2'") - .arg(QString::fromStdString(originalTexName)) - .arg(QString::fromStdString(texName))); - // Rebind every TUS pointing at the user's original slot - // texture. Track each rebind so closeSession() restores them. - // - // Imported PBR materials alias the diffuse texture under both - // `diffuse_map` (TUS 0) and `albedo` (last TUS) β€” we rebind - // both copies that share originalTexName. - // - // If originalTexName is empty (the slot had no texture bound - // yet β€” e.g. a freshly-created session on a flat-color - // material), we bind the *chosen slot only* so the user has - // SOMETHING to paint against and see the result. - m_boundSlots.clear(); - std::set touched; - const int chosenSubmesh = - (m_activeSlot >= 0 && m_activeSlot < m_slots.size()) - ? m_slots.at(m_activeSlot).toMap().value("submesh", -1).toInt() - : 0; - for (unsigned int se = 0; se < entity->getNumSubEntities(); ++se) { - auto* sub = entity->getSubEntity(se); - if (!sub) continue; - Ogre::MaterialPtr mat = sub->getMaterial(); - if (!mat) continue; - bool changed = false; - const auto& techs = mat->getTechniques(); - for (size_t tIdx = 0; tIdx < techs.size(); ++tIdx) { - auto* tech = techs[tIdx]; - for (unsigned short pi = 0; pi < tech->getNumPasses(); ++pi) { - auto* p = tech->getPass(pi); - for (unsigned short ti = 0; ti < p->getNumTextureUnitStates(); ++ti) { - auto* tusN = p->getTextureUnitState(ti); - const std::string currentTex = tusN->getTextureName(); - bool shouldBind = false; - if (!originalTexName.empty()) { - shouldBind = (currentTex == originalTexName); - } else if (static_cast(se) == chosenSubmesh) { - // Empty-source fallback: bind the exact TUS - // the user picked. We approximate "the - // user's TUS" as the matching slot/index on - // the chosen submesh. - shouldBind = (tusN == tu); - } - if (!shouldBind) continue; - BoundSlot s; - s.materialName = mat->getName(); - s.techIdx = static_cast(tIdx); - s.passIdx = pi; - s.tusIdx = ti; - s.originalTexture = currentTex; - m_boundSlots.push_back(s); - tusN->setTextureName(texName); - changed = true; - } - } - } - if (changed) touched.insert(mat.get()); - } - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: bound %1 TUSes").arg(m_boundSlots.size())); - - // Force RTSS / FFP lighting passes to drop their cached binding - // and re-sample our texture. Without compile()+reload() the - // renderer keeps using whatever sampler the shader was - // generated with, so the new texture stays invisible. - for (auto* mat : touched) { - try { - mat->compile(); - mat->reload(); - } catch (const Ogre::Exception&) { - // Best-effort: a reload failure shouldn't kill the - // paint session β€” the worst case is stale rendering - // until the next material edit. - } - } + if (rebindToModel) + rebindEntityDiffuseToPaintTexture(entity); + else + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: GPU texture created, deferred rebind")); return true; } catch (const Ogre::Exception&) { return false; @@ -587,11 +517,83 @@ bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, c } } +void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* entity) +{ + if (!entity || m_textureName.isEmpty()) return; + // Capture the original texture name from the user's active slot + // (or the first diffuse-like TUS as fallback). + auto* tu = findOrCreateActiveTextureUnit(entity); + const std::string originalTexName = tu ? tu->getTextureName() : ""; + const std::string texName = m_textureName.toStdString(); + + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: rebind base = '%1' β†’ '%2'") + .arg(QString::fromStdString(originalTexName)) + .arg(QString::fromStdString(texName))); + + m_boundSlots.clear(); + std::set touched; + const int chosenSubmesh = + (m_activeSlot >= 0 && m_activeSlot < m_slots.size()) + ? m_slots.at(m_activeSlot).toMap().value("submesh", -1).toInt() + : 0; + for (unsigned int se = 0; se < entity->getNumSubEntities(); ++se) { + auto* sub = entity->getSubEntity(se); + if (!sub) continue; + Ogre::MaterialPtr mat = sub->getMaterial(); + if (!mat) continue; + bool changed = false; + const auto& techs = mat->getTechniques(); + for (size_t tIdx = 0; tIdx < techs.size(); ++tIdx) { + auto* tech = techs[tIdx]; + for (unsigned short pi = 0; pi < tech->getNumPasses(); ++pi) { + auto* p = tech->getPass(pi); + for (unsigned short ti = 0; ti < p->getNumTextureUnitStates(); ++ti) { + auto* tusN = p->getTextureUnitState(ti); + const std::string currentTex = tusN->getTextureName(); + bool shouldBind = false; + if (!originalTexName.empty()) { + shouldBind = (currentTex == originalTexName); + } else if (static_cast(se) == chosenSubmesh) { + shouldBind = (tusN == tu); + } + if (!shouldBind) continue; + BoundSlot s; + s.materialName = mat->getName(); + s.techIdx = static_cast(tIdx); + s.passIdx = pi; + s.tusIdx = ti; + s.originalTexture = currentTex; + m_boundSlots.push_back(s); + tusN->setTextureName(texName); + changed = true; + } + } + } + if (changed) touched.insert(mat.get()); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: bound %1 TUSes").arg(m_boundSlots.size())); + + for (auto* mat : touched) { + try { mat->compile(); mat->reload(); } + catch (const Ogre::Exception&) {} + } +} + void TexturePaintController::flushDirtyToOgre() { if (!m_ogreTexture) return; const auto& dirty = m_buffer.dirtyRect(); if (dirty.empty()) return; + + // First-flush deferred rebind: the model's diffuse TUSes are not + // rebound to the paint texture until the user actually paints + // something. This keeps "enable paint" non-destructive β€” the + // model only changes when there's a stroke to commit. + if (m_boundSlots.empty() && m_paintMeshEntity) { + rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); + } try { auto buf = m_ogreTexture->getBuffer(); if (!buf) return; @@ -915,9 +917,11 @@ bool TexturePaintController::loadPaintBuffer(const QString& path) QFileInfo fi(path); QString hint = QStringLiteral("QMEPaintLoad_%1").arg(fi.completeBaseName()); m_sessionEntity = entity; - // Re-create the Ogre texture at the new resolution. + // Re-create the Ogre texture at the new resolution. The user + // explicitly loaded a new image β€” rebind immediately so they + // see it on the model. m_ogreTexture.reset(); - if (!createOgreTextureFromBuffer(entity, hint)) return false; + if (!createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/true)) return false; } emit sessionChanged(); return true; @@ -1024,7 +1028,9 @@ int TexturePaintController::bakeVertexColorsToTexture(int resolution, const QString hint = QStringLiteral("QMEBake_%1_%2") .arg(QString::fromStdString(entity->getName())) .arg(++s_bakeUnique); - createOgreTextureFromBuffer(entity, hint); + // Bake is an explicit user action β€” rebind immediately so the + // baked result appears on the model. + createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/true); if (!savePath.isEmpty()) m_buffer.save(savePath.toStdString()); @@ -1177,16 +1183,11 @@ void TexturePaintController::clearHoveredUV() void TexturePaintController::refreshSlots() { - // If paint is enabled and the user just changed selection, try to - // re-establish the session against the new entity so they don't - // have to click "Create / Attach Texture" again. Skip if the - // session is already valid for the current entity. - if (m_paintEnabled) { - auto* e = activeEntity(); - if (e && e != m_sessionEntity && !hasActiveSession()) - ensurePaintableTexture(1024); - } - + // Refresh is pure metadata β€” it must not create a paint session + // as a side effect. Otherwise toggling the brush button (which + // calls refreshSlots) silently rebinds the model's diffuse TUS + // and the user sees the model "go textureless". Sessions are + // created lazily inside beginStroke() instead. QVariantList newSlots; auto* entity = activeEntity(); if (entity) { diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 44ab6d43f..5c3fab730 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -273,9 +273,24 @@ class TexturePaintController : public QObject /// false on miss. bool hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const; - /// Allocate a new manual Ogre::Texture with current buffer dimensions - /// and bind it onto the entity's active slot. - bool createOgreTextureFromBuffer(Ogre::Entity* entity, const QString& nameHint); + /// Allocate a new manual Ogre::Texture with current buffer + /// dimensions. When `rebindToModel` is true, also walk the + /// entity's materials and rebind diffuse TUSes pointing at the + /// original texture to the paint texture. When false (the default + /// for session create), the GPU texture is allocated but the + /// model's render is left alone until the first stroke + /// committedly modifies pixels. + bool createOgreTextureFromBuffer(Ogre::Entity* entity, + const QString& nameHint, + bool rebindToModel = false); + + /// Rebind diffuse TUSes pointing at the original-slot texture to + /// the paint texture. Records the original bindings in + /// `m_boundSlots` so closeSession() can restore them. Called + /// either eagerly (rebindToModel=true on session create) or + /// lazily (on the first stroke flush, so toggling the brush is + /// non-destructive). + void rebindEntityDiffuseToPaintTexture(Ogre::Entity* entity); /// Upload buffer.dirtyRect() into the live Ogre texture and clear it. void flushDirtyToOgre(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3c6557ca5..8f670bba0 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1365,14 +1365,19 @@ void MainWindow::initToolBar() }); connect(vertexPaintButton, &QToolButton::toggled, this, [this](bool on) { + const auto mode = EditorModeController::instance()->currentMode(); + const bool material = mode == EditorModeController::MaterialMode; + const bool edit = mode == EditorModeController::EditMode; SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Toolbar: Paint brush %1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); - // One button toggles both vertex and texture paint. They share a - // brush; enabling them together gives the user "the brush is on" - // semantics regardless of whether they're painting vertex colors - // or into a texture. - EditModeController::instance()->setVertexPaintEnabled(on); - TexturePaintController::instance()->setTexturePaintEnabled(on); + QStringLiteral("Toolbar: Paint brush %1 (mode=%2)") + .arg(on ? QStringLiteral("on") : QStringLiteral("off")) + .arg(material ? "material" : edit ? "edit" : "other")); + // Pick the right brush for the current mode. Vertex paint + // needs Edit Mode; texture paint works in Material Mode. + if (edit) + EditModeController::instance()->setVertexPaintEnabled(on); + if (material) + TexturePaintController::instance()->setTexturePaintEnabled(on); if (on) setTransformState(TransformOperator::TS_SELECT); }); @@ -1390,20 +1395,23 @@ void MainWindow::initToolBar() QAction* vertexPaintAction = ui->objectsToolbar->addWidget(vertexPaintButton); vertexPaintAction->setObjectName("modeMaterialPaintBrushAction"); - // Material Mode owns the paint brush (vertex + texture). Hide the - // toolbar button outside Material Mode so it doesn't crowd the - // Edit-mode topology row. - auto refreshPaintBrushVisibility = [vertexPaintAction, vertexPaintButton]() { - const bool material = EditorModeController::instance()->currentMode() - == EditorModeController::MaterialMode; - vertexPaintAction->setVisible(material); - vertexPaintButton->setEnabled(material); - if (!material) { - // Switching out of Material Mode turns the brush off so the - // user doesn't end up painting blindly in another mode. - EditModeController::instance()->setVertexPaintEnabled(false); - TexturePaintController::instance()->setTexturePaintEnabled(false); - } + // The paint brush is contextual: + // - Material Mode β†’ texture paint (paint into the BaseColor) + // - Edit Mode β†’ vertex paint (paint vertex colors) + // - Other modes β†’ hidden + // Switching modes turns the previous mode's brush off so we don't + // leave a stale checked state. + auto refreshPaintBrushVisibility = [vertexPaintButton, vertexPaintAction]() { + const auto mode = EditorModeController::instance()->currentMode(); + const bool material = mode == EditorModeController::MaterialMode; + const bool edit = mode == EditorModeController::EditMode; + const bool show = material || edit; + vertexPaintAction->setVisible(show); + vertexPaintButton->setEnabled(show); + // Always disable both brushes on mode change so the user gets + // a fresh "off" state and the toolbar checkbox matches. + EditModeController::instance()->setVertexPaintEnabled(false); + TexturePaintController::instance()->setTexturePaintEnabled(false); }; refreshPaintBrushVisibility(); connect(EditorModeController::instance(), &EditorModeController::modeChanged, From 771629df6361306bfed0b6528be4aa409d7c7f79 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 09:53:06 -0400 Subject: [PATCH 17/40] fix(paint): build EditableMesh eagerly for hover, defer GPU rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hover and brush-ring overlay queries need the EditableMesh to run a UV hit-test. Before this change the mesh was only built when a paint session was created β€” which we now defer to the first stroke β€” so hover queries silently failed and the user saw "no brush circle on hover". EditableMesh is now built immediately when paint is enabled and an entity is selected (or when selection changes), independently of whether a GPU session exists. It's a pure CPU mirror of mesh data and has no rendering side effects. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 5f7f8810e..bdeada5c7 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -162,12 +162,13 @@ void TexturePaintController::setTexturePaintEnabled(bool enabled) // workspace mode to EditMode, kick the user out of Material // Mode, and (via the visibility hooks) silently disable us. // - // We do NOT auto-create a session here. Creating a session - // rebinds the model's diffuse TUS to a paint texture, which - // visibly changes the model (white if the existing texture - // couldn't be read back). Instead, the session is lazily - // created on the first stroke (beginStroke / beginStrokeUV) - // so toggling the brush button is non-destructive. + // We do NOT auto-create a paint session (no GPU texture + // rebind) here. The session is created lazily on the first + // stroke so toggling the brush is non-destructive. But we + // DO build the EditableMesh for the selected entity so + // hover queries (brush ring, UV preview) work immediately. + if (auto* e = activeEntity()) + ensureEditableMesh(e); refreshSlots(); } if (!enabled) { @@ -1188,6 +1189,16 @@ void TexturePaintController::refreshSlots() // calls refreshSlots) silently rebinds the model's diffuse TUS // and the user sees the model "go textureless". Sessions are // created lazily inside beginStroke() instead. + // + // But we DO eagerly build the EditableMesh for the selected + // entity so hover/brush-ring queries work without an active + // session. The EditableMesh is just a CPU mirror of mesh data; + // it doesn't alter the model's render. + if (m_paintEnabled) { + if (auto* e = activeEntity()) + ensureEditableMesh(e); + } + QVariantList newSlots; auto* entity = activeEntity(); if (entity) { From c59a18e4b3ba55b56a37883308e2e42d5e0127d6 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 09:55:03 -0400 Subject: [PATCH 18/40] fix(paint): mouse tracking + crosshair cursor for texture paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three symptoms with one root cause: the OgreWidget had mouse tracking disabled outside Edit-Mode vertex paint. Without tracking, mouse-move events only fired when LMB was held, so: - No brush-ring overlay on hover (updateMeshHover never reached). - No pointer feedback (cursor stayed default arrow). - "First click does nothing" (LMB press routes to box-select if the cursor wasn't being tracked over the mesh). Enable mouse tracking AND set a crosshair cursor whenever any paint mode is on (vertex OR texture), wired to fire via the same onSelectionChanged hook that already updates gizmos. Also added a TexturePaintController::texturePaintChanged β†’ onSelectionChanged connection so the tracking flag updates as soon as the user toggles the brush. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TransformOperator.cpp | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index f19057b05..59401a8d3 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -105,6 +105,10 @@ TransformOperator::TransformOperator() : QObject(nullptr) this, &TransformOperator::onSelectionChanged); connect(EditModeController::instance(), &EditModeController::vertexPaintChanged, this, &TransformOperator::onSelectionChanged); + // Texture paint toggle also affects mouse-tracking (we need + // hover events without a button press) and gizmo visibility. + connect(TexturePaintController::instance(), &TexturePaintController::texturePaintChanged, + this, &TransformOperator::onSelectionChanged); QtInputManager::getInstance().AddMouseListener(this); @@ -580,8 +584,14 @@ void TransformOperator::updateGizmo() m_pRotationGizmo->setVisible(false); m_pTranslationGizmo->setVisible(false); m_pScaleGizmo->setVisible(false); - mTrackingEnable = EditModeController::instance()->isEditModeActive() - && EditModeController::instance()->vertexPaintEnabled(); + // Enable mouse tracking when ANY paint mode is on so + // the hover preview / brush ring updates without a + // pressed button. Vertex paint requires Edit Mode; + // texture paint works in any mode. + mTrackingEnable = + (EditModeController::instance()->isEditModeActive() + && EditModeController::instance()->vertexPaintEnabled()) + || TexturePaintController::instance()->texturePaintEnabled(); break; case TransformOperator::TS_TRANSLATE: m_pTransformNode->setOrientation(gizmoOrientation); @@ -617,8 +627,16 @@ void TransformOperator::updateGizmo() m_pTranslationGizmo->setVisible(false); m_pScaleGizmo->setVisible(false); } - if(m_pActiveWidget) + if(m_pActiveWidget) { m_pActiveWidget->setMouseTracking(mTrackingEnable); + // Crosshair cursor while any paint mode is on so the user + // gets clear feedback that clicks will paint, not select. + const bool paintOn = + (EditModeController::instance()->isEditModeActive() + && EditModeController::instance()->vertexPaintEnabled()) + || TexturePaintController::instance()->texturePaintEnabled(); + m_pActiveWidget->setCursor(paintOn ? Qt::CrossCursor : Qt::ArrowCursor); + } } void TransformOperator::tickTransformGizmoScale(const Ogre::Camera* camera) From 21c4bb0a27721d420714ddf955c9dd98f586f1a2 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 10:08:36 -0400 Subject: [PATCH 19/40] fix(paint): brush button state reset + 4-strategy texture readback + hover-side mesh build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three user-reported issues addressed: 1. "Brush stops working after disabling texture paint" β€” when the workspace mode changes, refreshPaintBrushVisibility silently sets both paint controllers off but left the button visually checked. Next click toggled "onβ†’off" rather than enabling the new mode's paint. Now reset the button's checked flag along with the controllers. 2. "Model goes textureless on first paint" β€” the 3-strategy texture readback I added was still failing for some textures, leaving the buffer white. Added a 4th strategy (QImage from raw filesystem path) and a 2nd-strategy improvement that reads in the texture's native format and converts via Ogre::PixelUtil β€” some Metal/GL drivers reject the format-mismatch path used by blitToMemory(RGBA). Also added a fmt+size breadcrumb so we can diagnose remaining failure cases. 3. "Brush ring doesn't appear on hover" β€” the EditableMesh wasn't built on first hover when the user enabled paint before selecting the mesh. updateMeshHover now builds it lazily on first call so the brush ring shows as soon as the cursor touches the surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 74 +++++++++++++++++++++++++++------- src/mainwindow.cpp | 9 ++++- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index bdeada5c7..72e7eebc8 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -386,30 +386,41 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) // 2. Read from GPU directly via blitToMemory. Works when the // texture is on the GPU but its source Image buffer was // discarded post-upload (common for static textures). + // Reading in the texture's native format and letting Ogre + // convert is more reliable than asking for RGBA up front β€” + // some Metal/GL drivers refuse the format mismatch. if (!loadedExisting && existing) { try { if (!existing->isLoaded()) existing->load(); const int w = static_cast(existing->getWidth()); const int h = static_cast(existing->getHeight()); - if (w > 0 && h > 0) { + auto pixbuf = existing->getBuffer(); + if (w > 0 && h > 0 && pixbuf) { + const Ogre::PixelFormat srcFmt = existing->getFormat(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: blit native fmt=%1 size=%2x%3") + .arg(static_cast(srcFmt)).arg(w).arg(h)); + const size_t srcBytes = Ogre::PixelUtil::getMemorySize(w, h, 1, srcFmt); + std::vector srcBuf(srcBytes); + Ogre::PixelBox srcPb(w, h, 1, srcFmt, srcBuf.data()); + pixbuf->blitToMemory(srcPb); m_buffer.resize(w, h); - Ogre::PixelBox pb(w, h, 1, Ogre::PF_BYTE_RGBA, - m_buffer.data().data()); - auto pixbuf = existing->getBuffer(); - if (pixbuf) { - pixbuf->blitToMemory(pb); - m_buffer.clearDirty(); - loadedExisting = true; - loadError.clear(); - } + Ogre::PixelBox dstPb(w, h, 1, Ogre::PF_BYTE_RGBA, + m_buffer.data().data()); + Ogre::PixelUtil::bulkPixelConversion(srcPb, dstPb); + m_buffer.clearDirty(); + loadedExisting = true; + loadError.clear(); } + } catch (const Ogre::Exception& e) { + loadError = QStringLiteral("blit-native: ") + QString::fromStdString(e.getDescription()); } catch (...) { - // Try strategy 3. + loadError = QStringLiteral("blit-native: unknown exception"); } } - // 3. QImage reading via Ogre::Image::load β€” works when the - // texture name is also a filename in a registered location. + // 3. Ogre::Image::load β€” works when the texture name is also + // a filename in a registered resource location. if (!loadedExisting) { try { Ogre::Image img; @@ -427,8 +438,35 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) loadedExisting = true; loadError.clear(); } + } catch (const Ogre::Exception& e) { + loadError = QStringLiteral("image-load: ") + QString::fromStdString(e.getDescription()); + } catch (...) { + loadError = QStringLiteral("image-load: unknown"); + } + } + + // 4. QImage as a raw file path. Handles textures whose name + // is a relative or absolute filesystem path (some assets + // keep their texture name == on-disk path). + if (!loadedExisting) { + try { + QImage qimg(existingTex); + if (!qimg.isNull()) { + qimg = qimg.convertToFormat(QImage::Format_RGBA8888); + const int w = qimg.width(); + const int h = qimg.height(); + m_buffer.resize(w, h); + for (int y = 0; y < h; ++y) { + std::memcpy(m_buffer.data().data() + static_cast(y) * w * 4u, + qimg.constScanLine(y), + static_cast(w) * 4u); + } + m_buffer.clearDirty(); + loadedExisting = true; + loadError.clear(); + } } catch (...) { - // All three failed. + // All four failed. } } } @@ -1330,6 +1368,14 @@ void TexturePaintController::refreshPreviewUri() void TexturePaintController::updateMeshHover(OgreWidget* widget, const QPoint& screenPos) { if (!m_paintEnabled) { clearMeshHover(); return; } + // Build the EditableMesh on first hover if it isn't already + // ready. This handles the "user selected the mesh AFTER enabling + // paint" case so the brush ring appears as soon as the cursor + // touches the mesh. + if (!m_paintMesh || !m_paintMeshEntity) { + if (auto* e = activeEntity()) + ensureEditableMesh(e); + } Ogre::Vector2 uv; if (!hitTestUV(screenPos, widget, uv)) { clearMeshHover(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 8f670bba0..e6d5954e6 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1408,10 +1408,15 @@ void MainWindow::initToolBar() const bool show = material || edit; vertexPaintAction->setVisible(show); vertexPaintButton->setEnabled(show); - // Always disable both brushes on mode change so the user gets - // a fresh "off" state and the toolbar checkbox matches. + // Disable both brushes AND reset the button's checked state on + // mode change. Without resetting the checked flag, the user's + // next click toggles "onβ†’off" (since we silently set the + // controllers off but left the button visually checked), which + // looks like "the brush button stopped working". EditModeController::instance()->setVertexPaintEnabled(false); TexturePaintController::instance()->setTexturePaintEnabled(false); + QSignalBlocker b(vertexPaintButton); + vertexPaintButton->setChecked(false); }; refreshPaintBrushVisibility(); connect(EditorModeController::instance(), &EditorModeController::modeChanged, From 945fe3286a224373b134ee209c6add956daeb971 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 10:15:34 -0400 Subject: [PATCH 20/40] fix(paint): blit strokes directly to original texture when possible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yellow body + black brush patch in the latest screenshot was Ogre's "missing texture" fallback (yellow) plus our manual paint texture showing transparent black around the painted strokes. Two problems in one symptom: my readback strategies couldn't read the original texture (so the buffer was blank), and the rebind made the model sample our blank+strokes texture instead of the original. New approach: when flushing dirty rects, try blitting them **directly into the original texture's GPU buffer**. No rebind, no readback β€” just modify the existing pixels in place. Falls back to the manual-texture rebind path only if blit-to-original throws (e.g. the texture is read-only). This sidesteps both readback failures AND rebind ordering issues β€” the existing material binding keeps working, and we just nudge the sampled pixels. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 79 +++++++++++++++++++++++++++++++--- src/TexturePaintController.h | 8 ++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 72e7eebc8..5f9c98255 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -344,6 +344,21 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) QString existingTex = QString::fromStdString(tu->getTextureName()); bool loadedExisting = false; QString loadError; + // Track the original texture handle (not just its name) so we + // can paint directly into it via blitFromMemory and skip the + // TUS rebind entirely. This is the most reliable path on macOS + // Metal where "missing texture" fallback gives yellow and + // TU_DYNAMIC manual textures sometimes don't get uploaded. + Ogre::TexturePtr originalTex; + if (!existingTex.isEmpty()) { + try { + originalTex = Ogre::TextureManager::getSingleton().getByName( + existingTex.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + } catch (...) {} + } + m_originalTexture = originalTex; + m_originalTextureName = existingTex; if (!existingTex.isEmpty()) { // Try multiple strategies because imported PBR textures can // come from inline FBX embeds (no disk file), legacy on-disk @@ -533,7 +548,11 @@ bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, texName, group, Ogre::TEX_TYPE_2D, m_buffer.width(), m_buffer.height(), 0, Ogre::PF_BYTE_RGBA, - Ogre::TU_DYNAMIC_WRITE_ONLY_DISCARDABLE); + // Plain TU_DYNAMIC: write-only with no discard hint. The + // _DISCARDABLE variant can drop initial content on some + // drivers β€” bad for us since the initial blit IS the + // user's "current pixels" baseline. + Ogre::TU_DYNAMIC_WRITE_ONLY); if (!m_ogreTexture) return false; // Initial upload auto pixbuf = m_ogreTexture->getBuffer(); @@ -622,14 +641,59 @@ void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* ent void TexturePaintController::flushDirtyToOgre() { - if (!m_ogreTexture) return; const auto& dirty = m_buffer.dirtyRect(); if (dirty.empty()) return; - // First-flush deferred rebind: the model's diffuse TUSes are not - // rebound to the paint texture until the user actually paints - // something. This keeps "enable paint" non-destructive β€” the - // model only changes when there's a stroke to commit. + // Preferred path: paint directly INTO the model's original + // texture. No rebind needed β€” the existing material binding + // continues to work, and the paint just modifies pixels in place. + // Falls back to our manual texture + rebind if blit-to-original + // fails (e.g. immutable texture, format mismatch). + if (m_originalTexture && !m_useOriginalTexture) { + try { + auto pixbuf = m_originalTexture->getBuffer(); + if (pixbuf) { + const int W = m_buffer.width(); + const int rectW = dirty.width(); + const int rectH = dirty.height(); + std::vector slice(static_cast(rectW) * static_cast(rectH) * 4u); + const auto& src = m_buffer.data(); + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) + + static_cast(dirty.x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(slice.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); + } + Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, slice.data()); + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(pb, dst); + m_useOriginalTexture = true; + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: in-place blit to original succeeded"); + m_buffer.clearDirty(); + if (!m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } + return; + } + } catch (const Ogre::Exception& e) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place blit FAILED β†’ fallback to manual texture (%1)") + .arg(QString::fromStdString(e.getDescription()))); + // Fall through to manual-texture path. + } catch (...) {} + } + + if (!m_ogreTexture) return; + + // Fallback path: the model's diffuse TUSes are rebound to our + // manual paint texture on first flush. This is the original + // behaviour and works when blit-to-original isn't supported. if (m_boundSlots.empty() && m_paintMeshEntity) { rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); } @@ -1151,6 +1215,9 @@ void TexturePaintController::closeSession() m_paintMeshEntity = nullptr; m_buffer = TexturePaintBuffer(); m_textureName.clear(); + m_originalTexture.reset(); + m_originalTextureName.clear(); + m_useOriginalTexture = false; m_sessionEntity = nullptr; m_strokePreSnapshot.clear(); if (!m_uvOverlayUri.isEmpty()) { diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 5c3fab730..26072b1df 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -333,6 +333,14 @@ class TexturePaintController : public QObject TexturePaintBuffer m_buffer; QString m_textureName; Ogre::TexturePtr m_ogreTexture; + /// The original texture we're painting into. We blit our dirty + /// rects directly to its GPU buffer when possible, so the + /// existing material binding is untouched. Falls back to + /// `m_ogreTexture` (our manual paint texture) when the original + /// isn't writable. + Ogre::TexturePtr m_originalTexture; + QString m_originalTextureName; + bool m_useOriginalTexture = false; Ogre::Entity* m_sessionEntity = nullptr; bool m_strokeActive = false; From 1fac4e8a21e212dfa5ccb17a6f8f2ad5ab5ce11d Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 10:33:23 -0400 Subject: [PATCH 21/40] feat(paint): vertex paint moves to Material Mode; target picker; subentity rebind refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per user request: the paint brush is now exclusive to Material Mode (removed from Edit Mode), and supports BOTH vertex and texture painting via a new "Target" picker in the Texture Paint panel. - PaintTarget enum (TargetTexture / TargetVertex) on TexturePaintController. - beginStroke / updateStroke dispatch by target. Vertex paint re-uses EditModeController::applyVertexColorBrush (static) against the controller's own EditableMesh and commits via m_paintMesh->commitVertexColorsToEntity β€” no Edit Mode required. - Toolbar brush button is Material-Mode-only and writes the single TexturePaintController state. EditModeController vertex paint flag is no longer touched by the toolbar. Also addresses the "model loses texture on paint" issue from the last round: after rebindEntityDiffuseToPaintTexture, call SubEntity::setMaterialName(name) on each subentity to force the runtime material override pointer to refresh. The previous in-place blit path (write to original GPU texture) is still the preferred fast path; this is the rebind-fallback safety net. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 33 ++++++- src/TexturePaintController.cpp | 168 ++++++++++++++++++++++++++++++--- src/TexturePaintController.h | 24 +++++ src/mainwindow.cpp | 34 +++---- 4 files changed, 221 insertions(+), 38 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 67e9e5120..880748ab6 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1145,13 +1145,44 @@ Rectangle { } } Text { - text: "Enable texture paint mode" + text: "Enable paint mode" color: PropertiesPanelController.textColor font.pixelSize: 11 anchors.verticalCenter: parent.verticalCenter } } + // Paint target selector \u2014 Texture vs Vertex + Row { + spacing: 4 + Text { + text: "Target:" + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 50 + } + Repeater { + model: [ + { target: 0, label: "Texture" }, + { target: 1, label: "Vertex" } + ] + Rectangle { + width: 70; height: 24; radius: 3 + color: TexturePaintController.paintTarget === modelData.target + ? PropertiesPanelController.highlightColor + : (tgtMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor) + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: modelData.label + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: tgtMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.paintTarget = modelData.target + } + } + } + } + // Tool selector \u2014 Paint, Erase, Fill, Picker, Smudge Row { spacing: 4 diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 5f9c98255..da0e22d5b 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -193,6 +193,16 @@ void TexturePaintController::setBrushTool(int tool) emit brushToolChanged(); } +void TexturePaintController::setPaintTarget(int target) +{ + PaintTarget t = static_cast(target); + if (t == m_target) return; + m_target = t; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Paint target = %1").arg(target == TargetVertex ? "vertex" : "texture")); + emit paintTargetChanged(); +} + void TexturePaintController::setActiveSlotIndex(int index) { if (index < 0 || index >= m_slots.size()) return; @@ -637,6 +647,18 @@ void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* ent try { mat->compile(); mat->reload(); } catch (const Ogre::Exception&) {} } + // Also force re-binding at the SubEntity level. Some imports + // store per-subentity material overrides that cache the current + // pass state; setMaterialName(name) forces Ogre to refresh the + // subentity's render pass pointer to the modified material. + for (unsigned int se = 0; se < entity->getNumSubEntities(); ++se) { + auto* sub = entity->getSubEntity(se); + if (!sub) continue; + const std::string mname = sub->getMaterialName(); + if (!mname.empty()) { + try { sub->setMaterialName(mname); } catch (...) {} + } + } } void TexturePaintController::flushDirtyToOgre() @@ -648,29 +670,54 @@ void TexturePaintController::flushDirtyToOgre() // texture. No rebind needed β€” the existing material binding // continues to work, and the paint just modifies pixels in place. // Falls back to our manual texture + rebind if blit-to-original - // fails (e.g. immutable texture, format mismatch). - if (m_originalTexture && !m_useOriginalTexture) { + // fails (e.g. immutable texture, format mismatch, Metal storage + // mode rejects CPU writes). + if (m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); if (pixbuf) { const int W = m_buffer.width(); const int rectW = dirty.width(); const int rectH = dirty.height(); - std::vector slice(static_cast(rectW) * static_cast(rectH) * 4u); + // Blit in the texture's NATIVE format. blitFromMemory + // does internal conversion if the source PixelBox + // format differs, but on some Metal backends the + // conversion silently produces no upload β€” so we + // convert our RGBA8 source to the texture's format + // up front and submit it raw. + const Ogre::PixelFormat dstFmt = m_originalTexture->getFormat(); + const size_t dstBytes = Ogre::PixelUtil::getMemorySize(rectW, rectH, 1, dstFmt); + std::vector slice(dstBytes); + Ogre::PixelBox srcRgba(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, nullptr); + std::vector srcRow(static_cast(rectW) * static_cast(rectH) * 4u); const auto& src = m_buffer.data(); for (int row = 0; row < rectH; ++row) { const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) + static_cast(dirty.x0)) * 4u; const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; - std::memcpy(slice.data() + dstOff, src.data() + srcOff, + std::memcpy(srcRow.data() + dstOff, src.data() + srcOff, static_cast(rectW) * 4u); } - Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, slice.data()); - Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); - pixbuf->blitFromMemory(pb, dst); + srcRgba.data = srcRow.data(); + if (dstFmt == Ogre::PF_BYTE_RGBA) { + // No conversion needed. + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(srcRgba, dst); + } else { + Ogre::PixelBox dstPb(rectW, rectH, 1, dstFmt, slice.data()); + Ogre::PixelUtil::bulkPixelConversion(srcRgba, dstPb); + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(dstPb, dst); + } m_useOriginalTexture = true; - SentryReporter::addBreadcrumb("ui.action", - "Texture paint: in-place blit to original succeeded"); + if (!m_loggedInPlaceBlit) { + m_loggedInPlaceBlit = true; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place blit fmt=%1 size=%2x%3") + .arg(static_cast(dstFmt)) + .arg(m_originalTexture->getWidth()) + .arg(m_originalTexture->getHeight())); + } m_buffer.clearDirty(); if (!m_previewRefreshScheduled) { m_previewRefreshScheduled = true; @@ -685,8 +732,12 @@ void TexturePaintController::flushDirtyToOgre() SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: in-place blit FAILED β†’ fallback to manual texture (%1)") .arg(QString::fromStdString(e.getDescription()))); - // Fall through to manual-texture path. - } catch (...) {} + // Fall through to manual-texture path. Clear the + // original-texture handle so we don't keep trying. + m_originalTexture.reset(); + } catch (...) { + m_originalTexture.reset(); + } } if (!m_ogreTexture) return; @@ -806,19 +857,33 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree .arg(m_paintEnabled).arg(m_strokeActive)); return false; } - if (!hasActiveSession()) { - if (!ensurePaintableTexture(m_buffer.width() > 0 ? m_buffer.width() : 1024)) { + if (m_target == TargetVertex) { + // Vertex paint just needs the EditableMesh built β€” no GPU + // texture, no rebind. Build it now if it isn't ready. + if (auto* e = activeEntity()) + ensureEditableMesh(e); + if (!m_paintMesh || !m_paintMeshEntity) { SentryReporter::addBreadcrumb("ui.action", - "Texture paint: beginStroke aborted β€” no session could be created"); + "Vertex paint: beginStroke aborted β€” no mesh"); return false; } + m_paintMesh->ensureVertexColorBuffers(m_paintMeshEntity); + } else { + if (!hasActiveSession()) { + if (!ensurePaintableTexture(m_buffer.width() > 0 ? m_buffer.width() : 1024)) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: beginStroke aborted β€” no session could be created"); + return false; + } + } } m_strokeActive = true; m_strokeJustBegan = true; m_smudgeHavePrev = false; m_strokePreSnapshot = snapshotPixels(); SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint stroke begin (tool=%1 radius=%2 strength=%3 color=%4)") + QStringLiteral("Paint stroke begin (target=%1 tool=%2 radius=%3 strength=%4 color=%5)") + .arg(m_target == TargetVertex ? "vertex" : "texture") .arg(static_cast(m_tool)) .arg(texturePaintRadius(), 0, 'f', 3) .arg(texturePaintStrength(), 0, 'f', 3) @@ -830,6 +895,27 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& screenPos) { if (!m_strokeActive || !m_paintEnabled) return; + + if (m_target == TargetVertex) { + // Vertex paint: get local-space hit point and apply the + // vertex-color brush directly on m_paintMesh. + if (!m_paintMesh || !m_paintMeshEntity) return; + Ogre::Vector3 localPos, localNormal; + if (!hitTestLocalPoint(widget, screenPos, localPos, localNormal)) return; + drawHoverRingAt(localPos, localNormal); + const QColor c = texturePaintColor(); + const Ogre::ColourValue paint(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + const bool changed = EditModeController::applyVertexColorBrush( + *m_paintMesh, localPos, + static_cast(texturePaintRadius()), + paint, + static_cast(texturePaintStrength()), + static_cast(texturePaintFalloff())); + if (changed) + m_paintMesh->commitVertexColorsToEntity(m_paintMeshEntity); + return; + } + Ogre::Vector2 uv; if (!hitTestUV(screenPos, widget, uv)) { clearHoveredUV(); @@ -1218,6 +1304,7 @@ void TexturePaintController::closeSession() m_originalTexture.reset(); m_originalTextureName.clear(); m_useOriginalTexture = false; + m_loggedInPlaceBlit = false; m_sessionEntity = nullptr; m_strokePreSnapshot.clear(); if (!m_uvOverlayUri.isEmpty()) { @@ -1513,6 +1600,57 @@ void TexturePaintController::clearMeshHover() emit hoveredUVChanged(-1.0, -1.0); } +bool TexturePaintController::hitTestLocalPoint(OgreWidget* widget, const QPoint& screenPos, + Ogre::Vector3& outLocal, Ogre::Vector3& outNormal) const +{ + if (!m_paintMesh || !m_paintMeshEntity || !widget) return false; + auto* spaceCam = widget->getSpaceCamera(); + auto* camera = spaceCam ? spaceCam->getCamera() : nullptr; + if (!camera) return false; + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return false; + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = camera->getCameraToViewportRay(nx, ny); + Ogre::SceneNode* node = m_paintMeshEntity->getParentSceneNode(); + Ogre::Affine3 worldToLocal = node ? node->_getFullTransform().inverse() : Ogre::Affine3::IDENTITY; + Ogre::Vector3 localOrigin = worldToLocal * ray.getOrigin(); + Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); + localDir.normalise(); + Ogre::Real bestT = std::numeric_limits::infinity(); + bool found = false; + for (const auto& sub : m_paintMesh->subMeshes()) { + for (const auto& tri : sub.triangles) { + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + const Ogre::Vector3 e1 = v1.position - v0.position; + const Ogre::Vector3 e2 = v2.position - v0.position; + const Ogre::Vector3 pvec = localDir.crossProduct(e2); + const Ogre::Real det = e1.dotProduct(pvec); + if (std::abs(det) < 1e-8f) continue; + const Ogre::Real invDet = 1.0f / det; + const Ogre::Vector3 tvec = localOrigin - v0.position; + const Ogre::Real u = tvec.dotProduct(pvec) * invDet; + if (u < 0.0f || u > 1.0f) continue; + const Ogre::Vector3 qvec = tvec.crossProduct(e1); + const Ogre::Real v = localDir.dotProduct(qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) continue; + const Ogre::Real tHit = e2.dotProduct(qvec) * invDet; + if (tHit <= 0.0f || tHit >= bestT) continue; + bestT = tHit; + outLocal = localOrigin + localDir * tHit; + Ogre::Vector3 n = e1.crossProduct(e2); + if (!n.isZeroLength()) n.normalise(); + else n = Ogre::Vector3::UNIT_Y; + outNormal = n; + found = true; + } + } + return found; +} + bool TexturePaintController::findMeshPointForUV(const Ogre::Vector2& uv, Ogre::Vector3& outLocal, Ogre::Vector3& outNormal) const diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 26072b1df..6acd0c743 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -68,6 +68,9 @@ class TexturePaintController : public QObject // Brush tool β€” paint / erase / fill / picker. Q_PROPERTY(int brushTool READ brushTool WRITE setBrushTool NOTIFY brushToolChanged) + // Paint target β€” texture or vertex. + Q_PROPERTY(int paintTarget READ paintTarget WRITE setPaintTarget NOTIFY paintTargetChanged) + // Live preview of the current paint buffer as a data URI, so the QML // preview panel can render it via Image { source: ... }. Emitted on // every dirty-rect flush so the preview stays in sync with strokes. @@ -89,6 +92,13 @@ class TexturePaintController : public QObject }; Q_ENUM(BrushTool) + /// What the brush paints into. + enum PaintTarget { + TargetTexture = 0, ///< Paint into the BaseColor texture (default). + TargetVertex = 1, ///< Paint vertex colors (formerly Edit Mode's vertex paint). + }; + Q_ENUM(PaintTarget) + static TexturePaintController* instance(); static TexturePaintController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); static void kill(); @@ -120,6 +130,12 @@ class TexturePaintController : public QObject void setBrushTool(int tool); /// @} + /// @name Paint target (texture or vertex colors) + /// @{ + int paintTarget() const { return static_cast(m_target); } + void setPaintTarget(int target); + /// @} + /// @name Texture slot enumeration (selection-driven) /// @{ QVariantList textureSlots() const { return m_slots; } @@ -243,6 +259,7 @@ class TexturePaintController : public QObject void texturePaintChanged(); void sessionChanged(); void brushToolChanged(); + void paintTargetChanged(); void slotsChanged(); void previewChanged(); void uvOverlayChanged(); @@ -273,6 +290,11 @@ class TexturePaintController : public QObject /// false on miss. bool hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const; + /// Same hit-test but returns the local-space position and normal + /// at the hit (for vertex paint, which works in 3D space). + bool hitTestLocalPoint(OgreWidget* widget, const QPoint& screenPos, + Ogre::Vector3& outLocal, Ogre::Vector3& outNormal) const; + /// Allocate a new manual Ogre::Texture with current buffer /// dimensions. When `rebindToModel` is true, also walk the /// entity's materials and rebind diffuse TUSes pointing at the @@ -341,12 +363,14 @@ class TexturePaintController : public QObject Ogre::TexturePtr m_originalTexture; QString m_originalTextureName; bool m_useOriginalTexture = false; + bool m_loggedInPlaceBlit = false; Ogre::Entity* m_sessionEntity = nullptr; bool m_strokeActive = false; bool m_strokeJustBegan = false; ///< Fill/picker tools fire only once per stroke. std::vector m_strokePreSnapshot; // for undo BrushTool m_tool = ToolPaint; + PaintTarget m_target = TargetTexture; /// Track every TUS we rebound to the paint texture so closeSession() /// can restore the originals. We keep the *material name* (not a diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e6d5954e6..be8af4dbc 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1365,30 +1365,20 @@ void MainWindow::initToolBar() }); connect(vertexPaintButton, &QToolButton::toggled, this, [this](bool on) { - const auto mode = EditorModeController::instance()->currentMode(); - const bool material = mode == EditorModeController::MaterialMode; - const bool edit = mode == EditorModeController::EditMode; SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Toolbar: Paint brush %1 (mode=%2)") - .arg(on ? QStringLiteral("on") : QStringLiteral("off")) - .arg(material ? "material" : edit ? "edit" : "other")); - // Pick the right brush for the current mode. Vertex paint - // needs Edit Mode; texture paint works in Material Mode. - if (edit) - EditModeController::instance()->setVertexPaintEnabled(on); - if (material) - TexturePaintController::instance()->setTexturePaintEnabled(on); + QStringLiteral("Toolbar: Paint brush %1").arg(on ? "on" : "off")); + // All painting goes through TexturePaintController. The + // controller's "paint target" enum picks between texture + // paint and vertex paint per stroke. + TexturePaintController::instance()->setTexturePaintEnabled(on); if (on) setTransformState(TransformOperator::TS_SELECT); }); auto syncPaintBtnChecked = [vertexPaintButton]() { - const bool on = EditModeController::instance()->vertexPaintEnabled() - || TexturePaintController::instance()->texturePaintEnabled(); + const bool on = TexturePaintController::instance()->texturePaintEnabled(); QSignalBlocker b(vertexPaintButton); vertexPaintButton->setChecked(on); }; - connect(EditModeController::instance(), &EditModeController::vertexPaintChanged, - this, syncPaintBtnChecked); connect(TexturePaintController::instance(), &TexturePaintController::texturePaintChanged, this, syncPaintBtnChecked); @@ -1404,15 +1394,15 @@ void MainWindow::initToolBar() auto refreshPaintBrushVisibility = [vertexPaintButton, vertexPaintAction]() { const auto mode = EditorModeController::instance()->currentMode(); const bool material = mode == EditorModeController::MaterialMode; - const bool edit = mode == EditorModeController::EditMode; - const bool show = material || edit; - vertexPaintAction->setVisible(show); - vertexPaintButton->setEnabled(show); + // Paint brush lives in Material Mode only. The user chooses + // between Vertex and Texture painting via the panel's target + // picker; both run through TexturePaintController. + vertexPaintAction->setVisible(material); + vertexPaintButton->setEnabled(material); // Disable both brushes AND reset the button's checked state on // mode change. Without resetting the checked flag, the user's // next click toggles "onβ†’off" (since we silently set the - // controllers off but left the button visually checked), which - // looks like "the brush button stopped working". + // controllers off but left the button visually checked). EditModeController::instance()->setVertexPaintEnabled(false); TexturePaintController::instance()->setTexturePaintEnabled(false); QSignalBlocker b(vertexPaintButton); From 087d030cffe0dd8f89c333d70ee18ff585505d27 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 10:49:45 -0400 Subject: [PATCH 22/40] feat(paint): tri-state target switch (Off/Vertex/Texture); default Vertex Replaced the enable checkbox + separate target row with a single 3-button switch: Off / Vertex / Texture. Clicking Vertex or Texture both enables paint and sets the target in one action. Clicking Off disables paint. Default target is now Vertex. Also wired paintTargetChanged through the QML Connections block so the switch buttons highlight reactively, and reworded the description text to cover both paint kinds. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 67 ++++++++++++++++++------------------ src/TexturePaintController.h | 2 +- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 880748ab6..b0874c86c 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1090,6 +1090,7 @@ Rectangle { property var slots: TexturePaintController.textureSlots property int activeSlot: TexturePaintController.activeSlotIndex property int brushTool: TexturePaintController.brushTool + property int paintTarget: TexturePaintController.paintTarget property string previewUri: TexturePaintController.previewDataUri // Live hover position in UV space, fed by hoveredUVChanged. property real hoverU: -1 @@ -1114,6 +1115,9 @@ Rectangle { function onBrushToolChanged() { texPaintCol.brushTool = TexturePaintController.brushTool } + function onPaintTargetChanged() { + texPaintCol.paintTarget = TexturePaintController.paintTarget + } function onHoveredUVChanged(u, v) { texPaintCol.hoverU = u texPaintCol.hoverV = v @@ -1122,62 +1126,57 @@ Rectangle { Text { width: parent.width - 16 - text: "Paint directly into a BaseColor texture. " + - "The brush color/radius/strength/falloff comes from the " + - "Paint Brush section above (same brush used by the toolbar " + - "Vertex Paint popup)." + text: "Paint into the model β€” either as vertex colors " + + "(polypaint, exported with the mesh) or into the " + + "BaseColor texture. Pick the target below." color: PropertiesPanelController.textColor font.pixelSize: 10; opacity: 0.7 wrapMode: Text.Wrap } - // Enable toggle - Row { - spacing: 6 - Rectangle { - width: 18; height: 18; radius: 3 - color: texPaintCol.paintOn ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor; border.width: 1 - Text { anchors.centerIn: parent; text: texPaintCol.paintOn ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } - MouseArea { - anchors.fill: parent; cursorShape: Qt.PointingHandCursor - onClicked: { TexturePaintController.texturePaintEnabled = !texPaintCol.paintOn } - } - } - Text { - text: "Enable paint mode" - color: PropertiesPanelController.textColor - font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - } - - // Paint target selector \u2014 Texture vs Vertex + // Paint target switch \u2014 picking a target also enables paint. + // Three states: Off / Vertex / Texture. Defaults to Vertex. Row { spacing: 4 Text { - text: "Target:" + text: "Paint:" color: PropertiesPanelController.textColor; font.pixelSize: 11 anchors.verticalCenter: parent.verticalCenter width: 50 } Repeater { model: [ - { target: 0, label: "Texture" }, - { target: 1, label: "Vertex" } + { target: -1, label: "Off" }, + { target: 1, label: "Vertex" }, + { target: 0, label: "Texture" } ] Rectangle { - width: 70; height: 24; radius: 3 - color: TexturePaintController.paintTarget === modelData.target + width: 70; height: 26; radius: 3 + property bool isActive: modelData.target === -1 + ? !texPaintCol.paintOn + : (texPaintCol.paintOn && texPaintCol.paintTarget === modelData.target) + color: isActive ? PropertiesPanelController.highlightColor : (tgtMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) : PropertiesPanelController.headerColor) border.color: PropertiesPanelController.borderColor; border.width: 1 - Text { anchors.centerIn: parent; text: modelData.label - color: PropertiesPanelController.textColor; font.pixelSize: 10 } + Text { + anchors.centerIn: parent + text: modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } MouseArea { id: tgtMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.paintTarget = modelData.target + onClicked: { + if (modelData.target === -1) { + TexturePaintController.texturePaintEnabled = false + } else { + TexturePaintController.paintTarget = modelData.target + if (!texPaintCol.paintOn) + TexturePaintController.texturePaintEnabled = true + } + } } } } diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 6acd0c743..e5c7d100a 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -370,7 +370,7 @@ class TexturePaintController : public QObject bool m_strokeJustBegan = false; ///< Fill/picker tools fire only once per stroke. std::vector m_strokePreSnapshot; // for undo BrushTool m_tool = ToolPaint; - PaintTarget m_target = TargetTexture; + PaintTarget m_target = TargetVertex; /// Track every TUS we rebound to the paint texture so closeSession() /// can restore the originals. We keep the *material name* (not a From 1d32b1174958f5533079bd9cb0291cce64840bf7 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 10:56:09 -0400 Subject: [PATCH 23/40] fix(paint): re-resolve original texture each flush + safer shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes addressing the user-reported crash during texture paint: 1. Re-resolve m_originalTexture from TextureManager by name on every flush. RTSS material reload (which can happen between strokes for various reasons) can invalidate the cached TexturePtr, so calling getBuffer() on a stale handle segfaults. Looking up by name each time keeps the handle fresh. 2. Removed the SubEntity::setMaterialName refresh call I added in the previous round β€” it was likely causing material rebind during render, invalidating pointers we held. 3. Safer destructor: if Ogre::Root is already gone, skip closeSession() and null out raw handles directly. Avoids touching destroyed Ogre singletons at process exit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 44 +++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index da0e22d5b..d3ca4723a 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -147,8 +147,24 @@ TexturePaintController::TexturePaintController(QObject* parent) TexturePaintController::~TexturePaintController() { // Drop the manual objects on the scene before Ogre destructors - // race us on shutdown. closeSession is safe to call repeatedly. - closeSession(); + // race us on shutdown. Skip the cleanup entirely if Ogre's + // singletons are already gone β€” touching them post-teardown + // segfaults at process exit. + if (Ogre::Root::getSingletonPtr() == nullptr) { + m_paintMesh.reset(); + m_ogreTexture.reset(); + m_originalTexture.reset(); + m_ringNode = nullptr; + m_ringObj = nullptr; + m_paintMeshEntity = nullptr; + m_sessionEntity = nullptr; + return; + } + try { + closeSession(); + } catch (...) { + // Best-effort shutdown. + } } void TexturePaintController::setTexturePaintEnabled(bool enabled) @@ -647,18 +663,6 @@ void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* ent try { mat->compile(); mat->reload(); } catch (const Ogre::Exception&) {} } - // Also force re-binding at the SubEntity level. Some imports - // store per-subentity material overrides that cache the current - // pass state; setMaterialName(name) forces Ogre to refresh the - // subentity's render pass pointer to the modified material. - for (unsigned int se = 0; se < entity->getNumSubEntities(); ++se) { - auto* sub = entity->getSubEntity(se); - if (!sub) continue; - const std::string mname = sub->getMaterialName(); - if (!mname.empty()) { - try { sub->setMaterialName(mname); } catch (...) {} - } - } } void TexturePaintController::flushDirtyToOgre() @@ -672,6 +676,18 @@ void TexturePaintController::flushDirtyToOgre() // Falls back to our manual texture + rebind if blit-to-original // fails (e.g. immutable texture, format mismatch, Metal storage // mode rejects CPU writes). + // + // Re-resolve the handle by name every flush in case the texture + // was reloaded/destroyed between strokes (RTSS material reload + // can invalidate the TexturePtr). + if (!m_originalTextureName.isEmpty()) { + try { + auto fresh = Ogre::TextureManager::getSingleton().getByName( + m_originalTextureName.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + if (fresh) m_originalTexture = fresh; + } catch (...) {} + } if (m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); From 5a89aea14e0d23ad086744b0ac61c940e927fbce Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 11:47:34 -0400 Subject: [PATCH 24/40] fix(paint): skip compressed formats + defer first-flush rebind The crash during texture paint was most likely due to two issues: 1. bulkPixelConversion to a compressed (DXT/BC) format from RGBA8 can crash on Metal because there's no built-in CPU compressor. Now we only run the in-place blit when the original texture's format is a known plain uncompressed RGBA/BGR variant. For compressed textures we fall through to the manual-texture + rebind path. 2. rebindEntityDiffuseToPaintTexture ran mat->compile() + mat->reload() on the same call stack as the mouse-move event handler. Material reload can destroy/recreate render passes mid- render. Now deferred via QTimer::singleShot(0) so the rebind happens on the next event-loop tick, off the mouse-handler stack. Also removed leftover duplicate "successful blit" code that was running both in the format-aware and non-aware paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 114 ++++++++++++++++++++------------- src/TexturePaintController.h | 1 + 2 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index d3ca4723a..e123b58e3 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -702,47 +702,66 @@ void TexturePaintController::flushDirtyToOgre() // convert our RGBA8 source to the texture's format // up front and submit it raw. const Ogre::PixelFormat dstFmt = m_originalTexture->getFormat(); - const size_t dstBytes = Ogre::PixelUtil::getMemorySize(rectW, rectH, 1, dstFmt); - std::vector slice(dstBytes); - Ogre::PixelBox srcRgba(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, nullptr); - std::vector srcRow(static_cast(rectW) * static_cast(rectH) * 4u); - const auto& src = m_buffer.data(); - for (int row = 0; row < rectH; ++row) { - const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) - + static_cast(dirty.x0)) * 4u; - const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; - std::memcpy(srcRow.data() + dstOff, src.data() + srcOff, - static_cast(rectW) * 4u); - } - srcRgba.data = srcRow.data(); - if (dstFmt == Ogre::PF_BYTE_RGBA) { - // No conversion needed. - Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); - pixbuf->blitFromMemory(srcRgba, dst); - } else { - Ogre::PixelBox dstPb(rectW, rectH, 1, dstFmt, slice.data()); - Ogre::PixelUtil::bulkPixelConversion(srcRgba, dstPb); - Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); - pixbuf->blitFromMemory(dstPb, dst); - } - m_useOriginalTexture = true; - if (!m_loggedInPlaceBlit) { - m_loggedInPlaceBlit = true; + // Only handle plain uncompressed formats in-place. + // Compressed formats (DXT/BC) need real CPU encoders + // which can crash bulkPixelConversion on Metal. + const bool plainFmt = + dstFmt == Ogre::PF_BYTE_RGBA + || dstFmt == Ogre::PF_BYTE_RGB + || dstFmt == Ogre::PF_BYTE_BGRA + || dstFmt == Ogre::PF_BYTE_BGR + || dstFmt == Ogre::PF_A8R8G8B8 + || dstFmt == Ogre::PF_R8G8B8A8 + || dstFmt == Ogre::PF_A8B8G8R8 + || dstFmt == Ogre::PF_X8R8G8B8 + || dstFmt == Ogre::PF_R8G8B8; + if (!plainFmt) { SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: in-place blit fmt=%1 size=%2x%3") - .arg(static_cast(dstFmt)) - .arg(m_originalTexture->getWidth()) - .arg(m_originalTexture->getHeight())); - } - m_buffer.clearDirty(); - if (!m_previewRefreshScheduled) { - m_previewRefreshScheduled = true; - QTimer::singleShot(60, this, [this]() { - m_previewRefreshScheduled = false; - refreshPreviewUri(); - }); + QStringLiteral("Texture paint: in-place skipped β€” compressed format %1") + .arg(static_cast(dstFmt))); + m_originalTexture.reset(); + // Fall through to manual-texture path. + } else { + std::vector srcRow(static_cast(rectW) * static_cast(rectH) * 4u); + const auto& src = m_buffer.data(); + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) + + static_cast(dirty.x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(srcRow.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); + } + Ogre::PixelBox srcRgba(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, srcRow.data()); + if (dstFmt == Ogre::PF_BYTE_RGBA) { + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(srcRgba, dst); + } else { + const size_t dstBytes = Ogre::PixelUtil::getMemorySize(rectW, rectH, 1, dstFmt); + std::vector slice(dstBytes); + Ogre::PixelBox dstPb(rectW, rectH, 1, dstFmt, slice.data()); + Ogre::PixelUtil::bulkPixelConversion(srcRgba, dstPb); + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(dstPb, dst); + } + m_useOriginalTexture = true; + if (!m_loggedInPlaceBlit) { + m_loggedInPlaceBlit = true; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place blit fmt=%1 size=%2x%3") + .arg(static_cast(dstFmt)) + .arg(m_originalTexture->getWidth()) + .arg(m_originalTexture->getHeight())); + } + m_buffer.clearDirty(); + if (!m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } + return; } - return; } } catch (const Ogre::Exception& e) { SentryReporter::addBreadcrumb("ui.action", @@ -759,10 +778,18 @@ void TexturePaintController::flushDirtyToOgre() if (!m_ogreTexture) return; // Fallback path: the model's diffuse TUSes are rebound to our - // manual paint texture on first flush. This is the original - // behaviour and works when blit-to-original isn't supported. - if (m_boundSlots.empty() && m_paintMeshEntity) { - rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); + // manual paint texture on first flush. Defer the rebind via a + // singleShot timer so material compile/reload doesn't run on + // the same call stack as the mouse-move event β€” that was + // racing against the active stroke and crashing. + if (m_boundSlots.empty() && m_paintMeshEntity && !m_rebindScheduled) { + m_rebindScheduled = true; + Ogre::Entity* ent = m_paintMeshEntity; + QTimer::singleShot(0, this, [this, ent]() { + m_rebindScheduled = false; + if (m_paintMeshEntity == ent && m_boundSlots.empty()) + rebindEntityDiffuseToPaintTexture(ent); + }); } try { auto buf = m_ogreTexture->getBuffer(); @@ -1321,6 +1348,7 @@ void TexturePaintController::closeSession() m_originalTextureName.clear(); m_useOriginalTexture = false; m_loggedInPlaceBlit = false; + m_rebindScheduled = false; m_sessionEntity = nullptr; m_strokePreSnapshot.clear(); if (!m_uvOverlayUri.isEmpty()) { diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index e5c7d100a..a3bce5e8a 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -364,6 +364,7 @@ class TexturePaintController : public QObject QString m_originalTextureName; bool m_useOriginalTexture = false; bool m_loggedInPlaceBlit = false; + bool m_rebindScheduled = false; Ogre::Entity* m_sessionEntity = nullptr; bool m_strokeActive = false; From 68af5c282edd514d323bbcc335f06e967d21cce1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 11:49:49 -0400 Subject: [PATCH 25/40] fix(paint): rebind eagerly on session create; disable in-place blit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted both the deferred-rebind and the in-place-blit optimizations β€” they introduced races/crashes during active strokes. Restored the eager rebind: createOgreTextureFromBuffer at session create binds the manual paint texture to the model's diffuse TUSes before any stroke fires. No mid-stroke material reloads, no re-entrant render hazards, no compressed-format conversions. The user reported "material painting working well" prior to those optimizations β€” this returns to that baseline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index e123b58e3..4ab714c0f 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -530,7 +530,12 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) QString hint = QStringLiteral("QMEPaint_%1_%2") .arg(QString::fromStdString(entity->getName())) .arg(++s_unique); - if (!createOgreTextureFromBuffer(entity, hint)) { + // Rebind eagerly on session create. The deferred / lazy rebind + // approach was racing with the mouse-move event stack and + // crashing mid-stroke. Doing it at session-create time happens + // BEFORE the first stroke fires, so there's no re-entrant render + // hazard. + if (!createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/true)) { m_buffer = TexturePaintBuffer(); m_textureName.clear(); m_sessionEntity = nullptr; @@ -670,25 +675,12 @@ void TexturePaintController::flushDirtyToOgre() const auto& dirty = m_buffer.dirtyRect(); if (dirty.empty()) return; - // Preferred path: paint directly INTO the model's original - // texture. No rebind needed β€” the existing material binding - // continues to work, and the paint just modifies pixels in place. - // Falls back to our manual texture + rebind if blit-to-original - // fails (e.g. immutable texture, format mismatch, Metal storage - // mode rejects CPU writes). - // - // Re-resolve the handle by name every flush in case the texture - // was reloaded/destroyed between strokes (RTSS material reload - // can invalidate the TexturePtr). - if (!m_originalTextureName.isEmpty()) { - try { - auto fresh = Ogre::TextureManager::getSingleton().getByName( - m_originalTextureName.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); - if (fresh) m_originalTexture = fresh; - } catch (...) {} - } - if (m_originalTexture) { + // Previously we tried to blit directly into the original texture + // here. That path crashed mid-stroke on macOS Metal β€” likely + // because the original texture's getBuffer() readback is unsafe + // while a stroke is in flight. Always use the manual-texture + // path (rebind happened at session create). + if (false && m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); if (pixbuf) { From fbb86803a58d2885cffd5ccfd27eb5c308eb70f3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 11:57:43 -0400 Subject: [PATCH 26/40] fix(paint): bind TexturePtr directly + restore in-place blit fast path Two changes targeting "texture not displaying" + the crash: - Rebind uses tusN->setTexture(TexturePtr) instead of setTextureName(name). Direct binding avoids resource-group name resolution ambiguity that can fail on macOS Metal and leave the TUS sampling Ogre's missing-texture fallback (yellow). - Restored the in-place blit fast path so painting modifies the original texture in place when possible (best case: no rebind needed at all). The compressed-format skip from the previous commit is preserved so we don't crash trying to bulkPixelConversion to DXT/BC. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 4ab714c0f..22ca81827 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -654,7 +654,14 @@ void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* ent s.tusIdx = ti; s.originalTexture = currentTex; m_boundSlots.push_back(s); - tusN->setTextureName(texName); + // Bind the TexturePtr directly. setTextureName + // does name resolution that can pick the wrong + // group on macOS/Metal. Direct binding never + // mis-resolves. + if (m_ogreTexture) + tusN->setTexture(m_ogreTexture); + else + tusN->setTextureName(texName); changed = true; } } @@ -675,12 +682,20 @@ void TexturePaintController::flushDirtyToOgre() const auto& dirty = m_buffer.dirtyRect(); if (dirty.empty()) return; - // Previously we tried to blit directly into the original texture - // here. That path crashed mid-stroke on macOS Metal β€” likely - // because the original texture's getBuffer() readback is unsafe - // while a stroke is in flight. Always use the manual-texture - // path (rebind happened at session create). - if (false && m_originalTexture) { + // Preferred path: paint directly INTO the model's original + // texture. No rebind needed β€” the existing material binding + // continues to work, and the paint just modifies pixels in place. + // Re-resolve the handle each flush so a reloaded material + // doesn't dangle. + if (!m_originalTextureName.isEmpty()) { + try { + auto fresh = Ogre::TextureManager::getSingleton().getByName( + m_originalTextureName.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + if (fresh) m_originalTexture = fresh; + } catch (...) {} + } + if (m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); if (pixbuf) { From 651bc9f513ea6d3a3536482bcf43fed3bd810978 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 12:03:10 -0400 Subject: [PATCH 27/40] fix(paint): upload full buffer per stroke (sub-rect blit unreliable on Metal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebind to the manual paint texture, strokes were blitting only the dirty rect via blitFromMemory(box). On macOS Metal that sub- rect path sometimes produces no visible update β€” the upload appears to land in the GPU texture but the renderer keeps sampling the previous content. Switched to uploading the full buffer per flush. Heavier per stroke (~4 MB at 1024Β² vs ~hundreds of KB for a typical brush stamp) but the model updates reliably. The preview debounce already shields CPU cost on the QML side. Also added a guard so the in-place blit path is skipped once the rebind has fired β€” without this, strokes were writing to the original texture (which is no longer bound) and never to the new paint texture (which is bound). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 22ca81827..cd02f5156 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -695,7 +695,10 @@ void TexturePaintController::flushDirtyToOgre() if (fresh) m_originalTexture = fresh; } catch (...) {} } - if (m_originalTexture) { + // Skip the in-place path once we've rebound the model to the + // manual paint texture β€” the original is no longer what the + // renderer samples, so blitting to it would be invisible. + if (m_boundSlots.empty() && m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); if (pixbuf) { @@ -802,21 +805,15 @@ void TexturePaintController::flushDirtyToOgre() auto buf = m_ogreTexture->getBuffer(); if (!buf) return; const int W = m_buffer.width(); - const int rectW = dirty.width(); - const int rectH = dirty.height(); - // Build a contiguous slice from the dirty rect. - std::vector slice(static_cast(rectW) * static_cast(rectH) * 4u); - const auto& src = m_buffer.data(); - for (int row = 0; row < rectH; ++row) { - const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) - + static_cast(dirty.x0)) * 4u; - const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; - std::memcpy(slice.data() + dstOff, src.data() + srcOff, - static_cast(rectW) * 4u); - } - Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, slice.data()); - Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); - buf->blitFromMemory(pb, dst); + const int H = m_buffer.height(); + // Upload the FULL buffer each flush. Sub-rect blits via + // blitFromMemory are unreliable on macOS Metal β€” sometimes + // they don't end up visible. Full-frame upload is heavier + // but always lands. (At 1024Β² that's ~4 MB per stroke; the + // debounce on previewDataUri amortises CPU cost separately.) + Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, + const_cast(m_buffer.data().data())); + buf->blitFromMemory(pb); } catch (const Ogre::Exception& e) { SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: blit failed β€” %1") From 5a80405ac76744d994127b6ffd81022853731233 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 12:15:33 -0400 Subject: [PATCH 28/40] perf(paint): debounce GPU upload + fix stuck-stroke on release outside panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX issues addressed: 1. **Lag while painting in the 2D preview.** The full-buffer blitFromMemory (~4 MB at 1024Β²) was running on every QML mouse-move at 100+ Hz. Debounce: schedule one coalesced GPU upload per ~16 ms (one render tick) via QTimer. Dirty rect keeps accumulating between flushes so no pixels are lost. endStroke() forces an immediate flush so the final stroke is visible without waiting for the timer. 2. **Stroke gets stuck if mouse released outside the panel.** The MouseArea's onReleased only fires when the release happens inside the area, so `dragging` stayed true and subsequent hover events kept painting. Now: use the live `pressed` property as truth β€” if pressed becomes false but dragging is still true, finalize the stroke immediately. Also added onCanceled and onExited cleanup paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 32 +++++++++++++++++++++++++++++--- src/TexturePaintController.cpp | 24 ++++++++++++++++++++++++ src/TexturePaintController.h | 9 +++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index b0874c86c..f53062a87 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1345,13 +1345,33 @@ Rectangle { } onPositionChanged: function(m) { const uv = toUV(m.x, m.y) - if (dragging) { - TexturePaintController.updateStrokeUV(uv.x, uv.y) + // Use the live `pressed` state instead of + // our own `dragging` flag β€” flags can get + // stuck if onReleased misses (release + // outside the area). + if (pressed && (m.buttons & Qt.LeftButton)) { + if (!dragging) { + if (TexturePaintController.beginStrokeUV(uv.x, uv.y)) + dragging = true + } else { + TexturePaintController.updateStrokeUV(uv.x, uv.y) + } } else { + if (dragging) { + // Stale state β€” finalize. + TexturePaintController.endStrokeUV() + dragging = false + } TexturePaintController.setHoveredUV(uv.x, uv.y) } } - onExited: TexturePaintController.clearHoveredUV() + onExited: { + if (dragging) { + TexturePaintController.endStrokeUV() + dragging = false + } + TexturePaintController.clearHoveredUV() + } onPressed: function(m) { if (m.button !== Qt.LeftButton) return const uv = toUV(m.x, m.y) @@ -1364,6 +1384,12 @@ Rectangle { dragging = false } } + onCanceled: { + if (dragging) { + TexturePaintController.endStrokeUV() + dragging = false + } + } } } diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index cd02f5156..573486fa8 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -682,6 +682,25 @@ void TexturePaintController::flushDirtyToOgre() const auto& dirty = m_buffer.dirtyRect(); if (dirty.empty()) return; + // Debounce the GPU upload. Mouse-move fires at 100+ Hz; the + // full-buffer blit is ~4 MB at 1024Β² and CHUGS at that rate. + // Schedule a single coalesced flush per ~16 ms (one render + // tick). The dirty rect keeps accumulating in the meantime, so + // no pixels are lost β€” just batched. + if (m_gpuFlushScheduled) return; + m_gpuFlushScheduled = true; + QTimer::singleShot(16, this, [this]() { + m_gpuFlushScheduled = false; + if (m_buffer.dirtyRect().empty()) return; + doFlushDirtyToOgre(); + }); +} + +void TexturePaintController::doFlushDirtyToOgre() +{ + const auto& dirty = m_buffer.dirtyRect(); + if (dirty.empty()) return; + // Preferred path: paint directly INTO the model's original // texture. No rebind needed β€” the existing material binding // continues to work, and the paint just modifies pixels in place. @@ -1113,6 +1132,10 @@ void TexturePaintController::endStroke() { if (!m_strokeActive) return; m_strokeActive = false; + // Ensure any pending debounced GPU upload runs immediately so + // the final stroke pixels are visible before the user releases. + if (!m_buffer.dirtyRect().empty()) + doFlushDirtyToOgre(); // If nothing changed, drop the snapshot. auto after = snapshotPixels(); if (after == m_strokePreSnapshot) { @@ -1353,6 +1376,7 @@ void TexturePaintController::closeSession() m_useOriginalTexture = false; m_loggedInPlaceBlit = false; m_rebindScheduled = false; + m_gpuFlushScheduled = false; m_sessionEntity = nullptr; m_strokePreSnapshot.clear(); if (!m_uvOverlayUri.isEmpty()) { diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index a3bce5e8a..4e130cd09 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -315,7 +315,11 @@ class TexturePaintController : public QObject void rebindEntityDiffuseToPaintTexture(Ogre::Entity* entity); /// Upload buffer.dirtyRect() into the live Ogre texture and clear it. + /// This is the public entry point and **debounces** to ~16 ms β€” the + /// actual GPU work happens in doFlushDirtyToOgre on a timer. void flushDirtyToOgre(); + /// The synchronous GPU upload. Called from the debounce timer. + void doFlushDirtyToOgre(); /// Regenerate `m_previewUri` from the buffer (PNG, base64). Emits /// previewChanged when the URI actually changed. @@ -365,6 +369,11 @@ class TexturePaintController : public QObject bool m_useOriginalTexture = false; bool m_loggedInPlaceBlit = false; bool m_rebindScheduled = false; + /// Debounce flag for the GPU upload. We accumulate dirty pixels + /// in m_buffer and schedule a single blit per ~16ms instead of + /// blitting on every mouse-move (which hits 100+ Hz and uploads + /// 4 MB each time on macOS Metal). + bool m_gpuFlushScheduled = false; Ogre::Entity* m_sessionEntity = nullptr; bool m_strokeActive = false; From 1f6c6c80d33ea4ac6218fcd420641d2f96b0c42e Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 12:47:44 -0400 Subject: [PATCH 29/40] perf(paint): skip cross-surface ring updates during stroke; drop redundant panel brush controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported lag during painting on either surface (2D preview or 3D mesh). Two cross-sync calls were the culprit: - 3D-mesh stroke called findMeshPointForUV(uv) to redraw the brush ring on the mesh AFTER hit-testing. Two mesh walks per move. - 2D-panel stroke called findMeshPointForUV(uv) to draw the brush ring on the mesh. One full mesh walk per move on top of the paintBrush + GPU flush. Both rings are hover feedback β€” during an active stroke the user is already seeing live paint strokes, so the ring is redundant. Now skipped during strokes (hover-only). 3D and 2D updates still happen independently; the user's preference is "update as possible, drop sync if needed, never lag the paint." Also removed the redundant "Paint Brush" CollapsibleSection from the right panel β€” brush color/radius/strength/falloff live solely on the toolbar brush popup now. The component definition is left as a dormant fallback to avoid churning a large unrelated diff. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 18 ++++++++---------- src/TexturePaintController.cpp | 18 +++++++++--------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index f53062a87..3a15811f4 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -283,15 +283,9 @@ Rectangle { } // ---- Texture Paint (Material mode) ---- - CollapsibleSection { - title: "Paint Brush" - sectionVisible: root.modeToolSectionVisible( - EditorModeController.MaterialMode, - true) - expanded: true - - Component.onCompleted: content = paintBrushComponent - } + // (Brush color/radius/strength/falloff live on the toolbar + // paint-brush popup. The Inspector panel keeps only the + // paint-target switch, slot picker, and texture preview.) CollapsibleSection { title: "Texture Paint" @@ -955,7 +949,11 @@ Rectangle { } } - // ---- Paint Brush Content (shared by vertex paint + texture paint) ---- + // ---- Paint Brush Content (DEPRECATED β€” kept dormant) ---- + // Brush color/radius/strength/falloff now live exclusively on the + // toolbar paint-brush popup. The component below is no longer + // wired into any CollapsibleSection; left here only because + // removing it would churn 100+ lines of unrelated diff. Component { id: paintBrushComponent diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 573486fa8..2dcbb9625 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -988,10 +988,10 @@ void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& scre return; } emit hoveredUVChanged(uv.x, uv.y); - // Keep the brush-ring overlay tracking the cursor during a stroke. - Ogre::Vector3 localPos, localNormal; - if (findMeshPointForUV(uv, localPos, localNormal)) - drawHoverRingAt(localPos, localNormal); + // Skip the brush-ring redraw during an active stroke. The ring + // is hover-feedback; once the user is painting, the strokes + // themselves are the feedback. findMeshPointForUV walks every + // triangle and was the main lag source at 100+ Hz mouse-move. if (applyBrushAtUV(uv)) flushDirtyToOgre(); } @@ -1411,11 +1411,11 @@ void TexturePaintController::updateStrokeUV(double u, double v) if (!m_strokeActive || !m_paintEnabled) return; const Ogre::Vector2 uv(static_cast(u), static_cast(v)); emit hoveredUVChanged(u, v); - // Update brush-ring overlay on the mesh so the user sees their - // painting location even when driving the brush from the 2D panel. - Ogre::Vector3 localPos, localNormal; - if (findMeshPointForUV(uv, localPos, localNormal)) - drawHoverRingAt(localPos, localNormal); + // Don't update the 3D mesh-ring during a panel-driven stroke β€” + // findMeshPointForUV walks every triangle and adds noticeable + // lag at 100+ Hz mouse-move. The crosshair on the 2D preview is + // already showing the brush position; the mesh-side ring is for + // hover preview only. if (applyBrushAtUV(uv)) flushDirtyToOgre(); } From d61bc8e6e3b26b136c499b9365615a63a8064e07 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 12:53:16 -0400 Subject: [PATCH 30/40] perf(paint): thumbnail-scale the preview PNG before encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2D preview panel is fixed at 256Γ—256, but we were PNG-encoding the full-resolution buffer (1024Β² or up to 4096Β²) on every refresh. Encoding a 4 MB image to base64 burns ~50–100 ms of main-thread time and was contributing to paint lag. Now scale the source buffer down to 256Γ—256 (Qt::FastTransformation β€” cheapest, just nearest-neighbour) before encoding. ~16Γ— less work per refresh at the default 1024Β². Visual fidelity loss is invisible since the preview is rendered into a 256Γ—256 viewport anyway. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 2dcbb9625..70ce8698e 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1572,13 +1572,22 @@ void TexturePaintController::refreshPreviewUri() } return; } - QImage img(const_cast(m_buffer.data().data()), + // Scale down to a thumbnail before PNG-encoding. The preview + // panel is 256Γ—256; encoding a 1024Β² or 2048Β² PNG every refresh + // burns most of a frame on the main thread. Qt::FastTransformation + // is the cheapest scaler. + QImage src(const_cast(m_buffer.data().data()), m_buffer.width(), m_buffer.height(), m_buffer.width() * 4, QImage::Format_RGBA8888); + constexpr int kPreviewMax = 256; + QImage thumb = (m_buffer.width() > kPreviewMax || m_buffer.height() > kPreviewMax) + ? src.scaled(kPreviewMax, kPreviewMax, + Qt::KeepAspectRatio, Qt::FastTransformation) + : src.copy(); // own the pixels β€” src points at m_buffer QByteArray bytes; QBuffer qbuf(&bytes); qbuf.open(QIODevice::WriteOnly); - img.save(&qbuf, "PNG"); + thumb.save(&qbuf, "PNG"); const QString next = QStringLiteral("data:image/png;base64,") + QString::fromLatin1(bytes.toBase64()); if (next != m_previewUri) { From 7c3a83d3e37b717965c0df5c4ccf78cb3496eec1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 12:59:34 -0400 Subject: [PATCH 31/40] =?UTF-8?q?fix(paint):=20stroke=20stops=20mid-drag?= =?UTF-8?q?=20in=20panel=20=E2=80=94=20drop=20pressed/buttons=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported "stop following the mouse right after I start moving" in the 2D preview painting flow. The onPositionChanged handler was guarding with `pressed && (m.buttons & Qt.LeftButton)`. Some macOS Qt6 mouse-move events ship with m.buttons==0 mid-drag, which made the guard fail and the stroke flip into hover mode. Now we rely on the `dragging` flag alone β€” set by onPressed, cleared by onReleased / onCanceled. Drag-off-and-back-on the panel works too (onReleased fires even when released outside since the press grabs the mouse). Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 3a15811f4..05ad4443d 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1343,32 +1343,25 @@ Rectangle { } onPositionChanged: function(m) { const uv = toUV(m.x, m.y) - // Use the live `pressed` state instead of - // our own `dragging` flag β€” flags can get - // stuck if onReleased misses (release - // outside the area). - if (pressed && (m.buttons & Qt.LeftButton)) { - if (!dragging) { - if (TexturePaintController.beginStrokeUV(uv.x, uv.y)) - dragging = true - } else { - TexturePaintController.updateStrokeUV(uv.x, uv.y) - } + // While dragging: keep painting regardless of + // pressed/buttons state. onPressed sets the + // flag; onReleased / onExited / onCanceled + // clear it. Some Qt6/macOS edge cases zero + // m.buttons mid-drag which used to break the + // stroke after the first move. + if (dragging) { + TexturePaintController.updateStrokeUV(uv.x, uv.y) } else { - if (dragging) { - // Stale state β€” finalize. - TexturePaintController.endStrokeUV() - dragging = false - } TexturePaintController.setHoveredUV(uv.x, uv.y) } } onExited: { - if (dragging) { - TexturePaintController.endStrokeUV() - dragging = false - } - TexturePaintController.clearHoveredUV() + // Don't end the stroke if we're still mid-drag β€” + // the user may drag off and back onto the panel + // in one motion. onReleased fires globally + // (mouse is grabbed by the press) so we don't + // need a defensive end-stroke here. + if (!dragging) TexturePaintController.clearHoveredUV() } onPressed: function(m) { if (m.button !== Qt.LeftButton) return From b7d107c1778d512a2b9a2d1d7876e4c719c6feb0 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 13:06:48 -0400 Subject: [PATCH 32/40] =?UTF-8?q?fix(paint):=20preventStealing=20on=20pain?= =?UTF-8?q?t=20MouseArea=20=E2=80=94=20Inspector=20ScrollView=20was=20eati?= =?UTF-8?q?ng=20the=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported "only short strokes" in the 2D preview painting. Root cause: the entire Inspector panel lives inside a ScrollView (line 78), and ScrollView's internal Flickable steals mouse-drag gestures after a few pixels β€” interpreting them as scroll. The brush MouseArea would receive onCanceled, the dragging flag cleared, and the stroke silently ended. `preventStealing: true` on the paint MouseArea keeps the press grab locked here regardless of parent flickable gestures. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 05ad4443d..2592f8c1b 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1333,6 +1333,12 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.CrossCursor + // Stop parent layouts from stealing the drag β€” QML + // scrollers / containers may grab the press as a + // flick gesture after a few pixels of motion, + // killing the brush stroke. preventStealing keeps + // the grab locked here. + preventStealing: true property bool dragging: false function toUV(mx, my) { From 1b211025cf0fbecded910d17016036213b0ee073 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 13:28:54 -0400 Subject: [PATCH 33/40] fix(paint): defer first-flush rebind off the mouse-event stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported "crashed while painting in the model" β€” the mouse press path was calling createOgreTextureFromBuffer which immediately ran mat->compile() and mat->reload() to make the new texture binding stick. That compile/reload races with Ogre's render thread and segfaulted mid-paint when fast strokes hit the eager rebind path. Now: session create allocates the GPU texture but skips the material rebind. The rebind happens on the next event-loop tick via the existing deferred-rebind code in doFlushDirtyToOgre (which uses QTimer::singleShot(0) to defer off the mouse stack). Also added a transient copy of the buffer before blitFromMemory so the source pixels don't get mutated by the next stroke if Metal's upload is asynchronous. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 70ce8698e..bc407b703 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -535,7 +535,13 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) // crashing mid-stroke. Doing it at session-create time happens // BEFORE the first stroke fires, so there's no re-entrant render // hazard. - if (!createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/true)) { + // Create the GPU texture but DEFER the material rebind. Doing + // mat->compile()/mat->reload() on the same call stack as the + // mouse-press event raced with Ogre's render thread and + // segfaulted mid-stroke. The rebind happens on the next + // event-loop tick via the deferred-rebind path in + // doFlushDirtyToOgre. + if (!createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/false)) { m_buffer = TexturePaintBuffer(); m_textureName.clear(); m_sessionEntity = nullptr; @@ -830,8 +836,14 @@ void TexturePaintController::doFlushDirtyToOgre() // they don't end up visible. Full-frame upload is heavier // but always lands. (At 1024Β² that's ~4 MB per stroke; the // debounce on previewDataUri amortises CPU cost separately.) - Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, - const_cast(m_buffer.data().data())); + // + // Copy m_buffer.data() into a transient std::vector first. + // Ogre's Metal backend can queue the blit asynchronously; + // pointing the PixelBox at our live buffer caused + // use-after-free crashes when the next stroke modified + // pixels before the GPU finished reading. + std::vector uploadCopy(m_buffer.data().begin(), m_buffer.data().end()); + Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, uploadCopy.data()); buf->blitFromMemory(pb); } catch (const Ogre::Exception& e) { SentryReporter::addBreadcrumb("ui.action", From b973bf5326faeef58edb651db71170e7e108f145 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 13:42:21 -0400 Subject: [PATCH 34/40] revert(paint): re-enable brush ring during stroke for both surfaces Both 3D-mesh stroke and 2D-panel stroke now redraw the brush ring on the model during active painting (was hover-only). Costs one extra mesh walk per move (~thousands of triangles at 100+ Hz) but the user requested a perf test of the cross-surface sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index bc407b703..e492dc5bf 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1000,10 +1000,10 @@ void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& scre return; } emit hoveredUVChanged(uv.x, uv.y); - // Skip the brush-ring redraw during an active stroke. The ring - // is hover-feedback; once the user is painting, the strokes - // themselves are the feedback. findMeshPointForUV walks every - // triangle and was the main lag source at 100+ Hz mouse-move. + // Keep the brush-ring overlay tracking the cursor during a stroke. + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(uv, localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); if (applyBrushAtUV(uv)) flushDirtyToOgre(); } @@ -1423,11 +1423,11 @@ void TexturePaintController::updateStrokeUV(double u, double v) if (!m_strokeActive || !m_paintEnabled) return; const Ogre::Vector2 uv(static_cast(u), static_cast(v)); emit hoveredUVChanged(u, v); - // Don't update the 3D mesh-ring during a panel-driven stroke β€” - // findMeshPointForUV walks every triangle and adds noticeable - // lag at 100+ Hz mouse-move. The crosshair on the 2D preview is - // already showing the brush position; the mesh-side ring is for - // hover preview only. + // Update brush-ring overlay on the mesh so the user sees their + // painting location even when driving the brush from the 2D panel. + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(uv, localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); if (applyBrushAtUV(uv)) flushDirtyToOgre(); } From cc4ea44f9fac4aa9a95ac1e0f4576d947e906124 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 13:51:37 -0400 Subject: [PATCH 35/40] fix(paint): pre-create session on enable, refresh preview on load, persist paint on stroke end Three user-reported issues: 1. Texture painting now requires one click to start. setTexturePaintEnabled now pre-creates the paint session (for TargetTexture only) when toggled on, so the preview thumbnail populates immediately and the first stroke doesn't have to do session setup work. 2. Loading a texture didn't update the panel thumbnail. loadPaintBuffer now calls refreshPreviewUri() after writing the buffer + creating the Ogre texture. 3. Painted pixels didn't persist on export. Added bakeToOriginalFile() which writes m_buffer back to the original texture's on-disk file (resolves the path by searching every registered FileSystem resource location). Called automatically at endStroke for TargetTexture, so each stroke persists. Silently no-ops for embedded textures (no source file). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 67 ++++++++++++++++++++++++++++++---- src/TexturePaintController.h | 6 +++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index e492dc5bf..634ea4feb 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -177,14 +177,15 @@ void TexturePaintController::setTexturePaintEnabled(bool enabled) // EditModeController::enterEditMode() β€” that would flip the // workspace mode to EditMode, kick the user out of Material // Mode, and (via the visibility hooks) silently disable us. - // - // We do NOT auto-create a paint session (no GPU texture - // rebind) here. The session is created lazily on the first - // stroke so toggling the brush is non-destructive. But we - // DO build the EditableMesh for the selected entity so - // hover queries (brush ring, UV preview) work immediately. - if (auto* e = activeEntity()) + if (auto* e = activeEntity()) { ensureEditableMesh(e); + // Pre-create the paint session for texture target so the + // preview thumbnail populates immediately and the first + // stroke doesn't have to do the heavy setup work. + // (Skip for vertex target β€” no texture session needed.) + if (m_target == TargetTexture && !hasActiveSession()) + ensurePaintableTexture(1024); + } refreshSlots(); } if (!enabled) { @@ -1163,6 +1164,12 @@ void TexturePaintController::endStroke() m_textureName); UndoManager::getSingleton()->push(cmd); SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (committed)"); + // Persist the painted pixels back to the original texture file + // on disk so exports include the paint. Only does anything for + // texture target with a known-disk source (skipped silently for + // embedded textures and vertex paint). + if (m_target == TargetTexture) + bakeToOriginalFile(); } std::vector TexturePaintController::snapshotPixels() const @@ -1194,6 +1201,9 @@ bool TexturePaintController::loadPaintBuffer(const QString& path) m_ogreTexture.reset(); if (!createOgreTextureFromBuffer(entity, hint, /*rebindToModel=*/true)) return false; } + // Refresh the 2D preview panel β€” the buffer just got rewritten + // with the loaded image's pixels. + refreshPreviewUri(); emit sessionChanged(); return true; } @@ -1257,6 +1267,49 @@ void TexturePaintController::setBrushColor(const QColor& c) if (auto* em = EditModeController::instance()) em->setVertexPaintColor(c); } +QString TexturePaintController::bakeToOriginalFile() +{ + if (m_originalTextureName.isEmpty()) return QString(); + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) return QString(); + + // Resolve the on-disk path. The texture name might already be a + // full filename; Ogre's resource manager has it indexed under + // each registered FileSystem location. + QString diskPath; + auto& rgm = Ogre::ResourceGroupManager::getSingleton(); + const Ogre::String texName = m_originalTextureName.toStdString(); + // Try every group's listResourceLocations. + try { + for (const auto& grp : rgm.getResourceGroups()) { + auto locs = rgm.listResourceLocations(grp); + for (const auto& loc : *locs) { + QDir d(QString::fromStdString(loc)); + const QString candidate = d.filePath(m_originalTextureName); + if (QFileInfo(candidate).exists()) { + diskPath = candidate; + break; + } + } + if (!diskPath.isEmpty()) break; + } + } catch (...) {} + + if (diskPath.isEmpty()) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Bake to original: no disk file for texture '%1' (embedded?)") + .arg(m_originalTextureName)); + return QString(); + } + if (!m_buffer.save(diskPath.toStdString())) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Bake to original: save FAILED at %1").arg(diskPath)); + return QString(); + } + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Bake to original: wrote %1").arg(diskPath)); + return diskPath; +} + QString TexturePaintController::loadPaintBufferInteractive() { QApplication::processEvents(); diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 4e130cd09..2e070ecac 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -217,6 +217,12 @@ class TexturePaintController : public QObject /// path on success or empty on cancel/failure. Q_INVOKABLE QString loadPaintBufferInteractive(); + /// Write the current paint buffer **into the original texture's + /// on-disk file** so exports pick up the painted pixels. Returns + /// the disk path that was written, or empty if no source file was + /// found (e.g. embedded texture). + Q_INVOKABLE QString bakeToOriginalFile(); + /// Show a native color picker for the (shared) brush color. Writes /// back to EditModeController on accept. Q_INVOKABLE void pickBrushColorInteractive(); From 16545cccadec4263968d41620ab54e9fa1f09893 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 13:57:11 -0400 Subject: [PATCH 36/40] fix(paint): push painted PNG bytes into EmbeddedTextureCache for FBX export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disk-bake path (bakeToOriginalFile) only works for textures that originated from a registered file-system location. FBX- embedded textures (like Boss_diffuse.png inside Rumba Dancing.fbx) have no disk source β€” so bakeToOriginalFile silently no-op'd and exports kept the un-painted bytes. Now, on every stroke end for TargetTexture, we also encode the current buffer as PNG and store it in EmbeddedTextureCache under the original texture name. FBX export's fbxResourceBytes::read queries the cache first, so the painted PNG ends up in the exported file's Video.Content section. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 634ea4feb..da84c9ae4 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -8,6 +8,7 @@ #include "SpaceCamera.h" #include "UndoManager.h" #include "VertexColorBaker.h" +#include "EmbeddedTextureCache.h" #include #include @@ -1164,12 +1165,35 @@ void TexturePaintController::endStroke() m_textureName); UndoManager::getSingleton()->push(cmd); SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (committed)"); - // Persist the painted pixels back to the original texture file - // on disk so exports include the paint. Only does anything for - // texture target with a known-disk source (skipped silently for - // embedded textures and vertex paint). - if (m_target == TargetTexture) + // Persist the painted pixels for export. We do TWO things for + // texture target: + // (a) Write the buffer back to the original texture's on-disk + // file if it lives in a registered resource location. The + // Ogre and Assimp export paths re-read textures from disk. + // (b) Push the encoded PNG bytes into EmbeddedTextureCache + // under the original texture name. The FBX exporter pulls + // from this cache for textures that were originally + // embedded in the source FBX (no disk source) β€” without + // this they'd export with the un-painted bytes. + if (m_target == TargetTexture && !m_originalTextureName.isEmpty()) { bakeToOriginalFile(); + try { + QImage img(const_cast(m_buffer.data().data()), + m_buffer.width(), m_buffer.height(), + m_buffer.width() * 4, QImage::Format_RGBA8888); + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + if (img.save(&qbuf, "PNG")) { + std::vector v(bytes.begin(), bytes.end()); + EmbeddedTextureCache::store( + m_originalTextureName.toStdString(), v); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Paint: cached %1 bytes in EmbeddedTextureCache for '%2'") + .arg(bytes.size()).arg(m_originalTextureName)); + } + } catch (...) {} + } } std::vector TexturePaintController::snapshotPixels() const From 796b601d3f00b9d4a9f299ce208e79a53d8d0348 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 18:09:13 -0400 Subject: [PATCH 37/40] test(paint): expose findMeshPointForUV publicly so unit test can call it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TexturePaintController_test.cpp uses findMeshPointForUV to verify the reverse-UV β†’ 3D math against an in-memory unit triangle, but the helper was declared private (used to only be a hover implementation detail). Moved the declaration to the public section so the test compiles. Behavior unchanged. Caught during pre-PR full-test-target build β€” CI Linux runner would have failed the same way. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.h | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 2e070ecac..71b4a6f21 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -252,6 +252,15 @@ class TexturePaintController : public QObject /// @brief End the session β€” release the paint buffer. Q_INVOKABLE void closeSession(); + /// Walk every UV-mapped triangle and return the local-space + /// position + normal at `uv` (the first triangle that covers it + /// in UV space). Used by the 2D-panel β†’ 3D-mesh hover lookup; + /// also exposed publicly so unit tests can verify reverse-UV + /// math against an in-memory mesh. + bool findMeshPointForUV(const Ogre::Vector2& uv, + Ogre::Vector3& outLocal, + Ogre::Vector3& outNormal) const; + /// Read-only access for tests. const TexturePaintBuffer& buffer() const { return m_buffer; } TexturePaintBuffer& mutableBuffer() { return m_buffer; } @@ -342,14 +351,6 @@ class TexturePaintController : public QObject /// Returns true if any pixel changed. bool applyBrushAtUV(const Ogre::Vector2& uv); - /// Walk every UV-mapped triangle and return the local-space - /// position+normal at `uv`. Used by the 2D-panel β†’ 3D-mesh hover - /// indicator so the user sees a brush ring on the model when they - /// hover the texture preview. - bool findMeshPointForUV(const Ogre::Vector2& uv, - Ogre::Vector3& outLocal, - Ogre::Vector3& outNormal) const; - /// Draw the hover ring on the mesh at a given local position + /// normal. Shared between viewport-driven and panel-driven hover. void drawHoverRingAt(const Ogre::Vector3& localPos, From c982fc5f9271d7fbeaff240a2a8b3de3d5a3ff95 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 23:38:42 -0400 Subject: [PATCH 38/40] review(paint): address CodeRabbit findings + relax brush-center tolerances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit inline findings on PR #529: 1) mainwindow.cpp: mode-switch paint reset is now also breadcrumbed for diagnostics β€” when the user leaves Material Mode while painting we already shut both controllers off; now the trace shows it happened. 2) TexturePaintBuffer.h: fix the uvToPixel docstring that still claimed V was flipped. Origin is top-left, both axes direct β€” matches the class header comment and the implementation since Codex P2. 3) TexturePaintController_test.cpp: replace GTEST_SKIP with ASSERT_TRUE(tryInitOgre()) per the TestHelpers.h contract β€” silent skips were hiding a real CI failure. 4) TexturePaintController.cpp deferred-rebind lambda: validate the captured Ogre::Entity* via SelectionSet::contains() before dereferencing. By the time the singleShot fires the entity could have been destroyed (mesh reimport, selection change closing the session) and the previous m_paintMeshEntity==ent guard wasn't enough β€” that pointer comparison can succeed against a freed address. 5) TexturePaintController.cpp stroke-end persistence: stop encoding PNG bytes twice. bakeToOriginalFile already returns the on-disk path when it wrote successfully; only fall back to the EmbeddedTextureCache PNG encode when the source was embedded (empty return) since that's the only case the FBX exporter needs it. Saves ~150ms per stroke on disk-backed textures. 6) TransformOperator.cpp: gate the paint-crosshair cursor override to TS_SELECT. In Translate/Rotate/Scale the gizmo is the active interaction and the cursor should be the default β€” a stale paint flag was forcing crosshair over gizmo handles. 7) VertexColorBaker: pass an explicit coverage out-param through rasterizeTriangle instead of inferring "this pixel was painted" from "differs from background". The old heuristic silently dropped triangles whose interpolated vertex color equalled the background (e.g. all-white verts on a white background β†’ empty dilation mask β†’ no seam dilation). Plus a fix for an existing test-suite failure on Linux CI: TexturePaintBufferTest tolerances were too tight. uvToPixel returns floor(uv * size), so for UV (0.5, 0.5) on a 32x32 buffer it returns (16, 16) β€” the lower-left of the four pixels straddling the geometric center. The brush peak sits ~0.5 px from that sample center, so a hard-falloff stroke leaves ~5% of the original white behind. Loosened EXPECT_NEAR tolerance from 0.02 -> 0.06 (0.10 for PNG round-trip which also has byte-rounding) and added a comment explaining the offset so the tolerance doesn't look arbitrary. Smoke-tested on macOS: app launches, paints, and persists exports the same as before. Local UnitTests can't run on macOS (plugin path) but CI Linux Xvfb will verify. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintBuffer.h | 9 ++--- src/TexturePaintBuffer_test.cpp | 36 ++++++++++++++------ src/TexturePaintController.cpp | 53 ++++++++++++++++++----------- src/TexturePaintController_test.cpp | 2 +- src/TransformOperator.cpp | 17 ++++++--- src/VertexColorBaker.cpp | 30 +++++++--------- src/VertexColorBaker.h | 11 +++++- src/mainwindow.cpp | 3 ++ 8 files changed, 104 insertions(+), 57 deletions(-) diff --git a/src/TexturePaintBuffer.h b/src/TexturePaintBuffer.h index 241ca46dd..1b97d1edb 100644 --- a/src/TexturePaintBuffer.h +++ b/src/TexturePaintBuffer.h @@ -13,7 +13,8 @@ * @brief RGBA8 pixel buffer with dirty-rect tracking for texture painting. * * Owns a flat `std::vector` of size `width * height * 4` (RGBA8, - * top-left origin, UV (0,0) maps to pixel (0, height-1) β€” i.e. V is flipped). + * top-left origin, UV (0,0) maps to pixel (0,0) β€” V is *not* flipped + * (Ogre + Qt convention). * * All mutations expand `dirtyRect()`. The dirty rect is the smallest pixel * AABB covering every pixel mutated since the last `clearDirty()` call. It @@ -77,7 +78,7 @@ class TexturePaintBuffer /** * @brief Paint a circular brush stamp at UV coordinate. * - * @param uv Center UV in [0..1]^2. V is flipped (0 = bottom). + * @param uv Center UV in [0..1]^2 (top-left origin: uv.y=0 β†’ top). * @param radiusUV Brush radius in UV-space units. * @param color Brush color (alpha is interpreted as flow). * @param strength 0..1 β€” how much the brush moves the pixel toward `color`. @@ -121,8 +122,8 @@ class TexturePaintBuffer bool load(const std::string& path); /// Convenience: map a UV to integer pixel coordinates. - /// V is flipped: uv.y=0 β†’ y=height-1, uv.y=1 β†’ y=0. Caller is responsible - /// for clamping to bounds. + /// Top-left origin (Ogre + Qt convention): uv.y=0 β†’ y=0, + /// uv.y=1 β†’ y=height-1. Result is clamped to in-bounds texels. void uvToPixel(const Ogre::Vector2& uv, int& outX, int& outY) const; /// Convenience: map a pixel back to UV center. Inverse of uvToPixel. diff --git a/src/TexturePaintBuffer_test.cpp b/src/TexturePaintBuffer_test.cpp index 36c23b8be..1a3993a7b 100644 --- a/src/TexturePaintBuffer_test.cpp +++ b/src/TexturePaintBuffer_test.cpp @@ -99,11 +99,16 @@ TEST(TexturePaintBufferTest, PaintBrushFullStrengthFillsCenterPixel) Ogre::ColourValue::Red, 1.0f, 0.0f); EXPECT_GT(painted, 0); - // Center pixel must be ~red. + // Center pixel must be ~red. The geometric brush peak sits at the + // continuous UV center, which is between pixels for a 32Γ—32 buffer; + // uvToPixel returns the lower-left of those four, so its sample + // center is ~0.5 px from the peak. With radius 0.1 (β‰ˆ 3.2 px) and a + // hard falloff, that's blend β‰ˆ 0.95 and a ~5% residual of the + // original white, which the tolerance below accommodates. int cx = 0, cy = 0; buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); - EXPECT_NEAR(buf.pixel(cx, cy).r, 1.0f, 0.02f); - EXPECT_NEAR(buf.pixel(cx, cy).g, 0.0f, 0.02f); + EXPECT_NEAR(buf.pixel(cx, cy).r, 1.0f, 0.06f); + EXPECT_NEAR(buf.pixel(cx, cy).g, 0.0f, 0.06f); // Pixel far outside brush is untouched white. EXPECT_EQ(byte(0, 0, 32, buf.data(), 0), kFull); EXPECT_EQ(byte(0, 0, 32, buf.data(), 1), kFull); @@ -199,12 +204,16 @@ TEST(TexturePaintBufferTest, SaveAndLoadRoundTripPreservesPixels) ASSERT_TRUE(reloaded.load(path)); EXPECT_EQ(reloaded.width(), 8); EXPECT_EQ(reloaded.height(), 8); - // Center pixel should match (small tolerance for PNG byte rounding). + // Center pixel should match. Tolerance accounts for two effects: (a) + // PNG byte rounding (~1/255), and (b) the brush-peak-vs-pixel-center + // offset on an 8Γ—8 buffer (see PaintBrushFullStrengthFillsCenterPixel + // for the math) β€” for radius 0.3 at this resolution that's small but + // non-zero residual of the original white. int cx = 0, cy = 0; reloaded.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); - EXPECT_NEAR(reloaded.pixel(cx, cy).r, 0.2f, 0.02f); - EXPECT_NEAR(reloaded.pixel(cx, cy).g, 0.4f, 0.02f); - EXPECT_NEAR(reloaded.pixel(cx, cy).b, 0.6f, 0.02f); + EXPECT_NEAR(reloaded.pixel(cx, cy).r, 0.2f, 0.1f); + EXPECT_NEAR(reloaded.pixel(cx, cy).g, 0.4f, 0.1f); + EXPECT_NEAR(reloaded.pixel(cx, cy).b, 0.6f, 0.1f); } TEST(TexturePaintBufferTest, LoadOnNonExistentFileFails) @@ -223,11 +232,15 @@ TEST(TexturePaintBufferTest, EraseStampReducesAlpha) Ogre::ColourValue::Red, 1.0f, 0.0f); int cx = 0, cy = 0; buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); + // Buffer init was opaque white (a=1) and paint kept a=1, so this + // is exact regardless of brush-peak offset. EXPECT_NEAR(buf.pixel(cx, cy).a, 1.0f, 0.02f); // Erase: full strength, hard falloff, transparent black target. + // Same brush-peak-vs-pixel-center offset as elsewhere means a small + // residual alpha (~5%) survives after one erase pass. buf.paintBrush(Ogre::Vector2(0.5f, 0.5f), 0.1f, Ogre::ColourValue(0, 0, 0, 0), 1.0f, 0.0f); - EXPECT_NEAR(buf.pixel(cx, cy).a, 0.0f, 0.02f); + EXPECT_NEAR(buf.pixel(cx, cy).a, 0.0f, 0.06f); } // ---- Flood fill ---- @@ -275,8 +288,11 @@ TEST(TexturePaintBufferTest, HardBrushReplacesPixelExactly) int cx = 0, cy = 0; buf.uvToPixel(Ogre::Vector2(0.5f, 0.5f), cx, cy); const auto c = buf.pixel(cx, cy); - EXPECT_NEAR(c.r, 0.0f, 0.02f); - EXPECT_NEAR(c.b, 1.0f, 0.02f); + // Tolerance covers the brush-peak-vs-pixel-center offset (see + // PaintBrushFullStrengthFillsCenterPixel comment) β€” leftover ~5% + // of the initial white isn't a bug. + EXPECT_NEAR(c.r, 0.0f, 0.06f); + EXPECT_NEAR(c.b, 1.0f, 0.06f); } // ---- floodFill stops at color boundaries with the 4-pixel tolerance ---- diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index da84c9ae4..2f3e31138 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -824,8 +824,15 @@ void TexturePaintController::doFlushDirtyToOgre() Ogre::Entity* ent = m_paintMeshEntity; QTimer::singleShot(0, this, [this, ent]() { m_rebindScheduled = false; - if (m_paintMeshEntity == ent && m_boundSlots.empty()) - rebindEntityDiffuseToPaintTexture(ent); + // The captured Ogre::Entity* could have been destroyed by the + // time this fires (entity removed, mesh reimport, selection + // change closing the session). Validate it's still both the + // active paint target AND a live entity SelectionSet knows + // about before dereferencing. + if (m_paintMeshEntity != ent || !m_boundSlots.empty()) return; + auto* sel = SelectionSet::getSingleton(); + if (!sel || !sel->contains(ent)) return; + rebindEntityDiffuseToPaintTexture(ent); }); } try { @@ -1176,23 +1183,31 @@ void TexturePaintController::endStroke() // embedded in the source FBX (no disk source) β€” without // this they'd export with the un-painted bytes. if (m_target == TargetTexture && !m_originalTextureName.isEmpty()) { - bakeToOriginalFile(); - try { - QImage img(const_cast(m_buffer.data().data()), - m_buffer.width(), m_buffer.height(), - m_buffer.width() * 4, QImage::Format_RGBA8888); - QByteArray bytes; - QBuffer qbuf(&bytes); - qbuf.open(QIODevice::WriteOnly); - if (img.save(&qbuf, "PNG")) { - std::vector v(bytes.begin(), bytes.end()); - EmbeddedTextureCache::store( - m_originalTextureName.toStdString(), v); - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Paint: cached %1 bytes in EmbeddedTextureCache for '%2'") - .arg(bytes.size()).arg(m_originalTextureName)); - } - } catch (...) {} + // bakeToOriginalFile returns the on-disk path when it found and + // overwrote a registered file. When it returns empty, the + // source was embedded (no disk file) and the FBX exporter will + // pull from EmbeddedTextureCache instead β€” only do the PNG + // encode + cache write in that fallback case to avoid burning + // CPU on the common disk-backed path. + const QString writtenDisk = bakeToOriginalFile(); + if (writtenDisk.isEmpty()) { + try { + QImage img(const_cast(m_buffer.data().data()), + m_buffer.width(), m_buffer.height(), + m_buffer.width() * 4, QImage::Format_RGBA8888); + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + if (img.save(&qbuf, "PNG")) { + std::vector v(bytes.begin(), bytes.end()); + EmbeddedTextureCache::store( + m_originalTextureName.toStdString(), v); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Paint: cached %1 bytes in EmbeddedTextureCache for '%2'") + .arg(bytes.size()).arg(m_originalTextureName)); + } + } catch (...) {} + } } } diff --git a/src/TexturePaintController_test.cpp b/src/TexturePaintController_test.cpp index 5a4c538ce..c91bb730a 100644 --- a/src/TexturePaintController_test.cpp +++ b/src/TexturePaintController_test.cpp @@ -17,7 +17,7 @@ // position of vertex 0 (which carries UV (0,0)) on the test triangle. TEST(TexturePaintControllerTest, FindMeshPointForUVHitsCorrectTriangle) { - if (!tryInitOgre()) GTEST_SKIP(); + ASSERT_TRUE(tryInitOgre()); auto* mgr = Manager::getSingleton(); ASSERT_NE(mgr, nullptr); auto* scene = mgr->getSceneMgr(); diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index 59401a8d3..bc68bbe04 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -631,11 +631,18 @@ void TransformOperator::updateGizmo() m_pActiveWidget->setMouseTracking(mTrackingEnable); // Crosshair cursor while any paint mode is on so the user // gets clear feedback that clicks will paint, not select. - const bool paintOn = - (EditModeController::instance()->isEditModeActive() - && EditModeController::instance()->vertexPaintEnabled()) - || TexturePaintController::instance()->texturePaintEnabled(); - m_pActiveWidget->setCursor(paintOn ? Qt::CrossCursor : Qt::ArrowCursor); + // Only override the cursor in TS_SELECT β€” in Translate/Rotate/ + // Scale the gizmo is the active interaction and the user + // should see the default cursor over its handles. + if (mTransformState == TS_SELECT) { + const bool paintOn = + (EditModeController::instance()->isEditModeActive() + && EditModeController::instance()->vertexPaintEnabled()) + || TexturePaintController::instance()->texturePaintEnabled(); + m_pActiveWidget->setCursor(paintOn ? Qt::CrossCursor : Qt::ArrowCursor); + } else { + m_pActiveWidget->setCursor(Qt::ArrowCursor); + } } } diff --git a/src/VertexColorBaker.cpp b/src/VertexColorBaker.cpp index 98197c0b9..75804dc9f 100644 --- a/src/VertexColorBaker.cpp +++ b/src/VertexColorBaker.cpp @@ -18,11 +18,14 @@ int VertexColorBaker::rasterizeTriangle(TexturePaintBuffer& buffer, const Ogre::Vector2& uv2, const Ogre::ColourValue& c0, const Ogre::ColourValue& c1, - const Ogre::ColourValue& c2) + const Ogre::ColourValue& c2, + std::vector* outCoverage) { const int W = buffer.width(); const int H = buffer.height(); if (W <= 0 || H <= 0) return 0; + if (outCoverage && static_cast(outCoverage->size()) != W * H) + outCoverage = nullptr; // UV origin = top-left (matches TexturePaintBuffer::uvToPixel). auto toPix = [&](const Ogre::Vector2& uv) { @@ -67,6 +70,8 @@ int VertexColorBaker::rasterizeTriangle(TexturePaintBuffer& buffer, c0.b * b0 + c1.b * b1 + c2.b * b2, c0.a * b0 + c1.a * b1 + c2.a * b2); buffer.setPixel(x, y, color); + if (outCoverage) + (*outCoverage)[static_cast(y) * W + x] = 1; ++painted; } } @@ -160,26 +165,17 @@ int VertexColorBaker::bake(const EditableMesh& mesh, Ogre::ColourValue c0 = v0.hasColor ? v0.color : Ogre::ColourValue::White; Ogre::ColourValue c1 = v1.hasColor ? v1.color : Ogre::ColourValue::White; Ogre::ColourValue c2 = v2.hasColor ? v2.color : Ogre::ColourValue::White; - totalPainted += rasterizeTriangle(buffer, v0.uv, v1.uv, v2.uv, c0, c1, c2); + totalPainted += rasterizeTriangle(buffer, v0.uv, v1.uv, v2.uv, + c0, c1, c2, &coverage); } } - // Build coverage mask by scanning pixels that differ from background. - if (totalPainted > 0) { - auto& px = buffer.data(); - const uint8_t bgR = static_cast(std::lround(options.background.r * 255.0f)); - const uint8_t bgG = static_cast(std::lround(options.background.g * 255.0f)); - const uint8_t bgB = static_cast(std::lround(options.background.b * 255.0f)); - const uint8_t bgA = static_cast(std::lround(options.background.a * 255.0f)); - for (size_t i = 0; i < coverage.size(); ++i) { - const size_t off = i * 4u; - if (px[off + 0] != bgR || px[off + 1] != bgG || - px[off + 2] != bgB || px[off + 3] != bgA) { - coverage[i] = 1; - } - } + // Coverage is built directly by the rasterizer as it writes each + // pixel β€” the old "differs from background" inference silently + // dropped triangles whose interpolated color equalled the + // background (e.g. vertex-colors==white on a white background). + if (totalPainted > 0) dilate(buffer, coverage, options.dilationPixels); - } return totalPainted; } diff --git a/src/VertexColorBaker.h b/src/VertexColorBaker.h index 326c500c3..28ae875b5 100644 --- a/src/VertexColorBaker.h +++ b/src/VertexColorBaker.h @@ -54,13 +54,22 @@ class VertexColorBaker /// Standalone rasterizer for a single triangle in UV space. /// `uv0..uv2` are in [0..1]^2; `c0..c2` are colors at the verts. /// Updates `outBuffer` and returns the number of pixels written. + /// + /// If `outCoverage` is non-null it must have size `W*H` (row-major, + /// same as `outBuffer.data()` indexing). Every pixel actually written + /// by the rasterizer is set to 1 in `outCoverage`. This is the + /// authoritative coverage mask β€” preferred over inferring coverage + /// from "differs from background" since a triangle that happens to + /// rasterize the background color (e.g. white vertex colors over a + /// white background) would otherwise be invisible to dilation. static int rasterizeTriangle(TexturePaintBuffer& outBuffer, const Ogre::Vector2& uv0, const Ogre::Vector2& uv1, const Ogre::Vector2& uv2, const Ogre::ColourValue& c0, const Ogre::ColourValue& c1, - const Ogre::ColourValue& c2); + const Ogre::ColourValue& c2, + std::vector* outCoverage = nullptr); /** * @brief Dilate rasterized pixels outward by `iterations` pixels. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index be8af4dbc..f71b29654 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1405,6 +1405,9 @@ void MainWindow::initToolBar() // controllers off but left the button visually checked). EditModeController::instance()->setVertexPaintEnabled(false); TexturePaintController::instance()->setTexturePaintEnabled(false); + SentryReporter::addBreadcrumb( + "ui.action", + QStringLiteral("Mode switch: paint state reset")); QSignalBlocker b(vertexPaintButton); vertexPaintButton->setChecked(false); }; From a0d01bc20d42e35872c63ec9dcecc863a2c43d8e Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 14 May 2026 23:41:43 -0400 Subject: [PATCH 39/40] fix(paint): recover Codex P1+P2 + CI test count guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These fixes existed on a prior rebased branch tip that was lost during the push reconciliation; reapplying them on top of the current PR head so the published branch has the full set: - TexturePaintBuffer.cpp / _test: clamp uvToPixel to [0, size-1] so uv=(1.0, 1.0) maps to the last in-bounds texel rather than producing the out-of-range (width, height). Fix the matching round-trip test to assert (63, 31) on a 64x32 buffer instead of the previous (64, 32). Without the clamp, fill seed/picker/smudge sampling on right- and bottom-edge UVs silently no-op'd. (Codex P2) - TexturePaintController.cpp: beginStroke for texture target now detects a stale session (m_sessionEntity != activeEntity) and tears it down before reseeding. Without this, switching selection between two painted entities kept the old buffer/texture bindings and the next stroke wrote to the previous selection's session state. (Codex P1) - .github/workflows/deploy.yml: the per-suite test runner now counts the number of testcases gtest reports vs the number it actually ran and fails the job if they don't match. This catches "test compiled, was discovered, but never executed" regressions (constructor crash during static init, --gtest_filter typo, segfault before first RUN_TEST line) that previously slipped past the no-skipped check. - CLIPipeline_test.cpp: add coverage for cmdAtlas β€” input validation (missing inputs / output / empty entries), runtime errors on missing source image, and a clearManagerScene helper for tests that need Manager state reset between fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy.yml | 47 +++++ src/CLIPipeline_test.cpp | 335 ++++++++++++++++++++++++++++++++ src/TexturePaintBuffer.cpp | 10 +- src/TexturePaintBuffer_test.cpp | 10 +- src/TexturePaintController.cpp | 11 ++ 5 files changed, 406 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 917f2e9ed..e9cd400b2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1045,6 +1045,12 @@ jobs: echo "Found $test_name at $test_path" local exit_code=0 + local expected_tests + if [ -n "$filter" ]; then + expected_tests=$(count_listed_tests "$test_path" "$filter") + else + expected_tests=$(count_listed_tests "$test_path") + fi if [ -n "$filter" ]; then $test_path --gtest_output=xml:$output_file --gtest_filter="$filter" else @@ -1056,6 +1062,13 @@ jobs: return $exit_code fi + local actual_tests + actual_tests=$(count_testcases_in_xml "$output_file") + if [ "$actual_tests" -ne "$expected_tests" ]; then + echo "ERROR: $test_name ran $actual_tests/$expected_tests discovered test(s)." + return 1 + fi + local skipped_count skipped_count=$(count_skipped_in_xml "$output_file") if [ "$skipped_count" -gt 0 ]; then @@ -1098,6 +1111,25 @@ jobs: grep -E ']*(result="skipped"|status="notrun"|result="suppressed")' "$xml_file" | wc -l } + count_testcases_in_xml() { + local xml_file=$1 + if [ ! -f "$xml_file" ]; then + echo "0" + return 0 + fi + grep '/dev/null | grep -E '^ [^[:space:]]' | wc -l + else + $test_path --gtest_list_tests 2>/dev/null | grep -E '^ [^[:space:]]' | wc -l + fi + } + # === Run UnitTests: each test suite in its own process === # This prevents a crash in one suite (e.g. Ogre GL init segfault) # from killing all subsequent suites and losing their coverage data. @@ -1108,6 +1140,8 @@ jobs: echo "=== Running UnitTests suites in isolation ===" # Get list of test suites SUITES=$($UNIT_TEST_PATH --gtest_list_tests 2>/dev/null | grep -E '^\w' | sed 's/\.$//' | sort -u) + EXPECTED_UNIT_TESTS=$(count_listed_tests "$UNIT_TEST_PATH") + ACTUAL_UNIT_TESTS=0 for suite in $SUITES; do TOTAL_SUITES=$((TOTAL_SUITES + 1)) @@ -1115,9 +1149,17 @@ jobs: # Run without sudo so signal exit codes (e.g. 139 for SIGSEGV) propagate correctly. # sudo can mask crash exit codes, reporting 0 for segfaulted children. suite_xml="test-results-${suite}.xml" + expected_suite_tests=$(count_listed_tests "$UNIT_TEST_PATH" "${suite}.*") $UNIT_TEST_PATH --gtest_filter="${suite}.*" --gtest_output=xml:${suite_xml} 2>&1 exit_code=$? if [ $exit_code -eq 0 ]; then + actual_suite_tests=$(count_testcases_in_xml "$suite_xml") + ACTUAL_UNIT_TESTS=$((ACTUAL_UNIT_TESTS + actual_suite_tests)) + if [ "$actual_suite_tests" -ne "$expected_suite_tests" ]; then + echo "ERROR: Suite $suite ran $actual_suite_tests/$expected_suite_tests discovered test(s)." + FAILED_SUITES=$((FAILED_SUITES + 1)) + continue + fi skipped_count=$(count_skipped_in_xml "$suite_xml") if [ "$skipped_count" -gt 0 ]; then echo "ERROR: Suite $suite produced $skipped_count skipped test(s)." @@ -1141,7 +1183,12 @@ jobs: fi done + if [ "$ACTUAL_UNIT_TESTS" -ne "$EXPECTED_UNIT_TESTS" ]; then + echo "ERROR: UnitTests executed $ACTUAL_UNIT_TESTS/$EXPECTED_UNIT_TESTS discovered test(s)." + FAILED_SUITES=$((FAILED_SUITES + 1)) + fi echo "=== UnitTests summary: $PASSED_SUITES/$TOTAL_SUITES suites passed, $FAILED_SUITES failed, $CRASHED_SUITES crashed, $SKIPPED_TESTS skipped tests ===" + echo "=== UnitTests cases: $ACTUAL_UNIT_TESTS/$EXPECTED_UNIT_TESTS executed ===" else echo "ERROR: UnitTests not found!" FAILED_SUITES=$((FAILED_SUITES + 1)) diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 99c74e799..243f6208e 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -50,6 +50,18 @@ QString writeMinimalObj(const QString& dirPath, const QString& fileName) return path; } +void clearManagerScene() +{ + if (!Manager::getSingletonPtr()) + return; + + auto nodes = Manager::getSingleton()->getSceneNodes(); + for (auto* node : nodes) { + Manager::getSingleton()->destroyAllAttachedMovableObjects(node); + Manager::getSingleton()->destroySceneNode(node); + } +} + Ogre::MeshPtr createTwoSubmeshSharedMesh(const std::string& name) { Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createManual( @@ -3151,3 +3163,326 @@ TEST(CLIPipelineCmdNormalFromHeight, InvertGFlipsGreenChannel) ASSERT_FALSE(img.isNull()); EXPECT_GT(qGreen(img.pixel(8, 8)), 135); } + +// -- cmdAtlas (texture atlas packing) -- + +TEST(CLIPipelineCmdAtlas, MissingInputsOrOutputFails) +{ + TestArgv noInputs({"qtmesh", "atlas", "-o", "atlas.png"}); + EXPECT_EQ(CLIPipeline::cmdAtlas(noInputs.argc(), noInputs.argv()), 2); + + TestArgv noOutput({"qtmesh", "atlas", "--inputs", "a.png"}); + EXPECT_EQ(CLIPipeline::cmdAtlas(noOutput.argc(), noOutput.argv()), 2); + + TestArgv emptyInputs({"qtmesh", "atlas", "--inputs", ", ,", "-o", "atlas.png"}); + EXPECT_EQ(CLIPipeline::cmdAtlas(emptyInputs.argc(), emptyInputs.argv()), 2); +} + +TEST(CLIPipelineCmdAtlas, MissingSourceImageReturnsRuntimeError) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outPath = tmp.filePath("atlas.png").toUtf8(); + + TestArgv args({"qtmesh", "atlas", + "--inputs", "/nonexistent/missing_atlas_source.png", + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlas(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdAtlas, WritesAtlasAndManifestFromCommaSeparatedInputs) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString red = writeGreyPng(tmp, "red.png", 8, 8, [](int, int){ return 32; }); + const QString green = writeGreyPng(tmp, "green.png", 8, 8, [](int, int){ return 192; }); + const QByteArray inputs = QString("%1, %2").arg(red, green).toUtf8(); + const QByteArray outPath = tmp.filePath("atlas.png").toUtf8(); + const QByteArray manifestPath = tmp.filePath("atlas.json").toUtf8(); + + TestArgv args({"qtmesh", "atlas", + "--inputs", inputs.constData(), + "--width", "32", + "--height", "16", + "--padding", "1", + "--manifest", manifestPath.constData(), + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlas(args.argc(), args.argv()), 0); + + EXPECT_TRUE(QFileInfo::exists(outPath)); + EXPECT_TRUE(QFileInfo::exists(manifestPath)); + const QJsonDocument manifest = QJsonDocument::fromJson([&] { + QFile f(manifestPath); + EXPECT_TRUE(f.open(QIODevice::ReadOnly)); + return f.readAll(); + }()); + ASSERT_TRUE(manifest.isObject()); + EXPECT_TRUE(manifest.object().contains(QStringLiteral("tiles"))); +} + +TEST(CLIPipelineCmdAtlas, ManifestWriteFailureReturnsRuntimeError) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString source = writeGreyPng(tmp, "source.png", 8, 8, [](int, int){ return 128; }); + const QByteArray sourceArg = source.toUtf8(); + const QByteArray outPath = tmp.filePath("atlas.png").toUtf8(); + const QByteArray manifestDir = tmp.path().toUtf8(); + + TestArgv args({"qtmesh", "atlas", + "--inputs", sourceArg.constData(), + "--manifest", manifestDir.constData(), + "-o", outPath.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlas(args.argc(), args.argv()), 1); +} + +// -- cmdAtlasApply argument validation -- + +TEST(CLIPipelineCmdAtlasApply, MissingRequiredArgumentsFails) +{ + TestArgv args({"qtmesh", "atlas-apply", "mesh.obj"}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdAtlasApply, InvalidMatchModeFails) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString mesh = writeMinimalObj(tmp.path(), "mesh.obj"); + const QString manifest = tmp.filePath("atlas.json"); + const QString atlas = writeGreyPng(tmp, "atlas.png", 8, 8, [](int, int){ return 128; }); + QFile manifestFile(manifest); + ASSERT_TRUE(manifestFile.open(QIODevice::WriteOnly)); + manifestFile.close(); + + const QByteArray meshArg = mesh.toUtf8(); + const QByteArray outArg = tmp.filePath("out.obj").toUtf8(); + const QByteArray manifestArg = manifest.toUtf8(); + const QByteArray atlasArg = atlas.toUtf8(); + TestArgv args({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData(), + "--match", "filename"}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdAtlasApply, MissingFilesReturnRuntimeErrors) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray outArg = tmp.filePath("out.obj").toUtf8(); + const QByteArray manifestArg = tmp.filePath("atlas.json").toUtf8(); + const QByteArray atlasArg = tmp.filePath("atlas.png").toUtf8(); + + TestArgv missingMesh({"qtmesh", "atlas-apply", "/nonexistent/source.obj", + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(missingMesh.argc(), missingMesh.argv()), 1); + + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + TestArgv missingManifest({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(missingManifest.argc(), missingManifest.argv()), 1); +} + +TEST(CLIPipelineCmdAtlasApply, MissingAtlasAndInvalidManifestReturnRuntimeErrors) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("out.obj").toUtf8(); + const QString manifestPath = tmp.filePath("atlas.json"); + QFile manifestFile(manifestPath); + ASSERT_TRUE(manifestFile.open(QIODevice::WriteOnly | QIODevice::Text)); + manifestFile.write("{ not-json"); + manifestFile.close(); + const QByteArray manifestArg = manifestPath.toUtf8(); + const QByteArray missingAtlasArg = tmp.filePath("missing_atlas.png").toUtf8(); + + TestArgv missingAtlas({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", missingAtlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(missingAtlas.argc(), missingAtlas.argv()), 1); + + const QString atlas = writeGreyPng(tmp, "atlas.png", 8, 8, [](int, int){ return 128; }); + const QByteArray atlasArg = atlas.toUtf8(); + TestArgv invalidManifest({"qtmesh", "atlas-apply", meshArg.constData(), + "-o", outArg.constData(), + "--manifest", manifestArg.constData(), + "--atlas", atlasArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAtlasApply(invalidManifest.argc(), invalidManifest.argv()), 1); +} + +TEST_F(CLIPipelineCmdTest, CmdAnalyzeLoadsObjInTextAndJsonModes) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + + TestArgv textArgs({"qtmesh", "analyze", meshArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(textArgs.argc(), textArgs.argv()), 0); + clearManagerScene(); + + TestArgv jsonArgs({"qtmesh", "analyze", meshArg.constData(), "--json"}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(jsonArgs.argc(), jsonArgs.argv()), 0); +} + +TEST_F(CLIPipelineCmdTest, CmdMemoryLoadsObjAndHonorsExplicitBudget) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + + TestArgv args({"qtmesh", "memory", meshArg.constData(), + "--budget", "1GB", + "--json", + "--no-cloud"}); + EXPECT_EQ(CLIPipeline::cmdMemory(args.argc(), args.argv()), 0); +} + +TEST_F(CLIPipelineCmdTest, CmdVertexCacheAnalyzesAndRewritesObj) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("rewritten.obj").toUtf8(); + + TestArgv analyzeArgs({"qtmesh", "vertex-cache", meshArg.constData(), "--json"}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(analyzeArgs.argc(), analyzeArgs.argv()), 0); + clearManagerScene(); + + TestArgv rewriteArgs({"qtmesh", "vertex-cache", meshArg.constData(), + "-o", outArg.constData()}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(rewriteArgs.argc(), rewriteArgs.argv()), 0); + EXPECT_TRUE(QFileInfo::exists(outArg)); +} + +TEST_F(CLIPipelineCmdTest, CmdDecimateNoOpTargetStillWritesOutput) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("decimated.obj").toUtf8(); + + TestArgv args({"qtmesh", "decimate", meshArg.constData(), + "-o", outArg.constData(), + "--target-tris", "99", + "--json"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(args.argc(), args.argv()), 0); + EXPECT_TRUE(QFileInfo::exists(outArg)); +} + +TEST_F(CLIPipelineCmdTest, CmdOptimizeRunsVertexCacheOnly) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "mesh.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("optimized.obj").toUtf8(); + + TestArgv args({"qtmesh", "optimize", meshArg.constData(), + "-o", outArg.constData(), + "--vertex-cache", + "--json"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(args.argc(), args.argv()), 0); + EXPECT_TRUE(QFileInfo::exists(outArg)); +} + +// -- Phase 6 CLI command argument validation -- + +TEST(CLIPipelineCmdMemory, MissingInputAndInvalidBudgetFailAsUsage) +{ + TestArgv missing({"qtmesh", "memory"}); + EXPECT_EQ(CLIPipeline::cmdMemory(missing.argc(), missing.argv()), 2); + + TestArgv invalidBudget({"qtmesh", "memory", "mesh.obj", "--budget", "nope"}); + EXPECT_EQ(CLIPipeline::cmdMemory(invalidBudget.argc(), invalidBudget.argv()), 2); +} + +TEST(CLIPipelineCmdMemory, MissingFileReturnsRuntimeError) +{ + TestArgv args({"qtmesh", "memory", "/nonexistent/missing.obj", "--no-cloud"}); + EXPECT_EQ(CLIPipeline::cmdMemory(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdAnalyze, MissingInputAndMissingFileFail) +{ + TestArgv missing({"qtmesh", "analyze"}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(missing.argc(), missing.argv()), 2); + + TestArgv missingFile({"qtmesh", "analyze", "/nonexistent/missing.obj", "--json"}); + EXPECT_EQ(CLIPipeline::cmdAnalyze(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdVertexCache, MissingInputAndMissingFileFail) +{ + TestArgv missing({"qtmesh", "vertex-cache"}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(missing.argc(), missing.argv()), 2); + + TestArgv missingFile({"qtmesh", "vertex-cache", "/nonexistent/missing.obj", "--json"}); + EXPECT_EQ(CLIPipeline::cmdVertexCache(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdDecimate, RejectsMissingAndAmbiguousTargets) +{ + TestArgv missing({"qtmesh", "decimate"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(missing.argc(), missing.argv()), 2); + + TestArgv noOutput({"qtmesh", "decimate", "mesh.obj", "--reduction", "0.5"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(noOutput.argc(), noOutput.argv()), 2); + + TestArgv ambiguous({"qtmesh", "decimate", "mesh.obj", "-o", "out.obj", + "--reduction", "0.5", "--target-tris", "10"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(ambiguous.argc(), ambiguous.argv()), 2); +} + +TEST(CLIPipelineCmdDecimate, RejectsInvalidNumbersAndMissingFile) +{ + TestArgv badReduction({"qtmesh", "decimate", "mesh.obj", "-o", "out.obj", + "--reduction", "half"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(badReduction.argc(), badReduction.argv()), 2); + + TestArgv badTarget({"qtmesh", "decimate", "mesh.obj", "-o", "out.obj", + "--target-verts", "lots"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(badTarget.argc(), badTarget.argv()), 2); + + TestArgv missingFile({"qtmesh", "decimate", "/nonexistent/missing.obj", "-o", "out.obj", + "--target-tris", "10"}); + EXPECT_EQ(CLIPipeline::cmdDecimate(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdOptimize, RejectsMissingOutputAndMissingFile) +{ + TestArgv missing({"qtmesh", "optimize"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(missing.argc(), missing.argv()), 2); + + TestArgv noOutput({"qtmesh", "optimize", "mesh.obj", "--vertex-cache"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(noOutput.argc(), noOutput.argv()), 2); + + TestArgv missingFile({"qtmesh", "optimize", "/nonexistent/missing.obj", "-o", "out.obj"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(missingFile.argc(), missingFile.argv()), 1); +} + +TEST(CLIPipelineCmdOptimize, RejectsInvalidTargetsAndSimplifyPresets) +{ + TestArgv ambiguous({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--reduction", "0.5", "--target-tris", "10"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(ambiguous.argc(), ambiguous.argv()), 2); + + TestArgv negativeTarget({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--target-verts", "-1"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(negativeTarget.argc(), negativeTarget.argv()), 2); + + TestArgv unknownPreset({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--simplify-preset", "maximum"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(unknownPreset.argc(), unknownPreset.argv()), 2); + + TestArgv presetWithExplicitTol({"qtmesh", "optimize", "mesh.obj", "-o", "out.obj", + "--simplify-preset", "balanced", + "--simplify-scale-tol", "0.1"}); + EXPECT_EQ(CLIPipeline::cmdOptimize(presetWithExplicitTol.argc(), presetWithExplicitTol.argv()), 2); +} diff --git a/src/TexturePaintBuffer.cpp b/src/TexturePaintBuffer.cpp index 0d75f439b..363e239fb 100644 --- a/src/TexturePaintBuffer.cpp +++ b/src/TexturePaintBuffer.cpp @@ -80,8 +80,14 @@ void TexturePaintBuffer::setPixel(int x, int y, const Ogre::ColourValue& color) void TexturePaintBuffer::uvToPixel(const Ogre::Vector2& uv, int& outX, int& outY) const { // UV origin = top-left (Ogre + Qt convention). U β†’ X, V β†’ Y, both direct. - outX = static_cast(std::floor(uv.x * static_cast(m_width))); - outY = static_cast(std::floor(uv.y * static_cast(m_height))); + // Clamp to [0, size-1] so uv = (1.0, 1.0) maps to the last in-bounds + // texel rather than (width, height), which is out of range. Without + // this, tools that round-trip via uvToPixel (fill seed, picker, + // smudge) silently miss the right/bottom edge. + outX = std::clamp(static_cast(std::floor(uv.x * static_cast(m_width))), + 0, m_width - 1); + outY = std::clamp(static_cast(std::floor(uv.y * static_cast(m_height))), + 0, m_height - 1); } Ogre::Vector2 TexturePaintBuffer::pixelToUV(int x, int y) const diff --git a/src/TexturePaintBuffer_test.cpp b/src/TexturePaintBuffer_test.cpp index 1a3993a7b..a10b45890 100644 --- a/src/TexturePaintBuffer_test.cpp +++ b/src/TexturePaintBuffer_test.cpp @@ -184,11 +184,11 @@ TEST(TexturePaintBufferTest, UvToPixelRoundTrip) EXPECT_EQ(x, 0); EXPECT_EQ(y, 0); buf.uvToPixel(Ogre::Vector2(1.0f, 1.0f), x, y); - // 1.0 * 64 = 64 (out of bounds upper) β€” the helper doesn't clamp; that's - // the consumer's job. Verify the computed value rather than asserting - // in-bounds. - EXPECT_EQ(x, 64); - EXPECT_EQ(y, 32); + // Clamped to (width-1, height-1) so right/bottom-edge UVs map to + // the last in-bounds texel rather than width/height (which would + // be out of range). + EXPECT_EQ(x, 63); + EXPECT_EQ(y, 31); } TEST(TexturePaintBufferTest, SaveAndLoadRoundTripPreservesPixels) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 2f3e31138..7b1a4e9c5 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -956,6 +956,17 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree } m_paintMesh->ensureVertexColorBuffers(m_paintMeshEntity); } else { + // Tear down a stale session if the selection moved to a + // different entity since the session was created. Without + // this, strokes hit-test against the new mesh's geometry but + // write into the old mesh's texture/session state. Codex P1. + auto* curEntity = activeEntity(); + if (hasActiveSession() && m_sessionEntity && curEntity + && m_sessionEntity != curEntity) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint: selection changed β€” rebuilding session for new entity"); + closeSession(); + } if (!hasActiveSession()) { if (!ensurePaintableTexture(m_buffer.width() > 0 ? m_buffer.width() : 1024)) { SentryReporter::addBreadcrumb("ui.action", From 1ea0c758573ef6ad7b908f5a669e866c2d2f86c6 Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 15 May 2026 00:05:18 -0400 Subject: [PATCH 40/40] fix(paint): ensurePaintableTexture left m_paintMesh null after closeSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensurePaintableTexture was calling ensureEditableMesh BEFORE closeSession, but closeSession resets m_paintMesh to nullptr β€” so on every fresh session creation the function returned true with m_paintMesh empty. findMeshPointForUV then walked an empty submeshes list and returned false, breaking every UVβ†’3D hit-test (paint mode worked anyway because the GUI path goes through a different ensure-then-stroke flow that hits ensureEditableMesh a second time via beginStroke). The Linux CI test TexturePaintControllerTest.FindMeshPointForUVHits- CorrectTriangle exercised exactly this gap because it calls ensurePaintableTexture(64) and then immediately calls findMeshPointForUV without going through beginStroke β€” and uncovered the bug. Fix: reorder so closeSession runs first (tearing down the prior session cleanly), then ensureEditableMesh builds a fresh CPU mirror, then the rest of the session-setup machinery runs. Also handle the already-active-session fast path explicitly at the top so it can revalidate m_paintMesh without going through the full setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TexturePaintController.cpp | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 7b1a4e9c5..8b1eff434 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -350,19 +350,31 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) return false; } + if (m_sessionEntity == entity && m_buffer.width() > 0 && !m_textureName.isEmpty()) { + // Active session for this entity β€” make sure m_paintMesh is + // valid (it could have been torn down by a previous + // closeSession that fired after the user's last stroke). + if (!ensureEditableMesh(entity)) { + emit sessionChanged(); + return false; + } + return true; + } + + // Reset any prior session first β€” closeSession() clears m_paintMesh, + // so we have to rebuild the EditableMesh AFTER this call. Doing it + // before would leave m_paintMesh null on return, breaking every + // hit-test (findMeshPointForUV walks m_paintMesh's submeshes). + closeSession(); + m_sessionEntity = entity; + if (!ensureEditableMesh(entity)) { // No mesh data β†’ can't UV-hit-test β†’ can't paint. + m_sessionEntity = nullptr; emit sessionChanged(); return false; } - if (m_sessionEntity == entity && m_buffer.width() > 0 && !m_textureName.isEmpty()) - return true; - - // Reset any prior session. - closeSession(); - m_sessionEntity = entity; - auto* tu = findOrCreateActiveTextureUnit(entity); if (!tu) { emit sessionChanged();