From e049a305584be8e869c7468fe6bf15e2d26aaeca Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 15 May 2026 14:01:12 -0400 Subject: [PATCH 1/2] feat(paint): image-editing tools (FG/BG colors, smart-select, editor window) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three connected pieces of UX work on the texture/vertex paint pipeline: 1) FG / BG colors with swap + reset, exposed in the left toolbar. - EditModeController gains m_vertexPaintBackgroundColor + swap + reset APIs. Defaults: FG = Fern green (113,188,120), BG = black. - mainwindow.cpp adds an inline FG/BG swatch widget directly to the objects toolbar — two overlapping rectangles with a swap arrow (⇄) and a reset glyph (◰). Click either swatch to open QColorDialog. Visible in Material Mode only. - The Erase brush now paints with BG color instead of being hard-coded to "paint transparent". Old behaviour is preserved by setting BG alpha = 0 in the picker. - Brush popup no longer duplicates color UI — toolbar is the single source of truth. 2) Smart-select / magic-wand tool. - New PaintSelectionMask class: per-pixel boolean mask paired 1:1 with the paint buffer. Smart-select via flood-fill against a seed pixel with per-channel L∞ tolerance. Replace / Add / Sub combine modes (the GUI exposes Replace only; the SDK is wired for the rest). - TexturePaintController exposes Q_INVOKABLE Mask APIs: select- all / invert / clear / smartSelectAtUV / fillMaskWithFG / fillMaskWithBG / deleteMaskPixels. All actions push a single TexturePaintMaskActionCommand for undo. - Wand toolbar button (green icon drawn via QPainter to match topology buttons; grey disabled state) lives directly under the paint brush. Enabled only when texture paint is on AND target=Texture. Clicking outside the mesh clears the selection (Photoshop / GIMP convention). - Tolerance: no separate UI control. Click-and-drag horizontally during the wand stroke to scrub the tolerance. Each fresh press resets to the default 15%. - Selection overlay renders in two views: a 2D yellow-tint + black-outline marching-ants overlay on the texture preview, AND a 3D ManualObject-textured copy of the mesh that shows the selected UV regions on the model. The 3D overlay uses an unlit transparent material sampling the same mask texture. 3) Detached texture editor window (qml/TextureEditorWindow.qml). - QML Window opened from the right panel, 720x760 default, mirrors the paint buffer at full canvas size. Hooked into the same TexturePaintController.beginStrokeUV / updateStrokeUV / endStrokeUV path, so live-syncs with the 3D viewport in both directions. - Bottom action bar: Save, Load, Save to Original, plus the mask Fill FG / Fill BG / Delete / Invert / All / None actions. Top toolbar mirrors the brush tool selector. 4) Non-destructive paint on stroke end. - Stroke end NO LONGER overwrites the user's source texture file. Painted pixels live only in m_buffer and EmbeddedTextureCache (which feeds the FBX/glTF exporters). - To persist to disk the user must click "Save to Original" explicitly (right panel + editor window) or save to a new file via "Save…". This prevents the case where someone is just experimenting with a texture and overwrites their asset by accident. 5) Crash hardening. - TexturePaintController listens to Manager::sceneNodeDestroyed and Manager::sceneClearing. When a doomed scene node holds m_paintMeshEntity / m_sessionEntity / m_maskOverlayEntity, closeSession() runs while the entities are still valid. Was reproducing as a segfault after removing a mesh that had an active wand selection. - setPaintTarget() now tears down the texture-paint session when switching from Texture → Vertex (was leaving GPU texture + rebind state half-alive, crashing on the next vertex stroke). - destroyMeshMaskOverlay() and the brush-ring teardown in closeSession() now source the SceneManager from the Manager singleton instead of dereferencing entity pointers that may already be dangling. Brush radius default raised from 0.25 -> 0.02 with a 0.001-2.0 slider range so the small-detail use case is the default. New test: src/PaintSelectionMask_test.cpp covers the mask APIs (default-empty, set/clear, select-all/invert, smart-select with exact + tolerance match, Add/Sub combine modes, out-of-bounds + size-mismatch guards). Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 190 +++++++- qml/TextureEditorWindow.qml | 316 ++++++++++++ src/CMakeLists.txt | 2 + src/EditModeController.cpp | 63 +++ src/EditModeController.h | 29 +- src/PaintSelectionMask.cpp | 166 +++++++ src/PaintSelectionMask.h | 117 +++++ src/PaintSelectionMask_test.cpp | 153 ++++++ src/TexturePaintController.cpp | 837 ++++++++++++++++++++++++++++++-- src/TexturePaintController.h | 113 ++++- src/TransformOperator.cpp | 15 + src/mainwindow.cpp | 285 ++++++++--- src/qml_resources.qrc | 1 + 13 files changed, 2171 insertions(+), 116 deletions(-) create mode 100644 qml/TextureEditorWindow.qml create mode 100644 src/PaintSelectionMask.cpp create mode 100644 src/PaintSelectionMask.h create mode 100644 src/PaintSelectionMask_test.cpp diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 2592f8c1b..0d489e9e7 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1017,12 +1017,12 @@ Rectangle { } Slider { width: 140 - from: 0.02; to: 2.0; stepSize: 0.01 + from: 0.001; to: 2.0; stepSize: 0.001 value: brushCol.brushRadius onMoved: TexturePaintController.setBrushRadius(value) } Text { - text: brushCol.brushRadius.toFixed(2) + text: brushCol.brushRadius.toFixed(3) color: PropertiesPanelController.textColor; font.pixelSize: 10 anchors.verticalCenter: parent.verticalCenter } @@ -1090,6 +1090,10 @@ Rectangle { property int brushTool: TexturePaintController.brushTool property int paintTarget: TexturePaintController.paintTarget property string previewUri: TexturePaintController.previewDataUri + property string maskOverlayUri: TexturePaintController.maskOverlayDataUri + property bool hasMask: TexturePaintController.hasSelectionMask + property int maskCount: TexturePaintController.selectedPixelCount + property real smartTolerance: TexturePaintController.smartSelectTolerance // Live hover position in UV space, fed by hoveredUVChanged. property real hoverU: -1 property real hoverV: -1 @@ -1120,6 +1124,12 @@ Rectangle { texPaintCol.hoverU = u texPaintCol.hoverV = v } + function onSmartSelectChanged() { + texPaintCol.maskOverlayUri = TexturePaintController.maskOverlayDataUri + texPaintCol.hasMask = TexturePaintController.hasSelectionMask + texPaintCol.maskCount = TexturePaintController.selectedPixelCount + texPaintCol.smartTolerance = TexturePaintController.smartSelectTolerance + } } Text { @@ -1180,15 +1190,19 @@ Rectangle { } } - // Tool selector \u2014 Paint, Erase, Fill, Picker, Smudge + // Tool selector \u2014 Paint, Erase, Fill, Picker, Smudge. + // Wand is intentionally NOT here; it lives in the left + // toolbar (under the paint brush) so it can have its own + // green icon + enabled/disabled treatment without + // duplicating UI in the right panel. 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: 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 { @@ -1212,6 +1226,120 @@ Rectangle { } } + // Smart-select panel. Visible whenever there's an active + // session \u2014 tolerance is always meaningful, and once the + // mask is non-empty the action buttons go live. + Column { + spacing: 6 + visible: texPaintCol.hasSession + width: parent.width - 16 + + // Status row only — wand tolerance is no longer a + // separate control. The user clicks the mesh / 2D + // thumbnail with the Wand tool and drags horizontally + // mid-stroke; the controller scrubs the tolerance live + // and re-selects at the press seed. The current value + // is shown here so the user can see what they're at. + Row { + spacing: 10 + Text { + text: texPaintCol.hasMask + ? (texPaintCol.maskCount + " px selected") + : "Wand: drag horizontally while painting to adjust tolerance" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.75 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "tol " + Math.round(texPaintCol.smartTolerance * 100) + "%" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.55 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Detached editor window launcher. Same paint buffer, + // bigger canvas, real-time sync with the 3D viewport. + Row { + spacing: 6 + Rectangle { + width: 140; height: 24; radius: 3 + color: editorMa.containsMouse + ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: "⤢ Open Editor Window" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: editorMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.openEditorWindow() + } + } + } + + // Action buttons: act on the current mask. + Row { + spacing: 4 + Repeater { + model: [ + { label: "Fill FG", action: "fillFG", needsMask: true, hint: "Replace selection with foreground color" }, + { label: "Fill BG", action: "fillBG", needsMask: true, hint: "Replace selection with background color" }, + { label: "Delete", action: "delete", needsMask: true, hint: "Clear selection to transparent" }, + { label: "Invert", action: "invert", needsMask: false, hint: "Invert the selection" }, + { label: "All", action: "all", needsMask: false, hint: "Select every pixel" }, + { label: "None", action: "none", needsMask: true, hint: "Clear the selection" } + ] + Rectangle { + width: 56; height: 24; radius: 3 + color: actionMa.containsMouse + ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor + opacity: (modelData.needsMask && !texPaintCol.hasMask) ? 0.45 : 1.0 + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: actionMa + anchors.fill: parent + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: !modelData.needsMask || texPaintCol.hasMask + ToolTip.text: modelData.hint + ToolTip.visible: containsMouse + ToolTip.delay: 400 + onClicked: { + if (modelData.action === "fillFG") + TexturePaintController.fillMaskWithFG() + else if (modelData.action === "fillBG") + TexturePaintController.fillMaskWithBG() + else if (modelData.action === "delete") + TexturePaintController.deleteMaskPixels() + else if (modelData.action === "invert") + TexturePaintController.invertSelectionMask() + else if (modelData.action === "all") + TexturePaintController.selectAllMask() + else if (modelData.action === "none") + TexturePaintController.clearSelectionMask() + } + } + } + } + } + } + // Texture slot picker \u2014 populated by selection Row { spacing: 6 @@ -1312,6 +1440,21 @@ Rectangle { cache: false } + // Smart-select / magic-wand mask overlay. Yellow tint on + // the selected area + black outline at the boundary — + // matches the marching-ants idea without the animation. + Image { + id: maskOverlayImg + anchors.fill: parent + anchors.margins: 1 + visible: texPaintCol.hasMask + opacity: 0.85 + source: texPaintCol.maskOverlayUri + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + } + // Crosshair indicator at hover UV Rectangle { visible: texPaintCol.hoverU >= 0 && texPaintCol.hoverV >= 0 @@ -1440,6 +1583,39 @@ Rectangle { onClicked: TexturePaintController.loadPaintBufferInteractive() } } + + // Explicit "write back to the source texture file on + // disk" button. Painting is otherwise non-destructive + // (the strokes live only in the in-memory paint buffer + // and the EmbeddedTextureCache used by exports), so + // the user has to click this to overwrite the original + // asset. Strong-warning hover text so it isn't mistaken + // for the safe "Save\u2026" (which writes a new file). + Rectangle { + width: 130; height: 24; radius: 3 + color: saveOrigMa.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 to Original" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: saveOrigMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: texPaintCol.hasSession + ToolTip.text: "Overwrite the texture's source file on disk.\nCannot be undone outside the editor." + ToolTip.visible: containsMouse + ToolTip.delay: 400 + onClicked: TexturePaintController.bakeToOriginalFile() + } + } } // Action row 2: bake vertex colors diff --git a/qml/TextureEditorWindow.qml b/qml/TextureEditorWindow.qml new file mode 100644 index 000000000..686bb896e --- /dev/null +++ b/qml/TextureEditorWindow.qml @@ -0,0 +1,316 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls +import PropertiesPanel 1.0 + +/** + * Detached, full-size texture editor window. Mirrors the texture-paint + * 2D preview in the right inspector — same data URI, same paint pipeline + * via TexturePaintController.beginStrokeUV/updateStrokeUV/endStrokeUV. + * Live-syncs with the 3D viewport: paints on the detached canvas update + * the in-engine texture in real time, and strokes done on the 3D mesh + * update this canvas at the next preview refresh. + * + * Slice 3 of the paint-image-editing-tools epic. + */ +Window { + id: editorWindow + title: TexturePaintController.currentTextureName.length > 0 + ? ("Texture Editor — " + TexturePaintController.currentTextureName) + : "Texture Editor" + width: 720 + height: 760 + minimumWidth: 480 + minimumHeight: 520 + color: "#1e1e1e" + flags: Qt.Window | Qt.WindowMinMaxButtonsHint | Qt.WindowCloseButtonHint + + // Mirror everything we need from TexturePaintController. The whole + // window is invisible when no paint session is active — the + // "Open Editor Window" button only enables once the session exists. + property string previewUri: TexturePaintController.previewDataUri + property string maskOverlayUri: TexturePaintController.maskOverlayDataUri + property bool hasSession: TexturePaintController.hasActiveSession + property bool hasMask: TexturePaintController.hasSelectionMask + property int maskCount: TexturePaintController.selectedPixelCount + property real hoverU: -1 + property real hoverV: -1 + + Connections { + target: TexturePaintController + function onPreviewChanged() { + editorWindow.previewUri = TexturePaintController.previewDataUri + } + function onSmartSelectChanged() { + editorWindow.maskOverlayUri = TexturePaintController.maskOverlayDataUri + editorWindow.hasMask = TexturePaintController.hasSelectionMask + editorWindow.maskCount = TexturePaintController.selectedPixelCount + } + function onSessionChanged() { + editorWindow.hasSession = TexturePaintController.hasActiveSession + } + function onHoveredUVChanged(u, v) { + editorWindow.hoverU = u + editorWindow.hoverV = v + } + } + + // Top toolbar — tool selector + current FG/BG color readout, mirroring + // what's in the main toolbar so the user doesn't need to look away. + Row { + id: topBar + spacing: 6 + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: 8 + } + height: 30 + + Repeater { + model: [ + { tool: 0, label: "Paint", glyph: "✏", isWand: false }, + { tool: 1, label: "Erase", glyph: "⌫", isWand: false }, + { tool: 2, label: "Fill", glyph: "⧉", isWand: false }, + { tool: 3, label: "Pick", glyph: "⊰", isWand: false }, + { tool: 4, label: "Smudge", glyph: "∿", isWand: false }, + { tool: 5, label: "Wand", glyph: "", isWand: true } + ] + Rectangle { + width: 64; height: 28; radius: 3 + color: TexturePaintController.brushTool === modelData.tool + ? "#5b8def" + : (winToolMa.containsMouse ? "#3a3a3a" : "#2a2a2a") + border.color: "#555"; border.width: 1 + // Custom wand icon — see comment in PropertiesPanel.qml + // for the rationale (sparkle/star glyphs look like the + // AI button). + Canvas { + id: winWandIcon + visible: modelData.isWand + width: 14; height: 14 + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + onPaint: { + const ctx = getContext("2d") + ctx.reset() + ctx.strokeStyle = "white" + ctx.lineWidth = 1.6 + ctx.lineCap = "round" + ctx.beginPath() + ctx.moveTo(2.5, 11.5) + ctx.lineTo(11.5, 2.5) + ctx.stroke() + ctx.fillStyle = "white" + ctx.beginPath() + ctx.arc(11.8, 2.2, 1.7, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.arc(2.2, 11.8, 1.0, 0, Math.PI * 2) + ctx.fill() + } + } + Text { + anchors.centerIn: parent + visible: !modelData.isWand + text: modelData.glyph + " " + modelData.label + color: "white"; font.pixelSize: 11 + } + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: winWandIcon.right + anchors.leftMargin: 4 + visible: modelData.isWand + text: modelData.label + color: "white"; font.pixelSize: 11 + } + MouseArea { + id: winToolMa + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.brushTool = modelData.tool + } + } + } + + Item { width: 16; height: 1 } // spacer + + Text { + text: editorWindow.hasMask ? (editorWindow.maskCount + " px") : "" + color: "#ddd" + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + } + + // Main canvas. Inverted-letterbox layout: the canvas grows to fill, + // preserving the texture's aspect ratio. MouseArea handles paint + // strokes — same UV mapping as PropertiesPanel.qml. + Rectangle { + id: canvasBox + anchors { + top: topBar.bottom + left: parent.left + right: parent.right + bottom: bottomBar.top + margins: 8 + } + color: "#101010" + border.color: "#444"; border.width: 1 + + Image { + id: canvasImg + anchors.centerIn: parent + // Largest square that fits within the box. + width: Math.min(parent.width, parent.height) - 8 + height: width + source: editorWindow.previewUri + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + onSourceChanged: canvasImg.update() + } + // Mask overlay (selection bounds rendered as a yellow tint). + Image { + anchors.fill: canvasImg + visible: editorWindow.hasMask + opacity: 0.85 + source: editorWindow.maskOverlayUri + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + } + // Hover crosshair. + Rectangle { + visible: editorWindow.hoverU >= 0 && editorWindow.hoverV >= 0 + color: "#ff3030" + width: 1; height: canvasImg.height + x: canvasImg.x + Math.round(editorWindow.hoverU * canvasImg.width) + y: canvasImg.y + } + Rectangle { + visible: editorWindow.hoverU >= 0 && editorWindow.hoverV >= 0 + color: "#ff3030" + width: canvasImg.width; height: 1 + x: canvasImg.x + y: canvasImg.y + Math.round(editorWindow.hoverV * canvasImg.height) + } + + MouseArea { + id: canvasMa + anchors.fill: canvasImg + hoverEnabled: true + cursorShape: Qt.CrossCursor + preventStealing: true + property bool dragging: false + + function uvAt(mx, my) { + if (canvasImg.width <= 0 || canvasImg.height <= 0) + return null + const u = mx / canvasImg.width + const v = my / canvasImg.height + if (u < 0 || u > 1 || v < 0 || v > 1) return null + return { u: u, v: v } + } + + onPressed: (m) => { + const uv = uvAt(m.x, m.y) + if (!uv) return + dragging = TexturePaintController.beginStrokeUV(uv.u, uv.v) + m.accepted = true + } + onPositionChanged: (m) => { + const uv = uvAt(m.x, m.y) + if (!uv) { + TexturePaintController.clearHoveredUV() + return + } + TexturePaintController.setHoveredUV(uv.u, uv.v) + if (dragging) + TexturePaintController.updateStrokeUV(uv.u, uv.v) + } + onReleased: (m) => { + if (dragging) { + TexturePaintController.endStrokeUV() + dragging = false + } + } + onCanceled: { + if (dragging) { + TexturePaintController.endStrokeUV() + dragging = false + } + } + onExited: TexturePaintController.clearHoveredUV() + } + } + + // Bottom action bar: save / load / bake / mask actions / "open in + // external viewer". Keeps the most-used non-stroke actions one click + // away without polluting the main inspector. + Row { + id: bottomBar + spacing: 6 + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + margins: 8 + } + height: 30 + + Button { + text: "Save…" + enabled: editorWindow.hasSession + onClicked: TexturePaintController.savePaintBufferInteractive() + } + Button { + text: "Load…" + enabled: editorWindow.hasSession + onClicked: TexturePaintController.loadPaintBufferInteractive() + } + Button { + // Explicit destructive write — the painted pixels only hit + // disk when the user clicks this (or saves a new file). + // Strokes alone are kept in memory. + text: "Save to Original" + enabled: editorWindow.hasSession + ToolTip.text: "Overwrite the texture's source file on disk.\nCannot be undone outside the editor." + ToolTip.visible: hovered + ToolTip.delay: 400 + onClicked: TexturePaintController.bakeToOriginalFile() + } + Button { + text: "Fill FG" + enabled: editorWindow.hasMask + onClicked: TexturePaintController.fillMaskWithFG() + } + Button { + text: "Fill BG" + enabled: editorWindow.hasMask + onClicked: TexturePaintController.fillMaskWithBG() + } + Button { + text: "Delete" + enabled: editorWindow.hasMask + onClicked: TexturePaintController.deleteMaskPixels() + } + Button { + text: "Invert" + enabled: editorWindow.hasSession + onClicked: TexturePaintController.invertSelectionMask() + } + Button { + text: "All" + enabled: editorWindow.hasSession + onClicked: TexturePaintController.selectAllMask() + } + Button { + text: "None" + enabled: editorWindow.hasMask + onClicked: TexturePaintController.clearSelectionMask() + } + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index be8b517fd..50e160143 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -79,6 +79,7 @@ MaterialPresetLibrary.cpp MeshLodController.cpp TextureChannelPacker.cpp TextureAtlasPacker.cpp +PaintSelectionMask.cpp TexturePaintBuffer.cpp TexturePaintController.cpp VertexColorBaker.cpp @@ -176,6 +177,7 @@ MaterialPresetLibrary.h MeshLodController.h TextureChannelPacker.h TextureAtlasPacker.h +PaintSelectionMask.h TexturePaintBuffer.h TexturePaintController.h VertexColorBaker.h diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 4dde27a12..d0348a594 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -292,6 +292,69 @@ void EditModeController::setVertexPaintBrushColor(const QString& cssColor) setVertexPaintColor(c); } +void EditModeController::setVertexPaintBackgroundColor(const QColor& c) +{ + if (!c.isValid()) + return; + QColor rgb = c.toRgb(); + if (!rgb.isValid()) + return; + // BG color is allowed to be fully transparent — that's how the user + // signals "erase to transparent" instead of "erase to a solid color". + if (m_vertexPaintBackgroundColor.rgba() == rgb.rgba()) + return; + m_vertexPaintBackgroundColor = rgb; + SentryReporter::addBreadcrumb( + "ui.action", + QStringLiteral("Vertex paint BG color: %1").arg(rgb.name(QColor::HexArgb))); + emit vertexPaintChanged(); +} + +void EditModeController::setVertexPaintBackgroundBrushColor(const QString& cssColor) +{ + const QString s = cssColor.trimmed(); + if (s.isEmpty()) + return; + QColor c = QColor::fromString(s); + if (!c.isValid()) + c = QColor(s); + if (!c.isValid()) + return; + setVertexPaintBackgroundColor(c); +} + +void EditModeController::swapPaintColors() +{ + const QColor fg = m_vertexPaintColor; + const QColor bg = m_vertexPaintBackgroundColor; + m_vertexPaintColor = bg; + m_vertexPaintBackgroundColor = fg; + SentryReporter::addBreadcrumb("ui.action", "Vertex paint: FG/BG swapped"); + emit vertexPaintChanged(); +} + +void EditModeController::resetPaintColors() +{ + // Defaults match the member initialisers in the header: FG=Fern green, + // BG=black. Keep these in lockstep — the toolbar tooltip and the + // README both call out the resetting behaviour. + bool changed = false; + const QColor defaultFg(113, 188, 120); + const QColor defaultBg(0, 0, 0); + if (m_vertexPaintColor != defaultFg) { + m_vertexPaintColor = defaultFg; + changed = true; + } + if (m_vertexPaintBackgroundColor != defaultBg) { + m_vertexPaintBackgroundColor = defaultBg; + changed = true; + } + if (changed) { + SentryReporter::addBreadcrumb("ui.action", "Vertex paint: FG/BG reset to default"); + emit vertexPaintChanged(); + } +} + void EditModeController::setVertexPaintRadius(double r) { if (r <= 0.0 || m_vertexPaintRadius == r) diff --git a/src/EditModeController.h b/src/EditModeController.h index 542a232d7..b5022c47b 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -104,6 +104,7 @@ class EditModeController : public QObject // Vertex color paint (MVP) Q_PROPERTY(bool vertexPaintEnabled READ vertexPaintEnabled WRITE setVertexPaintEnabled NOTIFY vertexPaintChanged) Q_PROPERTY(QColor vertexPaintColor READ vertexPaintColor WRITE setVertexPaintColor NOTIFY vertexPaintChanged) + Q_PROPERTY(QColor vertexPaintBackgroundColor READ vertexPaintBackgroundColor WRITE setVertexPaintBackgroundColor NOTIFY vertexPaintChanged) Q_PROPERTY(double vertexPaintRadius READ vertexPaintRadius WRITE setVertexPaintRadius NOTIFY vertexPaintChanged) Q_PROPERTY(double vertexPaintStrength READ vertexPaintStrength WRITE setVertexPaintStrength NOTIFY vertexPaintChanged) Q_PROPERTY(double vertexPaintFalloff READ vertexPaintFalloff WRITE setVertexPaintFalloff NOTIFY vertexPaintChanged) @@ -219,6 +220,20 @@ class EditModeController : public QObject void setVertexPaintColor(const QColor& c); /// Preferred from QML: parses "#RRGGBB" / CSS names reliably (avoids QVariant QColor edge cases). Q_INVOKABLE void setVertexPaintBrushColor(const QString& cssColor); + + /// Secondary "background" color. Used by: + /// - texture paint erase (replaces pixels with this color instead of + /// transparent, so the user gets a solid fill) + /// - smart-select "fill with BG" action + /// The two-color FG/BG model mirrors Photoshop / GIMP / Krita. + QColor vertexPaintBackgroundColor() const { return m_vertexPaintBackgroundColor; } + void setVertexPaintBackgroundColor(const QColor& c); + Q_INVOKABLE void setVertexPaintBackgroundBrushColor(const QString& cssColor); + /// Swap foreground and background colors. Standard "X" shortcut in + /// image editors. + Q_INVOKABLE void swapPaintColors(); + /// Reset to canonical defaults: FG=black, BG=white. + Q_INVOKABLE void resetPaintColors(); double vertexPaintRadius() const { return m_vertexPaintRadius; } void setVertexPaintRadius(double r); double vertexPaintStrength() const { return m_vertexPaintStrength; } @@ -932,10 +947,18 @@ private slots: }; KnifeSession m_knifeSession; - // Vertex paint state + // Vertex paint state. Default FG = Fern (Qt "Fern" CSS color = + // #71BC78 = 113,188,120) so paint strokes are immediately visible + // on either dark or light textures, and BG = black so the Erase + // tool produces a recognisable hole. bool m_vertexPaintEnabled = false; - QColor m_vertexPaintColor = QColor(255, 0, 0); - double m_vertexPaintRadius = 0.25; // local units + QColor m_vertexPaintColor = QColor(113, 188, 120); + QColor m_vertexPaintBackgroundColor = QColor(0, 0, 0); + // Brush radius in local mesh units. Default 0.02 produces a small + // crisp dot on most meshes; users can scale up to 2.0 for broad + // washes or down to 0.001 for pixel-level precision via the + // toolbar slider / brush popup. + double m_vertexPaintRadius = 0.02; double m_vertexPaintStrength = 0.5; // 0..1 double m_vertexPaintFalloff = 0.5; // 0..1 bool m_vertexPaintStrokeActive = false; diff --git a/src/PaintSelectionMask.cpp b/src/PaintSelectionMask.cpp new file mode 100644 index 000000000..60d06e90e --- /dev/null +++ b/src/PaintSelectionMask.cpp @@ -0,0 +1,166 @@ +#include "PaintSelectionMask.h" + +#include "TexturePaintBuffer.h" + +#include +#include +#include + +void PaintSelectionMask::resize(int width, int height) +{ + m_width = std::max(0, width); + m_height = std::max(0, height); + m_data.assign(static_cast(m_width) * static_cast(m_height), 0); + m_setCount = 0; + m_bbox = {}; +} + +bool PaintSelectionMask::isSelected(int x, int y) const +{ + if (x < 0 || y < 0 || x >= m_width || y >= m_height) return false; + return m_data[static_cast(y) * static_cast(m_width) + static_cast(x)] != 0; +} + +void PaintSelectionMask::setSelected(int x, int y, bool selected) +{ + if (x < 0 || y < 0 || x >= m_width || y >= m_height) return; + const size_t idx = static_cast(y) * static_cast(m_width) + static_cast(x); + const bool was = m_data[idx] != 0; + if (was == selected) return; + m_data[idx] = selected ? 1 : 0; + if (selected) { + ++m_setCount; + expandBBox(x, y); + } else { + --m_setCount; + // Shrinking the bbox precisely is O(W*H); we let it stay loose + // and rebuild on demand if it matters (rebuildSummary). + } +} + +void PaintSelectionMask::clear() +{ + if (m_setCount == 0 && m_bbox.empty()) return; + std::fill(m_data.begin(), m_data.end(), 0); + m_setCount = 0; + m_bbox = {}; +} + +void PaintSelectionMask::selectAll() +{ + if (m_width <= 0 || m_height <= 0) return; + std::fill(m_data.begin(), m_data.end(), 1); + m_setCount = m_width * m_height; + m_bbox = {0, 0, m_width, m_height}; +} + +void PaintSelectionMask::invert() +{ + if (m_width <= 0 || m_height <= 0) return; + for (auto& b : m_data) b = b ? 0 : 1; + rebuildSummary(); +} + +namespace { + +inline bool colourWithinTol(const Ogre::ColourValue& a, + const Ogre::ColourValue& b, + float tol) +{ + return std::fabs(a.r - b.r) <= tol + && std::fabs(a.g - b.g) <= tol + && std::fabs(a.b - b.b) <= tol + && std::fabs(a.a - b.a) <= tol; +} + +} // namespace + +int PaintSelectionMask::smartSelect(const TexturePaintBuffer& buf, + int sx, int sy, + float tolerance, + CombineMode mode) +{ + if (buf.width() != m_width || buf.height() != m_height) return 0; + if (m_width <= 0 || m_height <= 0) return 0; + if (sx < 0 || sy < 0 || sx >= m_width || sy >= m_height) return 0; + tolerance = std::clamp(tolerance, 0.0f, 1.0f); + + const Ogre::ColourValue seed = buf.pixel(sx, sy); + + if (mode == CombineMode::Replace) + clear(); + + // Flood fill: 4-connected. Visited bitmap prevents revisiting in + // the Add/Sub modes where the mask state alone isn't enough (a + // pixel that's already in the mask in Add mode would otherwise + // block expansion through it). + std::vector visited(m_data.size(), 0); + std::vector> stack; + stack.reserve(64); + stack.push_back({sx, sy}); + int affected = 0; + + auto applyAt = [&](int x, int y) { + const size_t idx = static_cast(y) * static_cast(m_width) + static_cast(x); + const bool was = m_data[idx] != 0; + if (mode == CombineMode::Sub) { + if (!was) return false; + m_data[idx] = 0; + --m_setCount; + return true; + } + // Replace and Add both set the bit if it isn't already set. + if (was) return false; + m_data[idx] = 1; + ++m_setCount; + expandBBox(x, y); + return true; + }; + + 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) * static_cast(m_width) + static_cast(x); + if (visited[idx]) continue; + const Ogre::ColourValue here = buf.pixel(x, y); + if (!colourWithinTol(here, seed, tolerance)) continue; + visited[idx] = 1; + if (applyAt(x, y)) ++affected; + stack.push_back({x + 1, y}); + stack.push_back({x - 1, y}); + stack.push_back({x, y + 1}); + stack.push_back({x, y - 1}); + } + + // Sub mode can shrink the bbox; rebuild for correctness. + if (mode == CombineMode::Sub) + rebuildSummary(); + return affected; +} + +void PaintSelectionMask::rebuildSummary() +{ + m_setCount = 0; + m_bbox = {}; + for (int y = 0; y < m_height; ++y) { + for (int x = 0; x < m_width; ++x) { + if (m_data[static_cast(y) * static_cast(m_width) + static_cast(x)]) { + ++m_setCount; + expandBBox(x, y); + } + } + } +} + +void PaintSelectionMask::expandBBox(int x, int y) +{ + if (m_bbox.empty()) { + m_bbox = {x, y, x + 1, y + 1}; + return; + } + m_bbox.x0 = std::min(m_bbox.x0, x); + m_bbox.y0 = std::min(m_bbox.y0, y); + m_bbox.x1 = std::max(m_bbox.x1, x + 1); + m_bbox.y1 = std::max(m_bbox.y1, y + 1); +} diff --git a/src/PaintSelectionMask.h b/src/PaintSelectionMask.h new file mode 100644 index 000000000..4e82cbe17 --- /dev/null +++ b/src/PaintSelectionMask.h @@ -0,0 +1,117 @@ +#ifndef PAINTSELECTIONMASK_H +#define PAINTSELECTIONMASK_H + +#include +#include + +#include +#include +#include + +class TexturePaintBuffer; + +/** + * @brief Per-pixel boolean selection mask for the texture paint canvas. + * + * Paired 1:1 in size with a TexturePaintBuffer. A pixel is either + * "selected" (1) or "not selected" (0). Fill / erase / delete actions + * applied through TexturePaintController are restricted to the selected + * pixels when the mask is non-empty — matches the Photoshop / GIMP + * "marching ants" model where a selection scopes subsequent ops. + * + * Owns a flat `std::vector` of size width*height. Also tracks a + * tight AABB (bbox) of currently-set pixels so we can render the + * marching-ants overlay without scanning the whole buffer every frame. + * + * Pure data — no Qt or Ogre runtime dependencies beyond Vector2 / + * ColourValue used by the smart-select helper. + */ +class PaintSelectionMask +{ +public: + /// AABB of the current selection in pixel coords. [x0..x1) × [y0..y1). + struct BBox { + 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; } + }; + + PaintSelectionMask() = default; + + /// Match the buffer's size and clear all selection bits. + void resize(int width, int height); + + int width() const { return m_width; } + int height() const { return m_height; } + /// True iff every pixel is unselected. + bool isEmpty() const { return m_setCount == 0; } + /// Count of selected pixels (cached; O(1)). + int selectedCount() const { return m_setCount; } + /// Tight bbox of currently-set pixels. Empty when isEmpty(). + const BBox& bbox() const { return m_bbox; } + /// Raw mask byte buffer (row-major, top-left origin, 0 = unselected, + /// 1 = selected). + const std::vector& data() const { return m_data; } + std::vector& data() { return m_data; } + + /// True if the pixel at (x, y) is in the selection. Out-of-bounds + /// returns false. + bool isSelected(int x, int y) const; + + /// Set a single pixel's selection state. No-op on OOB. + void setSelected(int x, int y, bool selected); + + /// Clear the entire selection. + void clear(); + + /// Mark every pixel as selected. Equivalent to "select all". + void selectAll(); + + /// Invert the selection — every set bit flips. + void invert(); + + /** + * @brief Magic-wand / fuzzy select: flood-fill the selection + * starting at (sx, sy), adding pixels whose color is within + * `tolerance` of the seed color (per-channel, in [0..1] RGB + + * alpha space). + * + * @param buf The pixel buffer to sample. Mask is grown over + * pixels of `buf` whose colour is similar to + * the seed's. + * @param sx, sy Seed pixel. + * @param tolerance Per-channel L∞ tolerance, [0..1]. 0 = exact + * match only; 1 = match everything. + * @param mode kReplace = wipe then grow new region; + * kAdd = OR new region into current mask; + * kSub = remove the matched region from + * the current mask. + * @return number of pixels added/removed (depending on mode). + */ + enum class CombineMode { Replace = 0, Add = 1, Sub = 2 }; + int smartSelect(const TexturePaintBuffer& buf, + int sx, int sy, + float tolerance, + CombineMode mode = CombineMode::Replace); + +private: + /// Recompute bbox + count from scratch. Called after invert/erase ops + /// that can't update them incrementally without measurable extra work. + void rebuildSummary(); + /// Grow the bbox to include (x, y). + void expandBBox(int x, int y); + + int m_width = 0; + int m_height = 0; + /// 0 = unselected, 1 = selected. + std::vector m_data; + /// Number of pixels currently set to 1. + int m_setCount = 0; + BBox m_bbox; +}; + +#endif // PAINTSELECTIONMASK_H diff --git a/src/PaintSelectionMask_test.cpp b/src/PaintSelectionMask_test.cpp new file mode 100644 index 000000000..899da26a1 --- /dev/null +++ b/src/PaintSelectionMask_test.cpp @@ -0,0 +1,153 @@ +#include + +#include "PaintSelectionMask.h" +#include "TexturePaintBuffer.h" + +#include + +TEST(PaintSelectionMaskTest, DefaultIsEmpty) +{ + PaintSelectionMask m; + m.resize(8, 4); + EXPECT_EQ(m.width(), 8); + EXPECT_EQ(m.height(), 4); + EXPECT_TRUE(m.isEmpty()); + EXPECT_EQ(m.selectedCount(), 0); + EXPECT_TRUE(m.bbox().empty()); +} + +TEST(PaintSelectionMaskTest, SetSelectedUpdatesCountAndBBox) +{ + PaintSelectionMask m; + m.resize(8, 8); + m.setSelected(2, 3, true); + m.setSelected(5, 7, true); + EXPECT_EQ(m.selectedCount(), 2); + EXPECT_FALSE(m.isEmpty()); + const auto& b = m.bbox(); + EXPECT_EQ(b.x0, 2); + EXPECT_EQ(b.y0, 3); + EXPECT_EQ(b.x1, 6); + EXPECT_EQ(b.y1, 8); +} + +TEST(PaintSelectionMaskTest, SetSelectedOutOfBoundsIsNoop) +{ + PaintSelectionMask m; + m.resize(4, 4); + m.setSelected(-1, -1, true); + m.setSelected(4, 4, true); + EXPECT_TRUE(m.isEmpty()); +} + +TEST(PaintSelectionMaskTest, SelectAllAndInvert) +{ + PaintSelectionMask m; + m.resize(4, 4); + m.selectAll(); + EXPECT_EQ(m.selectedCount(), 16); + EXPECT_FALSE(m.isEmpty()); + m.invert(); + EXPECT_TRUE(m.isEmpty()); + m.invert(); + EXPECT_EQ(m.selectedCount(), 16); +} + +TEST(PaintSelectionMaskTest, ClearResetsAll) +{ + PaintSelectionMask m; + m.resize(4, 4); + m.selectAll(); + m.clear(); + EXPECT_TRUE(m.isEmpty()); + EXPECT_TRUE(m.bbox().empty()); +} + +TEST(PaintSelectionMaskTest, SmartSelectExactColorMatchOnly) +{ + TexturePaintBuffer buf(4, 4); + // Default = opaque white. Paint a 2x2 red corner. + for (int y = 0; y < 2; ++y) + for (int x = 0; x < 2; ++x) + buf.setPixel(x, y, Ogre::ColourValue::Red); + + PaintSelectionMask m; + m.resize(4, 4); + const int n = m.smartSelect(buf, 0, 0, 0.0f); + EXPECT_EQ(n, 4); + EXPECT_EQ(m.selectedCount(), 4); + EXPECT_TRUE(m.isSelected(0, 0)); + EXPECT_TRUE(m.isSelected(1, 1)); + EXPECT_FALSE(m.isSelected(2, 2)); // white, not red +} + +TEST(PaintSelectionMaskTest, SmartSelectAddMode) +{ + TexturePaintBuffer buf(4, 4); + // All white. Set one pixel red. + buf.setPixel(0, 0, Ogre::ColourValue::Red); + + PaintSelectionMask m; + m.resize(4, 4); + // Replace: pick red pixel — should select 1. + m.smartSelect(buf, 0, 0, 0.0f, PaintSelectionMask::CombineMode::Replace); + EXPECT_EQ(m.selectedCount(), 1); + + // Add: pick a white pixel — should grow to include all white. + m.smartSelect(buf, 3, 3, 0.0f, PaintSelectionMask::CombineMode::Add); + // 1 (red) + 15 (white pixels) = 16 + EXPECT_EQ(m.selectedCount(), 16); +} + +TEST(PaintSelectionMaskTest, SmartSelectSubMode) +{ + TexturePaintBuffer buf(4, 4); + PaintSelectionMask m; + m.resize(4, 4); + m.selectAll(); + EXPECT_EQ(m.selectedCount(), 16); + + // Subtract the white flood from the all-selected mask. + const int n = m.smartSelect(buf, 0, 0, 0.0f, PaintSelectionMask::CombineMode::Sub); + EXPECT_EQ(n, 16); + EXPECT_TRUE(m.isEmpty()); +} + +TEST(PaintSelectionMaskTest, SmartSelectToleranceExpandsRegion) +{ + TexturePaintBuffer buf(4, 4); + // Plant a near-white pixel (off by 10/255 = 0.039 per channel). + Ogre::ColourValue offWhite(245.0f/255.0f, 245.0f/255.0f, 245.0f/255.0f, 1.0f); + buf.setPixel(2, 2, offWhite); + + PaintSelectionMask m; + m.resize(4, 4); + // Strict: only white selected (off-white blocks the fill at (2,2)). + m.smartSelect(buf, 0, 0, 0.0f); + const int strict = m.selectedCount(); + + // Wide tolerance: the off-white pixel falls within range, so the + // flood reaches the entire 4×4. + m.smartSelect(buf, 0, 0, 0.1f); + const int wide = m.selectedCount(); + EXPECT_LT(strict, wide); + EXPECT_EQ(wide, 16); +} + +TEST(PaintSelectionMaskTest, SmartSelectOutOfBoundsSeedReturnsZero) +{ + TexturePaintBuffer buf(4, 4); + PaintSelectionMask m; + m.resize(4, 4); + EXPECT_EQ(m.smartSelect(buf, -1, 0, 0.5f), 0); + EXPECT_EQ(m.smartSelect(buf, 0, 99, 0.5f), 0); + EXPECT_TRUE(m.isEmpty()); +} + +TEST(PaintSelectionMaskTest, SmartSelectMaskSizeMismatchReturnsZero) +{ + TexturePaintBuffer buf(4, 4); + PaintSelectionMask m; + m.resize(2, 2); // wrong size + EXPECT_EQ(m.smartSelect(buf, 0, 0, 0.5f), 0); +} diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 8b1eff434..f015e9836 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -2,6 +2,7 @@ #include "EditModeController.h" #include "EditableMesh.h" +#include "Manager.h" #include "OgreWidget.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -14,6 +15,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -22,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -101,6 +108,56 @@ class TexturePaintStrokeCommand : public QUndoCommand bool m_skipFirstRedo = true; // command is pushed *after* the stroke applied }; +/// Undo command for a one-shot selection-mask action (fill FG / fill BG +/// / delete). Same pre/post-snapshot model as TexturePaintStrokeCommand +/// — actions like "Delete selected" can affect thousands of pixels but +/// happen atomically, so storing a full pixel snapshot is the simplest +/// correct approach (matches Photoshop's "History snapshot"). +class TexturePaintMaskActionCommand : public QUndoCommand +{ +public: + TexturePaintMaskActionCommand(TexturePaintController* controller, + std::vector before, + std::vector after, + int width, + int height, + QString textureName, + QString label) + : QUndoCommand(label) + , 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; +}; + } // namespace TexturePaintController* TexturePaintController::instance() @@ -143,6 +200,47 @@ TexturePaintController::TexturePaintController(QObject* parent) connect(sel, &SelectionSet::selectionChanged, this, &TexturePaintController::refreshSlots); } + + // Listen for scene-node destruction so we can drop dangling paint + // session references before the source Entity goes away. Without + // this, deleting a mesh while it had an active paint session or + // a wand selection mask would crash on the next frame — the + // mask-overlay clone holds a MeshPtr keyed off the dying entity, + // and m_paintMeshEntity / m_sessionEntity remain pointed at + // freed memory. Manager emits the signal BEFORE actually + // destroying the node so the entities are still valid here. + if (auto* mgr = Manager::getSingletonPtr()) { + connect(mgr, &Manager::sceneNodeDestroyed, this, + [this](Ogre::SceneNode* node) { + if (!node) return; + // If any attached object on the doomed node is the + // session entity (or the overlay clone), tear down + // the session now while the entity is still alive. + bool touches = false; + try { + const auto& objs = node->getAttachedObjects(); + for (auto* o : objs) { + if (!o) continue; + if (o == static_cast(m_paintMeshEntity) + || o == static_cast(m_sessionEntity) + || o == static_cast(m_maskOverlayEntity)) { + touches = true; + break; + } + } + } catch (...) { touches = true; } + if (touches) { + SentryReporter::addBreadcrumb("ui.action", + "Paint: scene node holding session entity destroyed — closing session"); + try { closeSession(); } catch (...) {} + } + }); + connect(mgr, &Manager::sceneClearing, this, [this]() { + // Hard scene reset — every entity is about to go away, so + // unconditionally tear down the paint session. + try { closeSession(); } catch (...) {} + }); + } } TexturePaintController::~TexturePaintController() @@ -215,10 +313,27 @@ void TexturePaintController::setPaintTarget(int target) { PaintTarget t = static_cast(target); if (t == m_target) return; + + // Abort any active stroke first — switching target mid-stroke + // crashes because beginStroke captured one set of buffers and + // updateStroke would write into the other. + if (m_strokeActive) { + try { endStroke(); } catch (...) {} + } + // Tear down the texture-paint session when leaving texture target. + // The session owns a GPU texture, rebind state, and an EditableMesh + // built for the texture-paint flow; leaving any of that in place + // when target=Vertex was the source of the "switch crashes app" + // bug — the next vertex stroke called into half-initialized state. + if (m_target == TargetTexture && t == TargetVertex && hasActiveSession()) { + try { closeSession(); } catch (...) {} + } m_target = t; SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Paint target = %1").arg(target == TargetVertex ? "vertex" : "texture")); emit paintTargetChanged(); + emit sessionChanged(); + emit smartSelectChanged(); // the mask UI is texture-only } void TexturePaintController::setActiveSlotIndex(int index) @@ -248,6 +363,12 @@ double TexturePaintController::texturePaintRadius() const return em ? em->vertexPaintRadius() : 0.05; } +QColor TexturePaintController::bgPaintColor() const +{ + auto* em = EditModeController::instance(); + return em ? em->vertexPaintBackgroundColor() : QColor(255, 255, 255); +} + double TexturePaintController::texturePaintStrength() const { auto* em = EditModeController::instance(); @@ -569,9 +690,15 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) .arg(QString::fromStdString(entity->getName())) .arg(loadedExisting ? "yes" : "no")); + // The selection mask is paired 1:1 with the paint buffer. Re-size + // on every session so smartSelect's per-pixel indexing matches. + m_mask.resize(m_buffer.width(), m_buffer.height()); + m_maskOverlayUri.clear(); + refreshPreviewUri(); if (m_uvOverlayVisible) refreshUvOverlay(); emit sessionChanged(); + emit smartSelectChanged(); return true; } @@ -991,6 +1118,8 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree m_strokeJustBegan = true; m_smudgeHavePrev = false; m_strokePreSnapshot = snapshotPixels(); + m_wandStrokeActive = false; + m_wandStartScreenPos = screenPos; SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Paint stroke begin (target=%1 tool=%2 radius=%3 strength=%4 color=%5)") .arg(m_target == TargetVertex ? "vertex" : "texture") @@ -1006,6 +1135,34 @@ void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& scre { if (!m_strokeActive || !m_paintEnabled) return; + // Wand drag-to-scrub: once the smart-select tool has seeded the + // mask at press time, every subsequent move re-runs the select + // at the same UV seed with the tolerance derived from horizontal + // mouse displacement. This lets the user adjust the selection + // size live without lifting the mouse or touching a separate UI + // control. + if (m_tool == ToolSmartSelect && m_wandStrokeActive) { + if (widget) { + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + const int viewportW = vw > 0 ? vw : 800; + // Scale: full tolerance range across one viewport width. + // That's intuitive — drag from middle to the right edge of + // the viewport ≈ 50% tolerance jump. + const double dx = static_cast(screenPos.x() - m_wandStartScreenPos.x()); + const double t = std::clamp(m_wandStartTolerance + dx / static_cast(viewportW), + 0.0, 1.0); + if (std::abs(t - m_smartSelectTolerance) > 1e-4) { + m_smartSelectTolerance = t; + emit smartSelectChanged(); + smartSelectAtUV(static_cast(m_wandSeedUV.x), + static_cast(m_wandSeedUV.y), + /*mode=*/0); + } + } + return; + } + if (m_target == TargetVertex) { // Vertex paint: get local-space hit point and apply the // vertex-color brush directly on m_paintMesh. @@ -1069,10 +1226,19 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) 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; + // Erase = paint with the user-chosen background color. The BG + // color is part of the FG/BG color pair (Photoshop / GIMP / + // Krita model). When BG is fully transparent (alpha 0) this + // matches the old "erase to transparent" behaviour; otherwise + // it lays down a solid replacement color — much more useful + // for actually masking out parts of a texture. + const QColor bg = bgPaintColor(); + const Ogre::ColourValue eraseTo( + static_cast(bg.redF()), + static_cast(bg.greenF()), + static_cast(bg.blueF()), + static_cast(bg.alphaF())); + return m_buffer.paintBrush(uv, radius, eraseTo, strength, falloff) > 0; } case ToolFill: { // Fill is a single-stamp operation — apply once per stroke @@ -1148,6 +1314,31 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) m_smudgePrev = uv; return changed; } + case ToolSmartSelect: { + // Press = seed the selection at uv. Subsequent moves don't + // re-seed — instead they nudge the tolerance and re-run the + // select at the press seed. The on-move tolerance update + // happens in updateStroke / updateStrokeUV because we need + // the screen / UV delta to compute the scrub; this case + // handles the press-time seed only. + // + // Reset to the canonical 15% tolerance each press so the + // user starts from a known baseline. Otherwise the drag from + // the previous stroke would carry over and a fresh click on + // a new region would silently use last-stroke's wide value. + if (!m_strokeJustBegan) return false; + m_strokeJustBegan = false; + m_wandStrokeActive = true; + m_wandSeedUV = uv; + constexpr double kDefaultWandTolerance = 0.15; + m_wandStartTolerance = kDefaultWandTolerance; + if (std::abs(m_smartSelectTolerance - kDefaultWandTolerance) > 1e-4) { + m_smartSelectTolerance = kDefaultWandTolerance; + emit smartSelectChanged(); + } + smartSelectAtUV(static_cast(uv.x), static_cast(uv.y), /*mode=*/0); + return false; // smart-select doesn't dirty pixels + } } return false; } @@ -1176,6 +1367,16 @@ void TexturePaintController::endStroke() { if (!m_strokeActive) return; m_strokeActive = false; + // Wand-drag stroke never dirties pixels, so the post-snapshot + // diff below would be a no-op; just clear the wand flags and bail. + if (m_wandStrokeActive) { + m_wandStrokeActive = false; + m_strokePreSnapshot.clear(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Wand stroke end (final tolerance=%1)") + .arg(m_smartSelectTolerance, 0, 'f', 3)); + return; + } // Ensure any pending debounced GPU upload runs immediately so // the final stroke pixels are visible before the user releases. if (!m_buffer.dirtyRect().empty()) @@ -1195,42 +1396,31 @@ void TexturePaintController::endStroke() m_textureName); UndoManager::getSingleton()->push(cmd); SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (committed)"); - // 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. + // Cache the painted pixels in-memory only. The user's original + // texture file on disk is NEVER touched during a stroke — they + // would lose paint on Cmd-Z, but they would also lose their + // unmodified asset if they were just experimenting. The + // EmbeddedTextureCache feeds the FBX exporter (and the in-engine + // re-bind), so an explicit Save / Export still picks up the + // painted texture. To persist to disk the user must invoke "Save + // to Original" or "Save…" / export. See bakeToOriginalFile(). if (m_target == TargetTexture && !m_originalTextureName.isEmpty()) { - // 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 (...) {} - } + 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' (no disk write)") + .arg(bytes.size()).arg(m_originalTextureName)); + } + } catch (...) {} } } @@ -1479,20 +1669,25 @@ void TexturePaintController::closeSession() } catch (...) {} m_ogreTexture.reset(); } - if (m_ringObj && m_paintMeshEntity) { - try { - auto* mgr = m_paintMeshEntity->_getManager(); - if (mgr) { + // Source scene manager from the global singleton — going through + // m_paintMeshEntity->_getManager() is unsafe if the entity was + // cascade-destroyed (mesh removed while paint was active). + { + auto* mgr = Manager::getSingletonPtr(); + auto* sceneMgr = mgr ? mgr->getSceneMgr() : nullptr; + if (sceneMgr) { + try { if (m_ringNode) { m_ringNode->detachAllObjects(); - mgr->getRootSceneNode()->removeChild(m_ringNode); - mgr->destroySceneNode(m_ringNode); - m_ringNode = nullptr; + sceneMgr->getRootSceneNode()->removeChild(m_ringNode); + sceneMgr->destroySceneNode(m_ringNode); } - mgr->destroyManualObject(m_ringObj); - m_ringObj = nullptr; - } - } catch (...) {} + if (m_ringObj) + sceneMgr->destroyManualObject(m_ringObj); + } catch (...) {} + } + m_ringNode = nullptr; + m_ringObj = nullptr; } m_paintMesh.reset(); m_paintMeshEntity = nullptr; @@ -1510,6 +1705,15 @@ void TexturePaintController::closeSession() m_uvOverlayUri.clear(); emit uvOverlayChanged(); } + // Tear down smart-select state too — a stale mask sized for the + // previous buffer would crash smartSelectAtUV. Also drop the on- + // mesh wand overlay (it pointed at the old entity). + m_mask = PaintSelectionMask(); + destroyMeshMaskOverlay(); + if (!m_maskOverlayUri.isEmpty()) { + m_maskOverlayUri.clear(); + emit smartSelectChanged(); + } m_previewUri.clear(); emit previewChanged(); emit sessionChanged(); @@ -1528,6 +1732,10 @@ bool TexturePaintController::beginStrokeUV(double u, double v) m_strokeJustBegan = true; m_smudgeHavePrev = false; m_strokePreSnapshot = snapshotPixels(); + m_wandStrokeActive = false; + // Re-use the screen-pos field to stash press UV (u in pixel-ish + // units). updateStrokeUV reads the delta from the current u. + m_wandStartScreenPos = QPoint(static_cast(u * 10000.0), 0); emit hoveredUVChanged(u, v); updateStrokeUV(u, v); return true; @@ -1538,6 +1746,24 @@ 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); + + // Wand drag-to-scrub from the 2D thumbnail. Horizontal UV delta + // maps directly to a tolerance delta: 0..1 UV span = full + // tolerance range, so the user can dial in coverage by sliding + // toward / away from the seed pixel. + if (m_tool == ToolSmartSelect && m_wandStrokeActive) { + const double pressU = static_cast(m_wandStartScreenPos.x()) / 10000.0; + const double du = u - pressU; + const double t = std::clamp(m_wandStartTolerance + du, 0.0, 1.0); + if (std::abs(t - m_smartSelectTolerance) > 1e-4) { + m_smartSelectTolerance = t; + emit smartSelectChanged(); + smartSelectAtUV(static_cast(m_wandSeedUV.x), + static_cast(m_wandSeedUV.y), + /*mode=*/0); + } + return; + } // 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; @@ -1859,6 +2085,14 @@ bool TexturePaintController::hitTestLocalPoint(OgreWidget* widget, const QPoint& return found; } +bool TexturePaintController::wouldStrokeHit(OgreWidget* widget, + const QPoint& screenPos) const +{ + if (!m_paintMesh || !m_paintMeshEntity || !widget) return false; + Ogre::Vector2 uv; + return hitTestUV(screenPos, widget, uv); +} + bool TexturePaintController::findMeshPointForUV(const Ogre::Vector2& uv, Ogre::Vector3& outLocal, Ogre::Vector3& outNormal) const @@ -1961,3 +2195,506 @@ void TexturePaintController::drawHoverRingAt(const Ogre::Vector3& localPos, } m_ringObj->end(); } + +// --------------------------------------------------------------------------- +// Smart-select / selection-mask API +// --------------------------------------------------------------------------- + +void TexturePaintController::setSmartSelectTolerance(double t) +{ + const double clamped = std::clamp(t, 0.0, 1.0); + if (m_smartSelectTolerance == clamped) return; + m_smartSelectTolerance = clamped; + emit smartSelectChanged(); +} + +bool TexturePaintController::hasSelectionMask() const +{ + return !m_mask.isEmpty(); +} + +int TexturePaintController::selectedPixelCount() const +{ + return m_mask.selectedCount(); +} + +void TexturePaintController::clearSelectionMask() +{ + if (m_mask.isEmpty()) return; + m_mask.clear(); + m_maskOverlayUri.clear(); + destroyMeshMaskOverlay(); + SentryReporter::addBreadcrumb("ui.action", "Smart select: cleared"); + emit smartSelectChanged(); +} + +void TexturePaintController::selectAllMask() +{ + if (!hasActiveSession()) return; + if (m_mask.width() != m_buffer.width() || m_mask.height() != m_buffer.height()) + m_mask.resize(m_buffer.width(), m_buffer.height()); + m_mask.selectAll(); + SentryReporter::addBreadcrumb("ui.action", "Smart select: select all"); + scheduleMaskOverlayRefresh(); + emit smartSelectChanged(); +} + +void TexturePaintController::invertSelectionMask() +{ + if (!hasActiveSession()) return; + if (m_mask.width() != m_buffer.width() || m_mask.height() != m_buffer.height()) + m_mask.resize(m_buffer.width(), m_buffer.height()); + m_mask.invert(); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Smart select: invert (%1 px now)").arg(m_mask.selectedCount())); + scheduleMaskOverlayRefresh(); + emit smartSelectChanged(); +} + +int TexturePaintController::smartSelectAtUV(double u, double v, int mode) +{ + if (!hasActiveSession()) return 0; + if (m_mask.width() != m_buffer.width() || m_mask.height() != m_buffer.height()) + m_mask.resize(m_buffer.width(), m_buffer.height()); + + int sx = 0, sy = 0; + m_buffer.uvToPixel(Ogre::Vector2(static_cast(u), static_cast(v)), + sx, sy); + const auto cmode = (mode == 1) ? PaintSelectionMask::CombineMode::Add + : (mode == 2) ? PaintSelectionMask::CombineMode::Sub + : PaintSelectionMask::CombineMode::Replace; + const int affected = m_mask.smartSelect(m_buffer, sx, sy, + static_cast(m_smartSelectTolerance), + cmode); + if (affected > 0) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Smart select: %1 px (mode=%2, tol=%3)") + .arg(affected).arg(mode).arg(m_smartSelectTolerance, 0, 'f', 2)); + scheduleMaskOverlayRefresh(); + emit smartSelectChanged(); + } + return affected; +} + +namespace { + +// Apply `apply` to every pixel in `mask`. Returns count of affected +// pixels. Caller owns the before/after snapshots for undo. +template +int applyToMaskedPixels(TexturePaintBuffer& buf, const PaintSelectionMask& mask, F&& apply) +{ + if (mask.isEmpty()) return 0; + const int W = buf.width(); + const int H = buf.height(); + if (mask.width() != W || mask.height() != H) return 0; + const auto& maskData = mask.data(); + auto& px = buf.data(); + const auto& bb = mask.bbox(); + int affected = 0; + for (int y = bb.y0; y < bb.y1; ++y) { + for (int x = bb.x0; x < bb.x1; ++x) { + const size_t i = static_cast(y) * static_cast(W) + static_cast(x); + if (!maskData[i]) continue; + const size_t off = i * 4u; + apply(px[off + 0], px[off + 1], px[off + 2], px[off + 3]); + ++affected; + } + } + if (affected > 0) + buf.markDirty(bb.x0, bb.y0, bb.x1, bb.y1); + return affected; +} + +} // namespace + +int TexturePaintController::fillMaskWithFG() +{ + if (!hasActiveSession() || !hasSelectionMask()) return 0; + auto before = m_buffer.data(); + const QColor c = texturePaintColor(); + const uint8_t fr = static_cast(std::lround(c.redF() * 255.0)); + const uint8_t fg = static_cast(std::lround(c.greenF() * 255.0)); + const uint8_t fb = static_cast(std::lround(c.blueF() * 255.0)); + const uint8_t fa = static_cast(std::lround(c.alphaF() * 255.0)); + const int affected = applyToMaskedPixels(m_buffer, m_mask, + [&](uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { + r = fr; g = fg; b = fb; a = fa; + }); + if (affected <= 0) return 0; + UndoManager::getSingleton()->push(new TexturePaintMaskActionCommand( + this, std::move(before), m_buffer.data(), + m_buffer.width(), m_buffer.height(), m_textureName, + QStringLiteral("Fill selection (FG)"))); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Smart select: filled %1 px with FG %2") + .arg(affected).arg(c.name(QColor::HexRgb))); + flushDirtyToOgre(); + return affected; +} + +int TexturePaintController::fillMaskWithBG() +{ + if (!hasActiveSession() || !hasSelectionMask()) return 0; + auto before = m_buffer.data(); + const QColor c = bgPaintColor(); + const uint8_t fr = static_cast(std::lround(c.redF() * 255.0)); + const uint8_t fg = static_cast(std::lround(c.greenF() * 255.0)); + const uint8_t fb = static_cast(std::lround(c.blueF() * 255.0)); + const uint8_t fa = static_cast(std::lround(c.alphaF() * 255.0)); + const int affected = applyToMaskedPixels(m_buffer, m_mask, + [&](uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { + r = fr; g = fg; b = fb; a = fa; + }); + if (affected <= 0) return 0; + UndoManager::getSingleton()->push(new TexturePaintMaskActionCommand( + this, std::move(before), m_buffer.data(), + m_buffer.width(), m_buffer.height(), m_textureName, + QStringLiteral("Fill selection (BG)"))); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Smart select: filled %1 px with BG %2") + .arg(affected).arg(c.name(QColor::HexArgb))); + flushDirtyToOgre(); + return affected; +} + +int TexturePaintController::deleteMaskPixels() +{ + if (!hasActiveSession() || !hasSelectionMask()) return 0; + auto before = m_buffer.data(); + const int affected = applyToMaskedPixels(m_buffer, m_mask, + [](uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { + r = 0; g = 0; b = 0; a = 0; + }); + if (affected <= 0) return 0; + UndoManager::getSingleton()->push(new TexturePaintMaskActionCommand( + this, std::move(before), m_buffer.data(), + m_buffer.width(), m_buffer.height(), m_textureName, + QStringLiteral("Delete selection"))); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Smart select: deleted %1 px").arg(affected)); + flushDirtyToOgre(); + return affected; +} + +void TexturePaintController::scheduleMaskOverlayRefresh() +{ + if (m_maskOverlayRefreshScheduled) return; + m_maskOverlayRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_maskOverlayRefreshScheduled = false; + refreshMaskOverlay(); + }); +} + +void TexturePaintController::refreshMaskOverlay() +{ + // Keep the on-mesh 3D overlay in sync with the mask. Doing it here + // (the debounced path) covers smartSelect, selectAll, and invert + // without each caller having to remember to refresh both layers. + refreshMeshMaskOverlay(); + + const int W = m_mask.width(); + const int H = m_mask.height(); + if (W <= 0 || H <= 0 || m_mask.isEmpty()) { + if (!m_maskOverlayUri.isEmpty()) { + m_maskOverlayUri.clear(); + emit smartSelectChanged(); + } + return; + } + // Render the mask as a high-contrast translucent overlay: yellow tint + // inside, black 1px outline at the boundary (any selected pixel with + // an unselected 4-neighbor). This is the "marching ants" mock — + // animation comes from the QML side if we add it later. + const int previewW = std::min(W, 512); + const int previewH = std::min(H, 512); + const float sx = static_cast(W) / previewW; + const float sy = static_cast(H) / previewH; + // ARGB32 (not RGBA8888) so qRgba's 0xAARRGGBB packing maps to the + // image's bytes correctly. With RGBA8888 the channels are + // re-ordered to R,G,B,A in memory and the values written via qRgba + // come out swizzled (yellow → light blue). + QImage img(previewW, previewH, QImage::Format_ARGB32); + img.fill(Qt::transparent); + const auto& d = m_mask.data(); + auto sel = [&](int x, int y) { + if (x < 0 || y < 0 || x >= W || y >= H) return false; + return d[static_cast(y) * static_cast(W) + static_cast(x)] != 0; + }; + for (int py = 0; py < previewH; ++py) { + const int y = std::min(H - 1, static_cast(py * sy)); + auto* line = reinterpret_cast(img.scanLine(py)); + for (int px = 0; px < previewW; ++px) { + const int x = std::min(W - 1, static_cast(px * sx)); + const bool inside = sel(x, y); + if (!inside) continue; + const bool boundary = !sel(x - 1, y) || !sel(x + 1, y) + || !sel(x, y - 1) || !sel(x, y + 1); + line[px] = boundary ? qRgba(0, 0, 0, 220) : qRgba(255, 240, 0, 80); + } + } + QByteArray bytes; + QBuffer qbuf(&bytes); + qbuf.open(QIODevice::WriteOnly); + if (!img.save(&qbuf, "PNG")) return; + m_maskOverlayUri = QStringLiteral("data:image/png;base64,") + QString::fromLatin1(bytes.toBase64()); + emit smartSelectChanged(); +} + +// --------------------------------------------------------------------------- +// Detached texture editor window +// --------------------------------------------------------------------------- + +void TexturePaintController::openEditorWindow() +{ + if (m_editorWindow) { + // Already open — raise / show. + if (auto* w = qobject_cast(m_editorWindow)) { + w->show(); + w->raise(); + w->requestActivate(); + } + return; + } + // Use a QQmlApplicationEngine so the loaded Window registers as a + // top-level (matches MaterialEditor's pattern). We also need to + // re-register the PropertiesPanel singleton in this new engine so + // the imported singleton resolves at QML load time — main.cpp's + // registrations are per-engine in Qt 6. + auto* engine = new QQmlApplicationEngine(this); + const QString appDir = QCoreApplication::applicationDirPath(); + engine->addImportPath(appDir + "/qml"); + engine->addImportPath(QLibraryInfo::path(QLibraryInfo::QmlImportsPath)); + + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "TexturePaintController", + [](QQmlEngine* e, QJSEngine*) -> QObject* { + return TexturePaintController::qmlInstance(e, nullptr); + }); + + bool handled = false; + connect(engine, &QQmlApplicationEngine::objectCreated, this, + [this, engine, &handled](QObject* obj, const QUrl&) { + handled = true; + if (!obj) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture editor window: QML load failed")); + engine->deleteLater(); + return; + } + m_editorWindow = obj; + if (auto* w = qobject_cast(obj)) { + connect(w, &QQuickWindow::visibleChanged, this, + [this, w, engine](bool vis) { + if (vis || m_editorWindow != w) return; + m_editorWindow = nullptr; + emit editorWindowChanged(); + engine->deleteLater(); + }); + w->show(); + w->raise(); + w->requestActivate(); + } + emit editorWindowChanged(); + }, Qt::DirectConnection); + + engine->load(QUrl(QStringLiteral("qrc:/PropertiesPanel/TextureEditorWindow.qml"))); + if (!handled) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture editor window: load() returned without objectCreated firing")); + } + SentryReporter::addBreadcrumb("ui.action", "Texture editor window opened"); +} + +void TexturePaintController::closeEditorWindow() +{ + if (!m_editorWindow) return; + if (auto* w = qobject_cast(m_editorWindow)) { + w->close(); + // visibleChanged handler will null m_editorWindow + emit. + } else { + m_editorWindow->deleteLater(); + m_editorWindow = nullptr; + emit editorWindowChanged(); + } +} + +// --------------------------------------------------------------------------- +// On-mesh wand-selection overlay +// --------------------------------------------------------------------------- + +void TexturePaintController::refreshMeshMaskOverlay() +{ + if (!m_paintMeshEntity || m_mask.isEmpty()) { + destroyMeshMaskOverlay(); + return; + } + auto* entity = m_paintMeshEntity; + auto* sceneMgr = entity->_getManager(); + auto* parentNode = entity->getParentSceneNode(); + if (!sceneMgr || !parentNode) return; + + // (1) Build / refresh the overlay GPU texture from the mask data. + // Match the 2D preview's marching-ants palette so the 3D and + // 2D views read as the same selection: yellow tint inside + // (255,240,0 α≈80), black outline (0,0,0 α≈220) on any + // selected pixel adjacent to an unselected one. Boundary + // test is the same 4-neighbor as refreshMaskOverlay. + const int W = m_mask.width(); + const int H = m_mask.height(); + if (W <= 0 || H <= 0) { + destroyMeshMaskOverlay(); + return; + } + std::vector rgba(static_cast(W) * static_cast(H) * 4u, 0); + const auto& d = m_mask.data(); + auto sel = [&](int x, int y) { + if (x < 0 || y < 0 || x >= W || y >= H) return false; + return d[static_cast(y) * static_cast(W) + static_cast(x)] != 0; + }; + for (int y = 0; y < H; ++y) { + for (int x = 0; x < W; ++x) { + const size_t i = static_cast(y) * static_cast(W) + static_cast(x); + if (!d[i]) continue; + const bool boundary = !sel(x - 1, y) || !sel(x + 1, y) + || !sel(x, y - 1) || !sel(x, y + 1); + const size_t off = i * 4u; + if (boundary) { + rgba[off + 0] = 0; + rgba[off + 1] = 0; + rgba[off + 2] = 0; + rgba[off + 3] = 220; + } else { + rgba[off + 0] = 255; + rgba[off + 1] = 240; + rgba[off + 2] = 0; + rgba[off + 3] = 80; + } + } + } + const std::string texName = "QMEPaintMaskOverlay_" + + std::to_string(reinterpret_cast(this)); + auto& texMgr = Ogre::TextureManager::getSingleton(); + if (m_maskOverlayTex && (static_cast(m_maskOverlayTex->getWidth()) != W + || static_cast(m_maskOverlayTex->getHeight()) != H)) { + // Size changed — drop and recreate. + try { texMgr.remove(m_maskOverlayTex); } catch (...) {} + m_maskOverlayTex.reset(); + } + if (!m_maskOverlayTex) { + m_maskOverlayTex = texMgr.createManual( + texName, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, + Ogre::TEX_TYPE_2D, W, H, 0, + Ogre::PF_BYTE_RGBA, + Ogre::TU_DYNAMIC_WRITE_ONLY); + } + try { + auto buf = m_maskOverlayTex->getBuffer(); + if (buf) { + Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, rgba.data()); + buf->blitFromMemory(pb); + } + } catch (...) {} + + // (2) Build / fetch the unlit transparent material that samples + // the overlay texture. One material per controller — reused + // across refreshes. + if (m_maskOverlayMatName.empty()) { + m_maskOverlayMatName = "QMEPaintMaskOverlay_Mat_" + + std::to_string(reinterpret_cast(this)); + } + auto& matMgr = Ogre::MaterialManager::getSingleton(); + Ogre::MaterialPtr mat = matMgr.getByName(m_maskOverlayMatName); + if (!mat) { + mat = matMgr.create(m_maskOverlayMatName, + Ogre::ResourceGroupManager::INTERNAL_RESOURCE_GROUP_NAME); + auto* tech = mat->getTechnique(0); + auto* pass = tech->getPass(0); + pass->setLightingEnabled(false); + pass->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA); + pass->setDepthWriteEnabled(false); + pass->setDepthCheckEnabled(true); + pass->setDepthBias(1.0f, 1.0f); // tiny z-pull-in so we sit on top + pass->setCullingMode(Ogre::CULL_NONE); + auto* tus = pass->createTextureUnitState(m_maskOverlayTex->getName()); + tus->setTextureFiltering(Ogre::TFO_NONE); + } else { + // Re-point the existing TUS at the (possibly resized) texture. + try { + auto* pass = mat->getTechnique(0)->getPass(0); + if (pass->getNumTextureUnitStates() > 0) { + pass->getTextureUnitState(0)->setTextureName(m_maskOverlayTex->getName()); + } + } catch (...) {} + } + + // (3) Create / refresh the duplicate Entity that draws the mesh + // with the overlay material. Sharing the source MeshPtr means + // the verts / UVs / animation match the base mesh for free. + if (!m_maskOverlayEntity) { + try { + const std::string entName = "QMEPaintMaskOverlay_Ent_" + + std::to_string(reinterpret_cast(this)); + m_maskOverlayEntity = sceneMgr->createEntity(entName, entity->getMesh()->getName()); + m_maskOverlayEntity->setMaterialName(m_maskOverlayMatName); + m_maskOverlayEntity->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY - 1); + m_maskOverlayEntity->setCastShadows(false); + m_maskOverlayEntity->setQueryFlags(0); + } catch (const Ogre::Exception& e) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Mask overlay: createEntity failed: %1") + .arg(QString::fromStdString(e.getDescription()))); + return; + } + } else { + // Ensure every submesh also samples the overlay material — Ogre + // creates one sub-entity per submesh on createEntity and they + // each get the source material unless we override. + for (unsigned i = 0; i < m_maskOverlayEntity->getNumSubEntities(); ++i) + m_maskOverlayEntity->getSubEntity(i)->setMaterialName(m_maskOverlayMatName); + } + if (!m_maskOverlayNode) { + m_maskOverlayNode = parentNode->createChildSceneNode(); + m_maskOverlayNode->attachObject(m_maskOverlayEntity); + } +} + +void TexturePaintController::destroyMeshMaskOverlay() +{ + // Source scene manager via the global Manager — touching + // m_maskOverlayEntity->_getManager() is unsafe when the entity + // was already cascade-destroyed (e.g. user removed the mesh + // while a wand selection was up). Manager's sceneNodeDestroyed + // signal handler nulls our pointers preemptively, so by the time + // we reach here the cleanup may be a partial no-op — guard each + // step independently. + auto* mgr = Manager::getSingletonPtr(); + auto* sceneMgr = mgr ? mgr->getSceneMgr() : nullptr; + + if (m_maskOverlayNode && sceneMgr) { + try { + m_maskOverlayNode->detachAllObjects(); + auto* parent = m_maskOverlayNode->getParentSceneNode(); + if (parent) parent->removeChild(m_maskOverlayNode); + sceneMgr->destroySceneNode(m_maskOverlayNode); + } catch (...) {} + } + m_maskOverlayNode = nullptr; + + if (m_maskOverlayEntity && sceneMgr) { + try { + // Only destroy if Ogre still thinks it owns this entity + // by name — otherwise it was already cascade-destroyed. + const std::string& n = m_maskOverlayEntity->getName(); + if (sceneMgr->hasEntity(n)) + sceneMgr->destroyEntity(m_maskOverlayEntity); + } catch (...) {} + } + m_maskOverlayEntity = nullptr; + + if (m_maskOverlayTex) { + try { Ogre::TextureManager::getSingleton().remove(m_maskOverlayTex); } catch (...) {} + m_maskOverlayTex.reset(); + } +} diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 71b4a6f21..6dafea65d 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -1,6 +1,7 @@ #ifndef TEXTUREPAINTCONTROLLER_H #define TEXTUREPAINTCONTROLLER_H +#include "PaintSelectionMask.h" #include "TexturePaintBuffer.h" #include @@ -85,10 +86,11 @@ class TexturePaintController : public QObject public: enum BrushTool { ToolPaint = 0, ///< Lerp pixels toward brush color. - ToolErase = 1, ///< Paint transparent (alpha 0). Reveals layer below if any. + ToolErase = 1, ///< Paint with BG color (was: paint transparent). 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. + ToolSmartSelect = 5, ///< Fuzzy / magic-wand region select by color. }; Q_ENUM(BrushTool) @@ -112,6 +114,10 @@ class TexturePaintController : public QObject /// @name Brush parameters (read-only mirror of EditModeController) /// @{ QColor texturePaintColor() const; + /// Secondary "background" color. Used by: + /// - ToolErase (replaces pixels with BG instead of transparent) + /// - mask "fill with BG" action + QColor bgPaintColor() const; double texturePaintRadius() const; double texturePaintStrength() const; double texturePaintFalloff() const; @@ -252,6 +258,56 @@ class TexturePaintController : public QObject /// @brief End the session — release the paint buffer. Q_INVOKABLE void closeSession(); + /// @name Selection mask (smart-select / magic-wand) + /// @{ + /// Smart-select tolerance, [0..1]. Mirrors GIMP's "Threshold" slider. + /// 0 = exact color only; 1 = the whole texture. + Q_PROPERTY(double smartSelectTolerance READ smartSelectTolerance + WRITE setSmartSelectTolerance NOTIFY smartSelectChanged) + double smartSelectTolerance() const { return m_smartSelectTolerance; } + void setSmartSelectTolerance(double t); + + /// True iff the user has selected at least one pixel. + Q_PROPERTY(bool hasSelectionMask READ hasSelectionMask NOTIFY smartSelectChanged) + bool hasSelectionMask() const; + + /// Number of pixels currently in the selection. + Q_PROPERTY(int selectedPixelCount READ selectedPixelCount NOTIFY smartSelectChanged) + int selectedPixelCount() const; + + /// PNG data URI of the selection mask (marching-ants–style outline + /// rendered as a transparent overlay). Empty when no selection. + Q_PROPERTY(QString maskOverlayDataUri READ maskOverlayDataUri NOTIFY smartSelectChanged) + QString maskOverlayDataUri() const { return m_maskOverlayUri; } + + /// Clear the entire selection. + Q_INVOKABLE void clearSelectionMask(); + /// Select every pixel. + Q_INVOKABLE void selectAllMask(); + /// Invert the current selection. + Q_INVOKABLE void invertSelectionMask(); + + /// Smart-select via a UV coordinate. `mode` is 0=replace, 1=add, 2=subtract. + /// Returns number of pixels added/removed. + Q_INVOKABLE int smartSelectAtUV(double u, double v, int mode = 0); + + /// Open the detached texture editor window (or raise it if it's + /// already open). The window shares the same paint pipeline as the + /// inline 2D preview, so strokes done in it update the 3D viewport + /// in real time and vice versa. + Q_INVOKABLE void openEditorWindow(); + Q_INVOKABLE void closeEditorWindow(); + Q_PROPERTY(bool editorWindowOpen READ editorWindowOpen NOTIFY editorWindowChanged) + bool editorWindowOpen() const { return m_editorWindow != nullptr; } + + /// Apply an action to the current selection. No-op when the mask is empty. + /// Each action pushes a single undo command (TexturePaintMaskActionCommand). + Q_INVOKABLE int fillMaskWithFG(); + Q_INVOKABLE int fillMaskWithBG(); + /// Delete = set selected pixels to fully transparent black (0,0,0,0). + Q_INVOKABLE int deleteMaskPixels(); + /// @} + /// 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; @@ -261,6 +317,13 @@ class TexturePaintController : public QObject Ogre::Vector3& outLocal, Ogre::Vector3& outNormal) const; + /// Quick "would beginStroke hit the mesh at screenPos?" probe. + /// Public wrapper around hitTestUV used by TransformOperator to + /// decide whether a click landed on the mesh or empty space (for + /// "click outside clears wand selection" behaviour). Returns false + /// on a miss or when there's no paint session. + bool wouldStrokeHit(OgreWidget* widget, const QPoint& screenPos) const; + /// Read-only access for tests. const TexturePaintBuffer& buffer() const { return m_buffer; } TexturePaintBuffer& mutableBuffer() { return m_buffer; } @@ -278,6 +341,8 @@ class TexturePaintController : public QObject void slotsChanged(); void previewChanged(); void uvOverlayChanged(); + void smartSelectChanged(); + void editorWindowChanged(); /// 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". @@ -434,6 +499,52 @@ class TexturePaintController : public QObject QString m_uvOverlayUri; bool m_uvOverlayVisible = false; + // Smart-select state. Mask is per-pixel boolean, paired with + // m_buffer; the overlay PNG mirrors it for QML. + PaintSelectionMask m_mask; + double m_smartSelectTolerance = 0.15; + QString m_maskOverlayUri; + bool m_maskOverlayRefreshScheduled = false; + + // Wand "drag during stroke" state. While the user holds the mouse + // down with the smart-select tool active we remember the seed UV + // and the tolerance at press time; subsequent moves don't reseed + // (would jitter the selection) — they nudge the tolerance via + // horizontal mouse displacement and re-run smartSelect at the + // original seed. + bool m_wandStrokeActive = false; + Ogre::Vector2 m_wandSeedUV = Ogre::Vector2::ZERO; + double m_wandStartTolerance = 0.15; + QPoint m_wandStartScreenPos; + + /// Regenerate `m_maskOverlayUri` (PNG, base64) from `m_mask`. + /// Debounced through QTimer::singleShot. + void scheduleMaskOverlayRefresh(); + void refreshMaskOverlay(); + + // Detached texture editor window. Owned heap-allocated; instantiated + // lazily when the user clicks "Open Editor Window" and torn down on + // window close. Held as a generic QObject* so the header doesn't + // need to pull in the QML engine headers. + QObject* m_editorWindow = nullptr; + + // On-mesh wand-selection overlay: a yellow tinted copy of the mesh + // rendered just above the original geometry in mesh-local space, + // textured with the mask PNG so users can see which pixels in UV + // space line up with which spots on the 3D model. Built on demand + // when the mask becomes non-empty; torn down when it clears. + Ogre::Entity* m_maskOverlayEntity = nullptr; + Ogre::SceneNode* m_maskOverlayNode = nullptr; + Ogre::TexturePtr m_maskOverlayTex; + std::string m_maskOverlayMatName; + + /// (Re)build the per-mesh mask overlay so the selected pixels + /// appear highlighted on the 3D model. Called whenever the mask + /// changes shape. Tears down the overlay when the mask is empty. + void refreshMeshMaskOverlay(); + /// Free overlay scene state. Safe to call when nothing is bound. + void destroyMeshMaskOverlay(); + static TexturePaintController* s_instance; }; diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index bc68bbe04..59afb39ad 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -1033,6 +1033,21 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) { auto* texPaint = TexturePaintController::instance(); if (texPaint->texturePaintEnabled() && mTransformState == TS_SELECT) { + // For the Wand tool only: probe the mesh first. If the + // click missed (empty space behind the model), Photoshop + // / GIMP convention is to clear the current selection. + // beginStroke would otherwise still return true on a miss + // for texture paint (it creates the session before + // hit-testing), masking the click-outside case. + if (texPaint->brushTool() == TexturePaintController::ToolSmartSelect + && !texPaint->wouldStrokeHit(m_pActiveWidget, e->pos())) { + if (texPaint->hasSelectionMask()) { + texPaint->clearSelectionMask(); + SentryReporter::addBreadcrumb("ui.action", + "Wand: cleared selection (click outside mesh)"); + } + return; + } if (texPaint->beginStroke(m_pActiveWidget, e->pos())) { mTexturePaintDragActive = true; SentryReporter::addBreadcrumb("ui.action", "Texture paint: stroke begin"); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index f71b29654..405ba52b0 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1252,66 +1252,28 @@ void MainWindow::initToolBar() auto* emPaint = EditModeController::instance(); - auto* colorRow = new QHBoxLayout(); - colorRow->addWidget(new QLabel(tr("Color:"), paintSettings)); - auto* colorBtn = new QPushButton(paintSettings); - colorBtn->setFixedSize(52, 24); - auto syncPaintColorBtn = [colorBtn, emPaint]() { - const QColor c = emPaint->vertexPaintColor(); - colorBtn->setStyleSheet( - QStringLiteral("background-color: %1; border: 1px solid #888; border-radius: 3px;") - .arg(c.name(QColor::HexRgb))); - }; - syncPaintColorBtn(); - connect(colorBtn, &QPushButton::clicked, this, [this, emPaint, syncPaintColorBtn]() { - SentryReporter::addBreadcrumb("ui.action", "Vertex paint color picker opened"); - QColor c = QColorDialog::getColor(emPaint->vertexPaintColor(), this, tr("Brush color")); - if (c.isValid()) - emPaint->setVertexPaintColor(c); - syncPaintColorBtn(); - }); - connect(emPaint, &EditModeController::vertexPaintChanged, this, syncPaintColorBtn); - colorRow->addWidget(colorBtn); - colorRow->addStretch(); - paintLay->addLayout(colorRow); - - static const char* kPaintSwatches[] = { - "#ffffff", "#cccccc", "#888888", "#444444", "#000000", - "#ff0000", "#ff8800", "#ffff00", "#88ff00", "#00ff00", - "#00ff88", "#00ffff", "#0088ff", "#0000ff", "#8800ff", - "#ff00ff", "#ff0088", "#8b4513", "#ffd700", "#90ee90", - "#ff6347", "#00ced1", "#dda0dd", "#f0e68c" - }; - auto* swatchGrid = new QGridLayout(); - swatchGrid->setSpacing(3); - for (size_t i = 0; i < sizeof(kPaintSwatches) / sizeof(kPaintSwatches[0]); ++i) { - const int r = static_cast(i) / 8; - const int col = static_cast(i) % 8; - auto* sw = new QPushButton(paintSettings); - sw->setFixedSize(22, 22); - const QString hex = QString::fromUtf8(kPaintSwatches[i]); - sw->setStyleSheet(QStringLiteral("background-color: %1; border: 1px solid #666; border-radius: 2px;").arg(hex)); - connect(sw, &QPushButton::clicked, this, [emPaint, hex, syncPaintColorBtn]() { - emPaint->setVertexPaintBrushColor(hex); - syncPaintColorBtn(); - }); - swatchGrid->addWidget(sw, r, col); - } - paintLay->addLayout(swatchGrid); - + // (Color selection deliberately removed from the brush popup — + // the FG/BG swatch widget on the main toolbar is now the single + // source of truth for the brush color. Duplicating it here would + // just add a second place to keep in sync.) + + // Brush radius slider. Range: 0.001 → 2.0 in mesh-local units. + // Mapped via /1000 so int slider ticks are 1mm resolution at the + // low end (where users want pixel-precise dots) and still reach a + // 2.0 unit radius for broad washes on tall meshes. auto* radLabel = new QLabel(paintSettings); auto* radSlider = new QSlider(Qt::Horizontal, paintSettings); - radSlider->setRange(2, 200); + radSlider->setRange(1, 2000); auto syncRad = [radLabel, radSlider, emPaint]() { QSignalBlocker b(radSlider); - const int v = qBound(2, static_cast(qRound(emPaint->vertexPaintRadius() * 100.0)), 200); + const int v = qBound(1, static_cast(qRound(emPaint->vertexPaintRadius() * 1000.0)), 2000); radSlider->setValue(v); - radLabel->setText(tr("Radius (local): %1").arg(emPaint->vertexPaintRadius(), 0, 'f', 2)); + radLabel->setText(tr("Radius (local): %1").arg(emPaint->vertexPaintRadius(), 0, 'f', 3)); }; syncRad(); connect(radSlider, &QSlider::valueChanged, this, [this, emPaint, radLabel](int v) { - emPaint->setVertexPaintRadius(v / 100.0); - radLabel->setText(tr("Radius (local): %1").arg(emPaint->vertexPaintRadius(), 0, 'f', 2)); + emPaint->setVertexPaintRadius(v / 1000.0); + radLabel->setText(tr("Radius (local): %1").arg(emPaint->vertexPaintRadius(), 0, 'f', 3)); }); connect(emPaint, &EditModeController::vertexPaintChanged, this, syncRad); paintLay->addWidget(radLabel); @@ -1357,8 +1319,7 @@ void MainWindow::initToolBar() vertexPaintMenu->addAction(paintWa); vertexPaintButton->setMenu(vertexPaintMenu); - connect(vertexPaintMenu, &QMenu::aboutToShow, this, [syncPaintColorBtn, syncRad, syncStr, syncFalloff]() { - syncPaintColorBtn(); + connect(vertexPaintMenu, &QMenu::aboutToShow, this, [syncRad, syncStr, syncFalloff]() { syncRad(); syncStr(); syncFalloff(); @@ -1385,13 +1346,216 @@ void MainWindow::initToolBar() QAction* vertexPaintAction = ui->objectsToolbar->addWidget(vertexPaintButton); vertexPaintAction->setObjectName("modeMaterialPaintBrushAction"); + // Wand (smart-select) toolbar button. Lives directly under the + // paint brush so the user can swap between "paint" and "magic- + // wand select" without leaving the toolbar. Click toggles between + // the Paint tool and the Wand tool — paint stays the default + // when the button is unchecked. + // + // Icon is drawn in two QIcon modes: green (Normal) and dimmed + // grey (Disabled) — matching the green / grey rhythm of the + // surrounding topology buttons. + auto paintWand = [](QPixmap& pm, const QColor& color) { + pm.fill(Qt::transparent); + QPainter p(&pm); + p.setRenderHint(QPainter::Antialiasing, true); + p.setPen(QPen(color, 2.0, Qt::SolidLine, Qt::RoundCap)); + p.drawLine(QPointF(3.0, 15.0), QPointF(13.5, 4.5)); + p.setBrush(color); + p.setPen(Qt::NoPen); + p.drawEllipse(QPointF(14.4, 3.6), 2.1, 2.1); // tip ball + p.drawEllipse(QPointF(3.0, 15.0), 1.2, 1.2); // handle nub + }; + auto makeWandIcon = [paintWand]() -> QIcon { + constexpr int kSize = 18; + QPixmap onPm(kSize, kSize); + paintWand(onPm, QColor(0x7B, 0xBD, 0x2A)); // green, matches topology buttons + QPixmap offPm(kSize, kSize); + paintWand(offPm, QColor(0xB8, 0xB8, 0xB8)); // grey for disabled + QIcon icon; + icon.addPixmap(onPm, QIcon::Normal, QIcon::Off); + icon.addPixmap(onPm, QIcon::Active, QIcon::Off); + icon.addPixmap(onPm, QIcon::Selected, QIcon::Off); + icon.addPixmap(onPm, QIcon::Normal, QIcon::On); + icon.addPixmap(offPm, QIcon::Disabled, QIcon::Off); + icon.addPixmap(offPm, QIcon::Disabled, QIcon::On); + return icon; + }; + + auto* wandButton = new QToolButton(ui->objectsToolbar); + wandButton->setCheckable(true); + wandButton->setIcon(makeWandIcon()); + wandButton->setIconSize(QSize(18, 18)); + wandButton->setToolTip(tr("Smart-select (Wand)\n" + "Click a region to select pixels of similar color.\n" + "Drag horizontally while clicking to widen / narrow.\n" + "Click outside the mesh to clear the selection.\n" + "Enabled only in Texture paint mode.")); + wandButton->setFont(topoFont); + wandButton->setStyleSheet(topoBtnStyle); + + auto syncWandChecked = [wandButton]() { + const bool wandActive = + TexturePaintController::instance()->brushTool() + == static_cast(TexturePaintController::ToolSmartSelect); + QSignalBlocker b(wandButton); + wandButton->setChecked(wandActive); + }; + // Wand only does something when texture paint is on AND the target + // is Texture (it operates on the paint buffer, not vertex colors). + // The disabled icon comes from QIcon's Disabled mode pixmap added + // in makeWandIcon, so the grey state matches the other toolbar + // buttons' disabled treatment. + auto syncWandEnabled = [wandButton]() { + auto* tpc = TexturePaintController::instance(); + const bool canWand = + tpc->texturePaintEnabled() + && tpc->paintTarget() == static_cast(TexturePaintController::TargetTexture); + wandButton->setEnabled(canWand); + if (!canWand && wandButton->isChecked()) { + // Auto-uncheck so the next paint-mode re-entry starts clean. + QSignalBlocker b(wandButton); + wandButton->setChecked(false); + tpc->setBrushTool(static_cast(TexturePaintController::ToolPaint)); + } + }; + syncWandChecked(); + syncWandEnabled(); + + connect(wandButton, &QToolButton::toggled, this, [](bool on) { + auto* tpc = TexturePaintController::instance(); + const int newTool = on + ? static_cast(TexturePaintController::ToolSmartSelect) + : static_cast(TexturePaintController::ToolPaint); + tpc->setBrushTool(newTool); + // Make sure paint mode is on while the user is reaching for + // the wand — otherwise the toolbar toggle does nothing visible. + if (on) + tpc->setTexturePaintEnabled(true); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Toolbar: Wand %1").arg(on ? "on" : "off")); + }); + connect(TexturePaintController::instance(), + &TexturePaintController::brushToolChanged, this, syncWandChecked); + connect(TexturePaintController::instance(), + &TexturePaintController::texturePaintChanged, this, syncWandEnabled); + connect(TexturePaintController::instance(), + &TexturePaintController::paintTargetChanged, this, syncWandEnabled); + + QAction* wandAction = ui->objectsToolbar->addWidget(wandButton); + wandAction->setObjectName("modeMaterialWandAction"); + + // FG/BG color swatch widget — Photoshop / GIMP style. Two overlapping + // rectangles showing the foreground and background colors. The user + // can click either to open a color picker, click the small swap + // arrow to flip them, or use the tiny "reset" indicator to put back + // FG=black, BG=white. Toolbar-resident so it's always one click away, + // no popup needed. + auto* paintColors = new QWidget(ui->objectsToolbar); + paintColors->setFixedSize(34, 28); + paintColors->setToolTip(tr("Foreground / Background colors\n" + "Click either swatch to change. The 'Erase' brush\n" + "paints with the BG color.")); + auto repaintSwatch = [paintColors]() { paintColors->update(); }; + paintColors->installEventFilter(this); + // Custom paint via overridden paintEvent on a private subclass would + // be cleaner, but a single inline filter avoids growing the class. + // We instead set a stylesheet-free child for each swatch. + { + auto* bg = new QPushButton(paintColors); + bg->setGeometry(11, 7, 18, 17); + bg->setObjectName("paintBgSwatch"); + bg->setFlat(true); + bg->setFocusPolicy(Qt::NoFocus); + bg->setCursor(Qt::PointingHandCursor); + bg->setToolTip(tr("Background color")); + auto* fg = new QPushButton(paintColors); + fg->setGeometry(3, 0, 18, 17); + fg->setObjectName("paintFgSwatch"); + fg->setFlat(true); + fg->setFocusPolicy(Qt::NoFocus); + fg->setCursor(Qt::PointingHandCursor); + fg->setToolTip(tr("Foreground color")); + // Tiny FG/BG swap arrow in the corner. A unicode glyph keeps us + // off icon-plugin dependencies. + auto* swap = new QPushButton(paintColors); + swap->setGeometry(20, 0, 14, 12); + swap->setObjectName("paintSwap"); + swap->setFlat(true); + swap->setFocusPolicy(Qt::NoFocus); + swap->setText(QStringLiteral("⇄")); + swap->setToolTip(tr("Swap foreground/background colors")); + swap->setStyleSheet(QStringLiteral( + "QPushButton { color: #aaa; background: transparent; border: none; font-size: 9px; padding: 0; }" + "QPushButton:hover { color: #fff; }")); + // Default-reset glyph: tiny black-over-white squares in the + // opposite corner. Click puts FG=black, BG=white. + auto* reset = new QPushButton(paintColors); + reset->setGeometry(0, 18, 12, 10); + reset->setObjectName("paintReset"); + reset->setFlat(true); + reset->setFocusPolicy(Qt::NoFocus); + reset->setText(QStringLiteral("◰")); + reset->setToolTip(tr("Reset to default foreground/background")); + reset->setStyleSheet(QStringLiteral( + "QPushButton { color: #aaa; background: transparent; border: none; font-size: 10px; padding: 0; }" + "QPushButton:hover { color: #fff; }")); + + auto syncSwatches = [fg, bg]() { + auto* em = EditModeController::instance(); + const QColor fgC = em->vertexPaintColor(); + const QColor bgC = em->vertexPaintBackgroundColor(); + fg->setStyleSheet(QStringLiteral( + "QPushButton { background-color: %1; border: 1px solid #444; border-radius: 2px; }") + .arg(fgC.name(QColor::HexRgb))); + bg->setStyleSheet(QStringLiteral( + "QPushButton { background-color: %1; border: 1px solid #444; border-radius: 2px; }") + .arg(bgC.name(QColor::HexRgb))); + }; + syncSwatches(); + connect(EditModeController::instance(), &EditModeController::vertexPaintChanged, + this, syncSwatches); + + connect(fg, &QPushButton::clicked, this, [this, syncSwatches]() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: FG color picker opened"); + auto* em = EditModeController::instance(); + QColor c = QColorDialog::getColor(em->vertexPaintColor(), this, + tr("Foreground color"), + QColorDialog::ShowAlphaChannel); + if (c.isValid()) + em->setVertexPaintColor(c); + syncSwatches(); + }); + connect(bg, &QPushButton::clicked, this, [this, syncSwatches]() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: BG color picker opened"); + auto* em = EditModeController::instance(); + QColor c = QColorDialog::getColor(em->vertexPaintBackgroundColor(), this, + tr("Background color"), + QColorDialog::ShowAlphaChannel); + if (c.isValid()) + em->setVertexPaintBackgroundColor(c); + syncSwatches(); + }); + connect(swap, &QPushButton::clicked, this, [syncSwatches]() { + EditModeController::instance()->swapPaintColors(); + syncSwatches(); + }); + connect(reset, &QPushButton::clicked, this, [syncSwatches]() { + EditModeController::instance()->resetPaintColors(); + syncSwatches(); + }); + } + QAction* paintColorsAction = ui->objectsToolbar->addWidget(paintColors); + paintColorsAction->setObjectName("modeMaterialPaintColorsAction"); + // 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]() { + auto refreshPaintBrushVisibility = [vertexPaintButton, vertexPaintAction, + paintColorsAction, wandButton, wandAction]() { const auto mode = EditorModeController::instance()->currentMode(); const bool material = mode == EditorModeController::MaterialMode; // Paint brush lives in Material Mode only. The user chooses @@ -1399,6 +1563,11 @@ void MainWindow::initToolBar() // picker; both run through TexturePaintController. vertexPaintAction->setVisible(material); vertexPaintButton->setEnabled(material); + paintColorsAction->setVisible(material); + wandAction->setVisible(material); + // wandButton enabled state is driven by syncWandEnabled — + // paint-on + target=Texture. Hide here when out of Material + // Mode but don't overrule enabled. // 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 @@ -1410,6 +1579,12 @@ void MainWindow::initToolBar() QStringLiteral("Mode switch: paint state reset")); QSignalBlocker b(vertexPaintButton); vertexPaintButton->setChecked(false); + // Reset the tool to Paint on every mode entry so the wand + // toolbar button starts unchecked too. + TexturePaintController::instance()->setBrushTool( + static_cast(TexturePaintController::ToolPaint)); + QSignalBlocker bw(wandButton); + wandButton->setChecked(false); }; refreshPaintBrushVisibility(); connect(EditorModeController::instance(), &EditorModeController::modeChanged, diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 26b6de267..081c34bbc 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -28,6 +28,7 @@ ../qml/SceneTreeNode.qml ../qml/ProfileGraph.qml ../qml/ThemedComboBox.qml + ../qml/TextureEditorWindow.qml ../qml/ModeBar.qml From dcce6241d43274c33fd5065d38e0ec5490bd252f Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 15 May 2026 14:47:30 -0400 Subject: [PATCH 2/2] review(paint): CI link + 11 reviewer findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: - tests/CMakeLists.txt: add PaintSelectionMask.cpp to the QML test target sources. Linker was emitting "undefined reference to PaintSelectionMask::resize/smartSelect" because the file shipped in src/CMakeLists.txt only, and the CI build-wrapper step links the QML-test targets too. CodeRabbit + Codex findings: 1) TexturePaintController::wouldStrokeHit() lazily builds the editable paint mesh before bailing out. Was returning false on the very first click against an uninitialised session (m_paintMesh null), which silently swallowed the first wand click on the model. 2) TransformOperator wand-miss handler now only swallows the click when there's an existing selection (Photoshop clear- on-empty-click). With no mask the click falls through to normal selection so the user can still pick a different mesh without first toggling wand off. 3) ToolSmartSelect press no longer overwrites the user's configured tolerance with the 15% hardcoded default. The default lives in the member initialiser; per-stroke logic just snapshots the current value as the wand-drag baseline. 4) updateEmbeddedTextureCache() extracted as a helper and called from: endStroke, applyPixelSnapshot (undo / redo), fillMaskWithFG, fillMaskWithBG, deleteMaskPixels. Without this, FBX export and "Save to Original" saw stale pixels after any mask action or undo (the buffer was up-to-date but the cache wasn't). 5) PaintSelectionMask::data() non-const accessor removed so external code can't mutate raw bits and desync the cached selectedCount / bbox. 6) mainwindow.cpp paintColorsAction now stays visible in Edit Mode too — vertex paint runs from Edit Mode and the brush popup no longer holds a color picker, so without this the user has no toolbar access to FG / BG / swap. 7) Toolbar swap and reset buttons now emit Sentry breadcrumbs to match the rest of the paint UI's telemetry trail. 8) EditModeController.h resetPaintColors() doc comment now correctly says "FG = Fern green, BG = black" — was stale "FG = black, BG = white" from before the defaults changed. 9) TextureEditorWindow.qml uvAt() maps against canvasImg.paintedWidth/paintedHeight (the inner letter- boxed rect) instead of the container size. Non-square textures used to map clicks to incorrect UVs because the PreserveAspectFit margins were treated as paintable area. 10) TextureEditorWindow.qml onSessionChanged clears previewUri / maskOverlayUri / hover state when the session ends so the detached window doesn't keep showing the last-session texture after mesh removal. 11) Mask action buttons (Fill FG / BG / Delete / Invert / All / None) and the editor window's bottom action bar are now laid out with Flow instead of Row so they wrap to a second line on narrow inspectors / windows instead of being clipped. Material list filter: also filtered out QMEPaintMaskOverlay_*, QMEPaint_*, and TexturePaint/* prefixes from the Inspector's submesh material dropdown (SceneTreeModel::availableMaterials) and the Material Editor's material list (MaterialEditorQML::getMaterialList) so the runtime paint- pipeline materials don't clutter user-facing lists. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 3 +- qml/TextureEditorWindow.qml | 49 ++++++++++++++++------ src/EditModeController.h | 5 ++- src/MaterialEditorQML.cpp | 19 +++++++-- src/PaintSelectionMask.h | 7 +++- src/SceneTreeModel.cpp | 15 +++++-- src/TexturePaintController.cpp | 77 ++++++++++++++++++++-------------- src/TexturePaintController.h | 19 +++++++-- src/TransformOperator.cpp | 10 ++++- src/mainwindow.cpp | 9 +++- tests/CMakeLists.txt | 1 + 11 files changed, 155 insertions(+), 59 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 0d489e9e7..1d47be6ee 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1287,7 +1287,8 @@ Rectangle { } // Action buttons: act on the current mask. - Row { + Flow { + width: parent.width spacing: 4 Repeater { model: [ diff --git a/qml/TextureEditorWindow.qml b/qml/TextureEditorWindow.qml index 686bb896e..1bdc3df99 100644 --- a/qml/TextureEditorWindow.qml +++ b/qml/TextureEditorWindow.qml @@ -48,6 +48,15 @@ Window { } function onSessionChanged() { editorWindow.hasSession = TexturePaintController.hasActiveSession + // When the session is torn down (mesh removed, mode change, + // target switch), wipe the stale preview/mask so the + // detached window doesn't show last-session's contents. + if (!editorWindow.hasSession) { + editorWindow.previewUri = "" + editorWindow.maskOverlayUri = "" + editorWindow.hoverU = -1 + editorWindow.hoverV = -1 + } } function onHoveredUVChanged(u, v) { editorWindow.hoverU = u @@ -182,35 +191,48 @@ Window { smooth: false cache: false } + // Painted-image rectangle. PreserveAspectFit letterboxes + // non-square textures inside the square canvas, so we map + // mouse coords against this inner rect instead of the + // Image's container size — otherwise clicks/drags map to + // wrong UVs on non-square textures. + Item { + id: paintedRect + x: canvasImg.x + (canvasImg.width - canvasImg.paintedWidth) / 2 + y: canvasImg.y + (canvasImg.height - canvasImg.paintedHeight) / 2 + width: canvasImg.paintedWidth + height: canvasImg.paintedHeight + } + // Hover crosshair. Rectangle { visible: editorWindow.hoverU >= 0 && editorWindow.hoverV >= 0 color: "#ff3030" - width: 1; height: canvasImg.height - x: canvasImg.x + Math.round(editorWindow.hoverU * canvasImg.width) - y: canvasImg.y + width: 1; height: paintedRect.height + x: paintedRect.x + Math.round(editorWindow.hoverU * paintedRect.width) + y: paintedRect.y } Rectangle { visible: editorWindow.hoverU >= 0 && editorWindow.hoverV >= 0 color: "#ff3030" - width: canvasImg.width; height: 1 - x: canvasImg.x - y: canvasImg.y + Math.round(editorWindow.hoverV * canvasImg.height) + width: paintedRect.width; height: 1 + x: paintedRect.x + y: paintedRect.y + Math.round(editorWindow.hoverV * paintedRect.height) } MouseArea { id: canvasMa - anchors.fill: canvasImg + anchors.fill: paintedRect hoverEnabled: true cursorShape: Qt.CrossCursor preventStealing: true property bool dragging: false function uvAt(mx, my) { - if (canvasImg.width <= 0 || canvasImg.height <= 0) + if (paintedRect.width <= 0 || paintedRect.height <= 0) return null - const u = mx / canvasImg.width - const v = my / canvasImg.height + const u = mx / paintedRect.width + const v = my / paintedRect.height if (u < 0 || u > 1 || v < 0 || v > 1) return null return { u: u, v: v } } @@ -249,8 +271,10 @@ Window { // Bottom action bar: save / load / bake / mask actions / "open in // external viewer". Keeps the most-used non-stroke actions one click - // away without polluting the main inspector. - Row { + // away without polluting the main inspector. Flow so buttons wrap + // to a second line on narrow window widths instead of getting + // clipped (minimumWidth is 480, the row had 9 buttons). + Flow { id: bottomBar spacing: 6 anchors { @@ -259,7 +283,6 @@ Window { bottom: parent.bottom margins: 8 } - height: 30 Button { text: "Save…" diff --git a/src/EditModeController.h b/src/EditModeController.h index b5022c47b..3a0138ae3 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -232,7 +232,10 @@ class EditModeController : public QObject /// Swap foreground and background colors. Standard "X" shortcut in /// image editors. Q_INVOKABLE void swapPaintColors(); - /// Reset to canonical defaults: FG=black, BG=white. + /// Reset to canonical defaults: FG = Fern green (#71BC78), + /// BG = black. Mirrors the member-initialiser defaults near + /// the bottom of the class so the documentation cannot drift + /// from the actual reset behaviour. Q_INVOKABLE void resetPaintColors(); double vertexPaintRadius() const { return m_vertexPaintRadius; } void setVertexPaintRadius(double r); diff --git a/src/MaterialEditorQML.cpp b/src/MaterialEditorQML.cpp index 96cfa8b54..b3aec6b88 100644 --- a/src/MaterialEditorQML.cpp +++ b/src/MaterialEditorQML.cpp @@ -3880,13 +3880,26 @@ QStringList MaterialEditorQML::getMaterialList() const } try { - Ogre::ResourceManager::ResourceMapIterator materialIterator = + Ogre::ResourceManager::ResourceMapIterator materialIterator = Ogre::MaterialManager::getSingleton().getResourceIterator(); - + while (materialIterator.hasMoreElements()) { Ogre::MaterialPtr material = Ogre::static_pointer_cast( materialIterator.peekNextValue()); - materialList.append(QString::fromStdString(material->getName())); + const QString name = QString::fromStdString(material->getName()); + // Hide internal paint-pipeline materials from the user + // facing list. These are created at runtime by + // TexturePaintController (mask overlay material, hover- + // ring material) and EditModeController's session, so the + // user never authored them and shouldn't be able to + // accidentally select / edit / delete them. + if (name.startsWith(QLatin1String("QMEPaintMaskOverlay_")) + || name.startsWith(QLatin1String("QMEPaint_")) + || name.startsWith(QLatin1String("TexturePaint/"))) { + materialIterator.moveNext(); + continue; + } + materialList.append(name); materialIterator.moveNext(); } } catch (const std::exception& e) { diff --git a/src/PaintSelectionMask.h b/src/PaintSelectionMask.h index 4e82cbe17..fc8df53fb 100644 --- a/src/PaintSelectionMask.h +++ b/src/PaintSelectionMask.h @@ -54,9 +54,12 @@ class PaintSelectionMask /// Tight bbox of currently-set pixels. Empty when isEmpty(). const BBox& bbox() const { return m_bbox; } /// Raw mask byte buffer (row-major, top-left origin, 0 = unselected, - /// 1 = selected). + /// 1 = selected). Read-only by design — selectedCount() and bbox() + /// are cached and would become inconsistent with the underlying + /// bits if external code mutated this array directly. Use + /// setSelected / clear / selectAll / invert / smartSelect to + /// mutate. const std::vector& data() const { return m_data; } - std::vector& data() { return m_data; } /// True if the pixel at (x, y) is in the selection. Out-of-bounds /// returns false. diff --git a/src/SceneTreeModel.cpp b/src/SceneTreeModel.cpp index a29616583..f0c634f86 100644 --- a/src/SceneTreeModel.cpp +++ b/src/SceneTreeModel.cpp @@ -338,9 +338,18 @@ QStringList SceneTreeModel::availableMaterials() const { auto res = it.getNext(); QString name = QString::fromStdString(res->getName()); - // Skip internal materials - if (!name.startsWith("Ogre/") && !name.startsWith("BaseWhite") && name != "GUI_Material") - names.append(name); + // Skip Ogre's built-in materials. + if (name.startsWith("Ogre/") || name.startsWith("BaseWhite") || name == "GUI_Material") + continue; + // Skip runtime paint-pipeline materials. These are created by + // TexturePaintController (paint session, mask overlay, hover + // ring) and aren't user-authored — they'd just clutter the + // submesh material dropdown. + if (name.startsWith("QMEPaintMaskOverlay_") + || name.startsWith("QMEPaint_") + || name.startsWith("TexturePaint/")) + continue; + names.append(name); } names.sort(Qt::CaseInsensitive); return names; diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index f015e9836..4254811f8 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1322,20 +1322,15 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) // the screen / UV delta to compute the scrub; this case // handles the press-time seed only. // - // Reset to the canonical 15% tolerance each press so the - // user starts from a known baseline. Otherwise the drag from - // the previous stroke would carry over and a fresh click on - // a new region would silently use last-stroke's wide value. + // Don't clobber the user-configured tolerance — the default + // lives in the member initializer in the header. Just + // remember it as the wand-stroke baseline so drag deltas + // are added relative to where the user started. if (!m_strokeJustBegan) return false; m_strokeJustBegan = false; m_wandStrokeActive = true; m_wandSeedUV = uv; - constexpr double kDefaultWandTolerance = 0.15; - m_wandStartTolerance = kDefaultWandTolerance; - if (std::abs(m_smartSelectTolerance - kDefaultWandTolerance) > 1e-4) { - m_smartSelectTolerance = kDefaultWandTolerance; - emit smartSelectChanged(); - } + m_wandStartTolerance = m_smartSelectTolerance; smartSelectAtUV(static_cast(uv.x), static_cast(uv.y), /*mode=*/0); return false; // smart-select doesn't dirty pixels } @@ -1404,24 +1399,27 @@ void TexturePaintController::endStroke() // re-bind), so an explicit Save / Export still picks up the // painted texture. To persist to disk the user must invoke "Save // to Original" or "Save…" / export. See bakeToOriginalFile(). - if (m_target == TargetTexture && !m_originalTextureName.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' (no disk write)") - .arg(bytes.size()).arg(m_originalTextureName)); - } - } catch (...) {} - } + updateEmbeddedTextureCache(); +} + +void TexturePaintController::updateEmbeddedTextureCache() +{ + if (m_target != TargetTexture) return; + if (m_originalTextureName.isEmpty()) return; + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) return; + 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); + } + } catch (...) {} } std::vector TexturePaintController::snapshotPixels() const @@ -1627,6 +1625,10 @@ void TexturePaintController::applyPixelSnapshot(const std::vector& pixe std::memcpy(m_buffer.data().data(), pixels.data(), pixels.size()); m_buffer.markDirty(0, 0, m_buffer.width(), m_buffer.height()); flushDirtyToOgre(); + // Undo / redo replaces the buffer entirely — the cache that + // backs exports needs to follow, otherwise FBX export sees the + // pixels from BEFORE the last mutation. + updateEmbeddedTextureCache(); } void TexturePaintController::closeSession() @@ -2086,9 +2088,19 @@ bool TexturePaintController::hitTestLocalPoint(OgreWidget* widget, const QPoint& } bool TexturePaintController::wouldStrokeHit(OgreWidget* widget, - const QPoint& screenPos) const -{ - if (!m_paintMesh || !m_paintMeshEntity || !widget) return false; + const QPoint& screenPos) +{ + if (!widget) return false; + // Lazily build the paint mesh — beginStroke would do it anyway, + // and without this the very first click on the model is treated + // as a miss (m_paintMesh is null until the first stroke). That + // bug masked the click-outside-clears flow because the seed + // press never reached beginStroke. + if (!m_paintMesh || !m_paintMeshEntity) { + if (auto* e = activeEntity()) + ensureEditableMesh(e); + if (!m_paintMesh || !m_paintMeshEntity) return false; + } Ogre::Vector2 uv; return hitTestUV(screenPos, widget, uv); } @@ -2329,6 +2341,7 @@ int TexturePaintController::fillMaskWithFG() QStringLiteral("Smart select: filled %1 px with FG %2") .arg(affected).arg(c.name(QColor::HexRgb))); flushDirtyToOgre(); + updateEmbeddedTextureCache(); return affected; } @@ -2354,6 +2367,7 @@ int TexturePaintController::fillMaskWithBG() QStringLiteral("Smart select: filled %1 px with BG %2") .arg(affected).arg(c.name(QColor::HexArgb))); flushDirtyToOgre(); + updateEmbeddedTextureCache(); return affected; } @@ -2373,6 +2387,7 @@ int TexturePaintController::deleteMaskPixels() SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Smart select: deleted %1 px").arg(affected)); flushDirtyToOgre(); + updateEmbeddedTextureCache(); return affected; } diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 6dafea65d..afd8a14e5 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -320,9 +320,13 @@ class TexturePaintController : public QObject /// Quick "would beginStroke hit the mesh at screenPos?" probe. /// Public wrapper around hitTestUV used by TransformOperator to /// decide whether a click landed on the mesh or empty space (for - /// "click outside clears wand selection" behaviour). Returns false - /// on a miss or when there's no paint session. - bool wouldStrokeHit(OgreWidget* widget, const QPoint& screenPos) const; + /// "click outside clears wand selection" behaviour). Lazily + /// builds the EditableMesh / paint session if the user is making + /// the first click on the model (otherwise the first hit would + /// silently miss because m_paintMesh is still null at the time + /// the TransformOperator press handler queries us). Not const + /// for that reason. + bool wouldStrokeHit(OgreWidget* widget, const QPoint& screenPos); /// Read-only access for tests. const TexturePaintBuffer& buffer() const { return m_buffer; } @@ -522,6 +526,15 @@ class TexturePaintController : public QObject void scheduleMaskOverlayRefresh(); void refreshMaskOverlay(); + /// Re-encode the current buffer as PNG and push it into + /// EmbeddedTextureCache so the next FBX export (or RTSS rebind) + /// sees the painted pixels. Must be called from every code path + /// that mutates `m_buffer` — stroke end, mask actions, undo / + /// redo — otherwise downstream consumers observe stale bytes + /// from before the mutation. Cheap PNG encode (~1024² is a few + /// ms) so callers don't need to debounce. + void updateEmbeddedTextureCache(); + // Detached texture editor window. Owned heap-allocated; instantiated // lazily when the user clicks "Open Editor Window" and torn down on // window close. Held as a generic QObject* so the header doesn't diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index 59afb39ad..9f3c86281 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -1041,12 +1041,20 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) // hit-testing), masking the click-outside case. if (texPaint->brushTool() == TexturePaintController::ToolSmartSelect && !texPaint->wouldStrokeHit(m_pActiveWidget, e->pos())) { + // Wand miss. If there's a mask, treat it as + // "click empty space to clear" — Photoshop / + // GIMP convention. Otherwise let the click + // fall through to the normal selection / box- + // pick path so the user can still select a + // different mesh without first switching off + // the wand. if (texPaint->hasSelectionMask()) { texPaint->clearSelectionMask(); SentryReporter::addBreadcrumb("ui.action", "Wand: cleared selection (click outside mesh)"); + return; } - return; + // No mask — fall through to normal selection. } if (texPaint->beginStroke(m_pActiveWidget, e->pos())) { mTexturePaintDragActive = true; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 405ba52b0..35c297d59 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1537,10 +1537,12 @@ void MainWindow::initToolBar() syncSwatches(); }); connect(swap, &QPushButton::clicked, this, [syncSwatches]() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: swap FG/BG colors"); EditModeController::instance()->swapPaintColors(); syncSwatches(); }); connect(reset, &QPushButton::clicked, this, [syncSwatches]() { + SentryReporter::addBreadcrumb("ui.action", "Toolbar: reset FG/BG colors"); EditModeController::instance()->resetPaintColors(); syncSwatches(); }); @@ -1563,7 +1565,12 @@ void MainWindow::initToolBar() // picker; both run through TexturePaintController. vertexPaintAction->setVisible(material); vertexPaintButton->setEnabled(material); - paintColorsAction->setVisible(material); + // FG/BG swatch stays available in Edit Mode too — vertex paint + // runs from Edit Mode and the brush popup no longer holds a + // color picker, so without this the user has no toolbar + // access to the foreground color while painting vertex colors. + const bool edit = mode == EditorModeController::EditMode; + paintColorsAction->setVisible(material || edit); wandAction->setVisible(material); // wandButton enabled state is driven by syncWandEnabled — // paint-on + target=Texture. Hide here when out of Material diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 336b6b554..40009cc6e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,7 @@ 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/PaintSelectionMask.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintBuffer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp