From a89747b0bcff94504f9b3a56777977f8f878020b Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 20 Jul 2026 13:18:51 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat(paint):=20Slice=20A=20=E2=80=94=20grad?= =?UTF-8?q?ient=20ramp=20brushes=20(#544)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add BrushEngine colour sources (solid | gradient) with linear / radial / angular modes, six bundled CC0 ramps, FG/BG quick mode, custom ramp editor with AppData save/load, and paint.brush.gradient breadcrumbs. Co-authored-by: Cursor --- qml/GradientRampEditor.qml | 227 +++++++++++++++ qml/PropertiesPanel.qml | 236 ++++++++++++++++ src/AppSettingsKeys.h | 21 ++ src/BrushEngine.cpp | 85 ++++++ src/BrushEngine.h | 66 +++++ src/CMakeLists.txt | 4 + src/GradientRamp.cpp | 310 +++++++++++++++++++++ src/GradientRamp.h | 82 ++++++ src/GradientRamp_test.cpp | 209 ++++++++++++++ src/TexturePaintBuffer.cpp | 14 + src/TexturePaintBuffer.h | 17 ++ src/TexturePaintController.cpp | 486 ++++++++++++++++++++++++++++++++- src/TexturePaintController.h | 94 +++++++ src/qml_resources.qrc | 1 + tests/CMakeLists.txt | 2 + 15 files changed, 1852 insertions(+), 2 deletions(-) create mode 100644 qml/GradientRampEditor.qml create mode 100644 src/BrushEngine.cpp create mode 100644 src/BrushEngine.h create mode 100644 src/GradientRamp.cpp create mode 100644 src/GradientRamp.h create mode 100644 src/GradientRamp_test.cpp diff --git a/qml/GradientRampEditor.qml b/qml/GradientRampEditor.qml new file mode 100644 index 000000000..29134d5e3 --- /dev/null +++ b/qml/GradientRampEditor.qml @@ -0,0 +1,227 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls +import PropertiesPanel 1.0 + +/** + * Paint v2 Slice A (#544) — custom gradient ramp editor. + * + * Gradient strip with draggable colour stops. Stops can be added + * (double-click the strip), deleted (Delete / Backspace), repositioned + * (drag), and recoloured (click a stop → colour dialog via the + * "Recolour" button). Save writes a named JSON ramp into + * `/paint/ramps/`. + */ +Window { + id: rampWin + title: "Gradient Ramp Editor" + width: 480 + height: 280 + minimumWidth: 360 + minimumHeight: 220 + color: "#1e1e1e" + flags: Qt.Window | Qt.WindowCloseButtonHint + + property var stops: TexturePaintController.activeRampStops + property bool stepped: TexturePaintController.gradientStepped + property int selectedStop: 0 + property string rampName: TexturePaintController.useFgBgRamp + ? "Custom" + : TexturePaintController.activeRampName + + Connections { + target: TexturePaintController + function onGradientChanged() { + rampWin.stops = TexturePaintController.activeRampStops + rampWin.stepped = TexturePaintController.gradientStepped + if (!TexturePaintController.useFgBgRamp) + rampWin.rampName = TexturePaintController.activeRampName + } + } + + function pushStops() { + TexturePaintController.setActiveRampStops(stops, stepped) + } + + function stopColor(i) { + if (i < 0 || i >= stops.length) return "#888888" + const s = stops[i] + const r = Math.round((s.r || 0) * 255) + const g = Math.round((s.g || 0) * 255) + const b = Math.round((s.b || 0) * 255) + return "#" + r.toString(16).padStart(2, "0") + + g.toString(16).padStart(2, "0") + + b.toString(16).padStart(2, "0") + } + + Column { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + Text { + text: "Drag stops along the ramp. Double-click the strip to add a stop." + color: "#aaaaaa" + font.pixelSize: 11 + width: parent.width + wrapMode: Text.Wrap + } + + Item { + id: stripArea + width: parent.width + height: 48 + + Image { + id: stripImg + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 28 + source: TexturePaintController.rampPreviewDataUri + fillMode: Image.Stretch + asynchronous: false + cache: false + + MouseArea { + anchors.fill: parent + onDoubleClicked: function(mouse) { + const t = Math.max(0, Math.min(1, mouse.x / Math.max(1, width))) + // Sample current ramp colour at t for the new stop. + let r = 0.5, g = 0.5, b = 0.5, a = 1.0 + if (stops.length >= 2) { + // Nearest-neighbour seed from neighbouring stops. + let best = stops[0] + let bestD = Math.abs(best.t - t) + for (let i = 1; i < stops.length; ++i) { + const d = Math.abs(stops[i].t - t) + if (d < bestD) { best = stops[i]; bestD = d } + } + r = best.r; g = best.g; b = best.b; a = best.a + } + const next = stops.slice() + next.push({ t: t, r: r, g: g, b: b, a: a }) + next.sort(function(a, b) { return a.t - b.t }) + stops = next + selectedStop = next.findIndex(function(s) { + return Math.abs(s.t - t) < 1e-4 + }) + pushStops() + } + } + } + + Repeater { + model: stops + Rectangle { + width: 12; height: 18; radius: 2 + y: 30 + x: modelData.t * stripArea.width - width / 2 + color: stopColor(index) + border.color: index === selectedStop ? "#ffffff" : "#666666" + border.width: index === selectedStop ? 2 : 1 + + MouseArea { + anchors.fill: parent + drag.target: parent + drag.axis: Drag.XAxis + drag.minimumX: -width / 2 + drag.maximumX: stripArea.width - width / 2 + cursorShape: Qt.SizeHorCursor + onPressed: selectedStop = index + onReleased: { + const next = stops.slice() + const t = Math.max(0, Math.min(1, + (parent.x + parent.width / 2) / Math.max(1, stripArea.width))) + next[index] = { + t: t, r: modelData.r, g: modelData.g, + b: modelData.b, a: modelData.a + } + next.sort(function(a, b) { return a.t - b.t }) + stops = next + pushStops() + } + } + } + } + } + + Row { + spacing: 8 + Text { + text: "Name:" + color: "#dddddd" + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + TextField { + id: nameField + width: 160 + text: rampWin.rampName + color: "#eeeeee" + background: Rectangle { + color: "#2a2a2a" + border.color: "#555555" + radius: 3 + } + onEditingFinished: rampWin.rampName = text + } + CheckBox { + text: "Stepped" + checked: rampWin.stepped + onToggled: { + rampWin.stepped = checked + pushStops() + } + } + } + + Row { + spacing: 8 + Button { + text: "Recolour Stop" + enabled: selectedStop >= 0 && selectedStop < stops.length + onClicked: { + // Use FG colour as a quick recolour source — the + // toolbar FG picker is the project's colour dialog. + const fg = TexturePaintController.texturePaintColor + const next = stops.slice() + const cur = next[selectedStop] + next[selectedStop] = { + t: cur.t, + r: fg.r, + g: fg.g, + b: fg.b, + a: fg.a + } + stops = next + pushStops() + } + } + Button { + text: "Delete Stop" + enabled: stops.length > 2 && selectedStop >= 0 && selectedStop < stops.length + onClicked: { + const next = stops.slice() + next.splice(selectedStop, 1) + stops = next + selectedStop = Math.min(selectedStop, next.length - 1) + pushStops() + } + } + Button { + text: "Save Ramp" + onClicked: { + const name = nameField.text.trim().length > 0 + ? nameField.text.trim() : "Custom" + if (TexturePaintController.saveCustomRamp(name, stops, stepped)) + rampWin.rampName = name + } + } + Button { + text: "Close" + onClicked: rampWin.close() + } + } + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index ea8cfc6af..d57538b30 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4068,6 +4068,15 @@ Rectangle { // Live hover position in UV space, fed by hoveredUVChanged. property real hoverU: -1 property real hoverV: -1 + // Paint v2 Slice A — gradient ramp brushes (#544). + property int colorSource: TexturePaintController.colorSource + property int gradientMode: TexturePaintController.gradientMode + property string activeRampName: TexturePaintController.activeRampName + property bool useFgBgRamp: TexturePaintController.useFgBgRamp + property bool gradientStepped: TexturePaintController.gradientStepped + property real rampJitter: TexturePaintController.rampJitter + property var rampNames: TexturePaintController.rampNames + property string rampPreviewUri: TexturePaintController.rampPreviewDataUri Connections { target: TexturePaintController @@ -4091,6 +4100,16 @@ Rectangle { function onPaintTargetChanged() { texPaintCol.paintTarget = TexturePaintController.paintTarget } + function onGradientChanged() { + texPaintCol.colorSource = TexturePaintController.colorSource + texPaintCol.gradientMode = TexturePaintController.gradientMode + texPaintCol.activeRampName = TexturePaintController.activeRampName + texPaintCol.useFgBgRamp = TexturePaintController.useFgBgRamp + texPaintCol.gradientStepped = TexturePaintController.gradientStepped + texPaintCol.rampJitter = TexturePaintController.rampJitter + texPaintCol.rampNames = TexturePaintController.rampNames + texPaintCol.rampPreviewUri = TexturePaintController.rampPreviewDataUri + } function onHoveredUVChanged(u, v) { texPaintCol.hoverU = u texPaintCol.hoverV = v @@ -4167,6 +4186,223 @@ Rectangle { // \u2014 so the user picks the tool with the same buttons // regardless of which target they're painting. + // Paint v2 Slice A (#544) — gradient ramp brushes. + Column { + spacing: 6 + width: parent.width - 16 + visible: texPaintCol.paintOn + + Text { + text: "Brush Color" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + + Row { + spacing: 4 + Repeater { + model: [ + { src: 0, label: "Solid" }, + { src: 1, label: "Gradient" } + ] + Rectangle { + width: 72; height: 24; radius: 3 + property bool isActive: texPaintCol.colorSource === modelData.src + color: isActive + ? PropertiesPanelController.highlightColor + : (srcMa.containsMouse + ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor) + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: srcMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.colorSource = modelData.src + } + } + } + } + + Column { + spacing: 6 + width: parent.width + visible: texPaintCol.colorSource === 1 + + Row { + spacing: 4 + Repeater { + model: [ + { mode: 0, label: "Linear" }, + { mode: 1, label: "Radial" }, + { mode: 2, label: "Angular" } + ] + Rectangle { + width: 58; height: 22; radius: 3 + property bool isActive: texPaintCol.gradientMode === modelData.mode + color: isActive + ? PropertiesPanelController.highlightColor + : (modeMa.containsMouse + ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor) + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: modeMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.gradientMode = modelData.mode + } + } + } + } + + Image { + width: parent.width + height: 18 + source: texPaintCol.rampPreviewUri + fillMode: Image.Stretch + asynchronous: false + cache: false + } + + Row { + spacing: 6 + width: parent.width + Text { + text: "Ramp:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + width: 40 + } + ThemedComboBox { + id: rampCombo + width: Math.min(140, parent.width - 100) + model: texPaintCol.rampNames + currentIndex: Math.max(0, model.indexOf(texPaintCol.activeRampName)) + onActivated: function(index) { + if (index >= 0 && index < model.length) + TexturePaintController.activeRampName = model[index] + } + } + Rectangle { + width: 48; height: 22; radius: 3 + color: editRampMa.containsMouse + ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: "Edit…" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: editRampMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.openRampEditor() + } + } + } + + Row { + spacing: 8 + Rectangle { + width: 14; height: 14; radius: 3 + color: texPaintCol.useFgBgRamp + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: texPaintCol.useFgBgRamp ? "✓" : "" + color: "white"; font.pixelSize: 9 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.useFgBgRamp = !texPaintCol.useFgBgRamp + } + } + Text { + text: "Use FG/BG colours" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + Rectangle { + width: 14; height: 14; radius: 3 + color: texPaintCol.gradientStepped + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: texPaintCol.gradientStepped ? "✓" : "" + color: "white"; font.pixelSize: 9 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.gradientStepped = !texPaintCol.gradientStepped + } + } + Text { + text: "Stepped" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + spacing: 6 + width: parent.width + Text { + text: "Jitter" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + width: 40 + } + Slider { + id: jitterSlider + width: Math.min(120, parent.width - 80) + from: 0; to: 1; stepSize: 0.01 + value: texPaintCol.rampJitter + onMoved: TexturePaintController.rampJitter = value + } + Text { + text: Math.round(texPaintCol.rampJitter * 100) + "%" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.7 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + // 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. diff --git a/src/AppSettingsKeys.h b/src/AppSettingsKeys.h index 2093ec119..7fa00a91e 100644 --- a/src/AppSettingsKeys.h +++ b/src/AppSettingsKeys.h @@ -281,6 +281,27 @@ inline const QString& shadowSpotResolution() return k; } +/** @brief Last-selected paint gradient ramp name (Paint v2 Slice A / #544). */ +inline const QString& paintGradientRampName() +{ + static const QString k(QStringLiteral("Paint/gradientRampName")); + return k; +} + +/** @brief Paint brush colour source: 0=solid, 1=gradient. */ +inline const QString& paintColorSource() +{ + static const QString k(QStringLiteral("Paint/colorSource")); + return k; +} + +/** @brief Paint gradient mode: 0=linear, 1=radial, 2=angular. */ +inline const QString& paintGradientMode() +{ + static const QString k(QStringLiteral("Paint/gradientMode")); + return k; +} + } // namespace AppSettingsKeys #endif // APP_SETTINGS_KEYS_H diff --git a/src/BrushEngine.cpp b/src/BrushEngine.cpp new file mode 100644 index 000000000..b907f4e11 --- /dev/null +++ b/src/BrushEngine.cpp @@ -0,0 +1,85 @@ +#include "BrushEngine.h" + +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace BrushEngine { +namespace { + +/// Wrap into [0,1], preserving the closed endpoint so sample(1) hits the +/// last ramp stop. Values outside [0,1] fold via floor (phase jitter / +/// multi-cycle strokeT). +float wrap01(float t) +{ + if (t >= 0.0f && t <= 1.0f) + return t; + t = t - std::floor(t); + if (t < 0.0f) + t += 1.0f; + // float noise can land exactly on 1 after the floor of a near-integer + if (t >= 1.0f) + return 0.0f; + return t; +} + +} // namespace + +float linearStrokeT(float pathLength, float wavelength, float phase) +{ + if (!(wavelength > 1e-8f)) + return wrap01(phase); + // Exact cycle boundaries map back to 0 so a repeating ramp is seamless. + float t = pathLength / wavelength + phase; + t = t - std::floor(t); + if (t < 0.0f) + t += 1.0f; + if (t >= 1.0f - 1e-6f) + return 0.0f; + return t; +} + +float radialT(float dx, float dy) +{ + const float r = std::sqrt(dx * dx + dy * dy); + if (r <= 0.0f) + return 0.0f; + if (r >= 1.0f) + return 1.0f; + return r; +} + +float angularT(float dx, float dy) +{ + if (dx == 0.0f && dy == 0.0f) + return 0.0f; + float a = std::atan2(dy, dx); // (−π, π] + a = a / (2.0f * static_cast(M_PI)); // (−0.5, 0.5] + if (a < 0.0f) + a += 1.0f; + return a; +} + +GradientRamp::Rgba sampleColor(const SampleParams& p) +{ + if (p.source != ColorSource::Gradient || !p.ramp || !p.ramp->isValid()) + return p.solid; + + float t = 0.0f; + switch (p.mode) { + case GradientMode::Linear: + t = wrap01(p.strokeT + p.phaseJitter); + break; + case GradientMode::Radial: + t = wrap01(radialT(p.dx, p.dy) + p.phaseJitter); + break; + case GradientMode::Angular: + t = wrap01(angularT(p.dx, p.dy) + p.phaseJitter); + break; + } + return p.ramp->sample(t); +} + +} // namespace BrushEngine diff --git a/src/BrushEngine.h b/src/BrushEngine.h new file mode 100644 index 000000000..a5f0d037f --- /dev/null +++ b/src/BrushEngine.h @@ -0,0 +1,66 @@ +#ifndef BRUSH_ENGINE_H +#define BRUSH_ENGINE_H + +#include "GradientRamp.h" + +/** + * @brief Paint v2 BrushEngine — colour-source sampling for brush stamps. + * + * Slice A (#544) extracts the colour decision out of TexturePaintController's + * stamp loop so solid and gradient sources compose cleanly. Later slices + * (textured stamps, cavity masks, pressure) plug into the same SampleParams + * without re-touching every tool. + * + * Pure data. No Qt / Ogre dependency — TexturePaintController converts the + * returned Rgba into Ogre::ColourValue at the stamp site. + */ +namespace BrushEngine { + +enum class ColorSource { + Solid = 0, + Gradient = 1, +}; + +/// How a gradient maps onto a stamp / stroke. +enum class GradientMode { + /// Colour advances with stroke path length (`strokeT`). + Linear = 0, + /// Colour radiates from brush centre → edge (`dx`,`dy` length). + Radial = 1, + /// Colour cycles around the brush centre (atan2 of `dx`,`dy`). + Angular = 2, +}; + +struct SampleParams { + ColorSource source = ColorSource::Solid; + GradientRamp::Rgba solid{}; + const GradientRamp::Ramp* ramp = nullptr; + GradientMode mode = GradientMode::Linear; + /// Linear-mode parameter ∈ [0,1] (typically fract(pathLen / wavelength)). + float strokeT = 0.0f; + /// Optional phase offset added before sampling (random ramp jitter). + float phaseJitter = 0.0f; + /// Normalised offset from brush centre in stamp space (−1..1). + /// Used by Radial / Angular; ignored by Solid and Linear. + float dx = 0.0f; + float dy = 0.0f; +}; + +/// Map stroke path length into a repeating [0,1] parameter. +/// `wavelength` is the UV-space distance that covers one full ramp cycle +/// (defaults to ~4× brush radius in the controller). +float linearStrokeT(float pathLength, float wavelength, float phase = 0.0f); + +/// Radial t = clamp(length(dx,dy), 0..1). +float radialT(float dx, float dy); + +/// Angular t = atan2(dy,dx) mapped into [0,1). +float angularT(float dx, float dy); + +/// Sample the active colour source. Falls back to `solid` when the ramp +/// is missing / invalid. +GradientRamp::Rgba sampleColor(const SampleParams& p); + +} // namespace BrushEngine + +#endif // BRUSH_ENGINE_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f910afbea..099068409 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -146,6 +146,8 @@ TextureChannelPacker.cpp TextureAtlasPacker.cpp PaintBufferImageProvider.cpp PaintSelectionMask.cpp +GradientRamp.cpp +BrushEngine.cpp TexturePaintBuffer.cpp TexturePaintController.cpp VertexColorBaker.cpp @@ -344,6 +346,8 @@ TextureChannelPacker.h TextureAtlasPacker.h PaintBufferImageProvider.h PaintSelectionMask.h +GradientRamp.h +BrushEngine.h TexturePaintBuffer.h UpdateVersion.h TexturePaintController.h diff --git a/src/GradientRamp.cpp b/src/GradientRamp.cpp new file mode 100644 index 000000000..97ab3e389 --- /dev/null +++ b/src/GradientRamp.cpp @@ -0,0 +1,310 @@ +#include "GradientRamp.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace GradientRamp { +namespace { + +Rgba lerp(const Rgba& a, const Rgba& b, float t) +{ + t = std::clamp(t, 0.0f, 1.0f); + return { + a.r + (b.r - a.r) * t, + a.g + (b.g - a.g) * t, + a.b + (b.b - a.b) * t, + a.a + (b.a - a.a) * t, + }; +} + +Stop stopAt(float t, float r, float g, float b, float a = 1.0f) +{ + return {t, {r, g, b, a}}; +} + +void sortStops(std::vector& stops) +{ + std::sort(stops.begin(), stops.end(), + [](const Stop& a, const Stop& b) { + return a.position < b.position; + }); +} + +} // namespace + +Rgba Ramp::sample(float t) const +{ + if (stops.empty()) + return {}; + if (stops.size() == 1) + return stops.front().colour; + + t = std::clamp(t, 0.0f, 1.0f); + if (t <= stops.front().position) + return stops.front().colour; + if (t >= stops.back().position) + return stops.back().colour; + + for (size_t i = 0; i + 1 < stops.size(); ++i) { + const Stop& a = stops[i]; + const Stop& b = stops[i + 1]; + if (t > b.position) + continue; + if (interpolate == Interpolate::Stepped) { + // Hold the left stop until the next stop's position; at the + // exact boundary, take the right stop (falls through via continue). + if (t < b.position) + return a.colour; + continue; + } + const float span = b.position - a.position; + if (span <= 1e-8f) + return b.colour; + const float u = (t - a.position) / span; + return lerp(a.colour, b.colour, u); + } + return stops.back().colour; +} + +Ramp fromFgBg(const Rgba& fg, const Rgba& bg, const std::string& name) +{ + Ramp r; + r.name = name; + r.interpolate = Interpolate::Linear; + r.stops = {stopAt(0.0f, fg.r, fg.g, fg.b, fg.a), + stopAt(1.0f, bg.r, bg.g, bg.b, bg.a)}; + return r; +} + +std::vector bundledPresets() +{ + std::vector out; + out.reserve(6); + + { + Ramp r; + r.name = "Greyscale"; + r.stops = {stopAt(0.0f, 0, 0, 0), stopAt(1.0f, 1, 1, 1)}; + out.push_back(std::move(r)); + } + { + // Full hue wheel — red→yellow→green→cyan→blue→magenta→red. + Ramp r; + r.name = "Hue"; + r.stops = { + stopAt(0.0f / 6.0f, 1, 0, 0), + stopAt(1.0f / 6.0f, 1, 1, 0), + stopAt(2.0f / 6.0f, 0, 1, 0), + stopAt(3.0f / 6.0f, 0, 1, 1), + stopAt(4.0f / 6.0f, 0, 0, 1), + stopAt(5.0f / 6.0f, 1, 0, 1), + stopAt(1.0f, 1, 0, 0), + }; + out.push_back(std::move(r)); + } + { + Ramp r; + r.name = "Gold to Rust"; + r.stops = { + stopAt(0.0f, 0.95f, 0.78f, 0.25f), + stopAt(0.45f, 0.85f, 0.45f, 0.12f), + stopAt(1.0f, 0.55f, 0.18f, 0.08f), + }; + out.push_back(std::move(r)); + } + { + Ramp r; + r.name = "Sunset"; + r.stops = { + stopAt(0.0f, 1.00f, 0.55f, 0.15f), + stopAt(0.45f, 0.95f, 0.25f, 0.45f), + stopAt(1.0f, 0.20f, 0.10f, 0.45f), + }; + out.push_back(std::move(r)); + } + { + Ramp r; + r.name = "Ocean"; + r.stops = { + stopAt(0.0f, 0.02f, 0.12f, 0.35f), + stopAt(0.50f, 0.05f, 0.45f, 0.55f), + stopAt(1.0f, 0.25f, 0.85f, 0.90f), + }; + out.push_back(std::move(r)); + } + { + Ramp r; + r.name = "Skin Tones"; + r.stops = { + stopAt(0.0f, 0.96f, 0.82f, 0.72f), + stopAt(0.50f, 0.78f, 0.55f, 0.42f), + stopAt(1.0f, 0.42f, 0.25f, 0.18f), + }; + out.push_back(std::move(r)); + } + return out; +} + +const Ramp* findBundled(const std::string& name) +{ + // Stable addresses for the lifetime of the process — rebuilt once. + static const std::vector kBundled = bundledPresets(); + for (const Ramp& r : kBundled) { + if (r.name == name) + return &r; + } + return nullptr; +} + +std::string toJson(const Ramp& ramp) +{ + QJsonObject root; + root.insert(QStringLiteral("name"), QString::fromStdString(ramp.name)); + root.insert(QStringLiteral("interpolate"), + ramp.interpolate == Interpolate::Stepped + ? QStringLiteral("stepped") + : QStringLiteral("linear")); + QJsonArray stops; + for (const Stop& s : ramp.stops) { + QJsonObject o; + o.insert(QStringLiteral("t"), static_cast(s.position)); + o.insert(QStringLiteral("r"), static_cast(s.colour.r)); + o.insert(QStringLiteral("g"), static_cast(s.colour.g)); + o.insert(QStringLiteral("b"), static_cast(s.colour.b)); + o.insert(QStringLiteral("a"), static_cast(s.colour.a)); + stops.append(o); + } + root.insert(QStringLiteral("stops"), stops); + return QJsonDocument(root).toJson(QJsonDocument::Compact).toStdString(); +} + +bool fromJson(const std::string& json, Ramp& out) +{ + QJsonParseError err; + const QJsonDocument doc = + QJsonDocument::fromJson(QByteArray::fromStdString(json), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) + return false; + const QJsonObject root = doc.object(); + Ramp ramp; + ramp.name = root.value(QStringLiteral("name")).toString().toStdString(); + const QString interp = + root.value(QStringLiteral("interpolate")).toString(QStringLiteral("linear")); + ramp.interpolate = (interp == QStringLiteral("stepped")) + ? Interpolate::Stepped + : Interpolate::Linear; + const QJsonArray stops = root.value(QStringLiteral("stops")).toArray(); + ramp.stops.reserve(static_cast(stops.size())); + for (const QJsonValue& v : stops) { + if (!v.isObject()) + continue; + const QJsonObject o = v.toObject(); + Stop s; + s.position = static_cast(o.value(QStringLiteral("t")).toDouble(0.0)); + s.colour.r = static_cast(o.value(QStringLiteral("r")).toDouble(0.0)); + s.colour.g = static_cast(o.value(QStringLiteral("g")).toDouble(0.0)); + s.colour.b = static_cast(o.value(QStringLiteral("b")).toDouble(0.0)); + s.colour.a = static_cast(o.value(QStringLiteral("a")).toDouble(1.0)); + s.position = std::clamp(s.position, 0.0f, 1.0f); + s.colour.r = std::clamp(s.colour.r, 0.0f, 1.0f); + s.colour.g = std::clamp(s.colour.g, 0.0f, 1.0f); + s.colour.b = std::clamp(s.colour.b, 0.0f, 1.0f); + s.colour.a = std::clamp(s.colour.a, 0.0f, 1.0f); + ramp.stops.push_back(s); + } + sortStops(ramp.stops); + if (!ramp.isValid()) + return false; + out = std::move(ramp); + return true; +} + +std::string rampsDirectory() +{ + if (!QCoreApplication::instance()) + return {}; + const QString root = + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if (root.isEmpty()) + return {}; + const QString dir = root + QStringLiteral("/paint/ramps"); + QDir().mkpath(dir); + return dir.toStdString(); +} + +std::string safeFileStem(const std::string& name) +{ + std::string out; + out.reserve(name.size()); + for (char c : name) { + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_' + || c == ' '; + out.push_back(ok ? (c == ' ' ? '_' : c) : '_'); + } + if (out.empty()) + out = "ramp"; + return out; +} + +std::string saveCustom(const Ramp& ramp) +{ + const std::string dir = rampsDirectory(); + if (dir.empty() || !ramp.isValid()) + return {}; + const std::string stem = safeFileStem(ramp.name.empty() ? "custom" : ramp.name); + const QString path = + QString::fromStdString(dir) + QLatin1Char('/') + + QString::fromStdString(stem) + QStringLiteral(".json"); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return {}; + const std::string payload = toJson(ramp); + if (f.write(payload.data(), static_cast(payload.size())) + != static_cast(payload.size())) + return {}; + return path.toStdString(); +} + +std::vector loadCustomRamps() +{ + std::vector out; + const std::string dir = rampsDirectory(); + if (dir.empty()) + return out; + const QDir qdir(QString::fromStdString(dir)); + const QStringList files = + qdir.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name); + for (const QString& name : files) { + QFile f(qdir.filePath(name)); + if (!f.open(QIODevice::ReadOnly)) + continue; + Ramp ramp; + if (fromJson(f.readAll().toStdString(), ramp)) + out.push_back(std::move(ramp)); + } + return out; +} + +bool deleteCustom(const std::string& name) +{ + const std::string dir = rampsDirectory(); + if (dir.empty() || name.empty()) + return false; + const QString path = + QString::fromStdString(dir) + QLatin1Char('/') + + QString::fromStdString(safeFileStem(name)) + QStringLiteral(".json"); + return QFile::remove(path); +} + +} // namespace GradientRamp diff --git a/src/GradientRamp.h b/src/GradientRamp.h new file mode 100644 index 000000000..bd205c726 --- /dev/null +++ b/src/GradientRamp.h @@ -0,0 +1,82 @@ +#ifndef GRADIENT_RAMP_H +#define GRADIENT_RAMP_H + +#include +#include + +/** + * @brief Paint v2 Slice A (#544) — gradient ramp data model (Ogre-free). + * + * An ordered list of colour stops on [0,1] with linear or stepped + * interpolation. Bundled CC0 presets live here; custom ramps serialize + * as JSON into `/paint/ramps/`. + * + * Pure data. Qt is used only in the .cpp for JSON I/O and AppData paths; + * the sample() path has no Qt dependency so unit tests stay headless. + */ +namespace GradientRamp { + +struct Rgba { + float r = 0.0f; + float g = 0.0f; + float b = 0.0f; + float a = 1.0f; +}; + +struct Stop { + float position = 0.0f; ///< ∈ [0,1] + Rgba colour; +}; + +enum class Interpolate { + Linear = 0, ///< Bilinear lerp between neighbouring stops. + Stepped = 1, ///< Hold the left stop's colour until the next stop. +}; + +struct Ramp { + std::string name; + std::vector stops; + Interpolate interpolate = Interpolate::Linear; + + /// Sample the ramp at t ∈ [0,1] (clamped). Empty ramps return black. + Rgba sample(float t) const; + + /// True when at least two stops are present (a usable ramp). + bool isValid() const { return stops.size() >= 2; } +}; + +/// Build a two-stop FG→BG ramp (Photoshop / Krita quick mode). +Ramp fromFgBg(const Rgba& fg, const Rgba& bg, + const std::string& name = "FG/BG"); + +/// The six bundled CC0 presets required by #544. +std::vector bundledPresets(); + +/// Look up a bundled preset by exact name. Returns nullptr if unknown. +const Ramp* findBundled(const std::string& name); + +/// JSON schema: `{ "name", "interpolate": "linear"|"stepped", +/// "stops": [ { "t", "r", "g", "b", "a" } ] }`. +std::string toJson(const Ramp& ramp); +bool fromJson(const std::string& json, Ramp& out); + +/// `/paint/ramps/` — created on demand. Empty when AppData is +/// unavailable (headless tests without QCoreApplication). +std::string rampsDirectory(); + +/// Save `ramp` as `/.json`. Returns the +/// written path, or empty on failure. +std::string saveCustom(const Ramp& ramp); + +/// Load every `*.json` in the custom ramps directory. +std::vector loadCustomRamps(); + +/// Delete a custom ramp file by name. Returns true if a file was removed. +bool deleteCustom(const std::string& name); + +/// Sanitize a ramp name into a filesystem-safe stem (no path seps). +std::string safeFileStem(const std::string& name); + +} // namespace GradientRamp + +#endif // GRADIENT_RAMP_H diff --git a/src/GradientRamp_test.cpp b/src/GradientRamp_test.cpp new file mode 100644 index 000000000..ec0210aa8 --- /dev/null +++ b/src/GradientRamp_test.cpp @@ -0,0 +1,209 @@ +#include + +#include +#include + +#include "BrushEngine.h" +#include "GradientRamp.h" +#include "TexturePaintBuffer.h" + +#include + +namespace { + +GradientRamp::Rgba approxEq(const GradientRamp::Rgba& a, + const GradientRamp::Rgba& b, + float eps = 0.02f) +{ + EXPECT_NEAR(a.r, b.r, eps); + EXPECT_NEAR(a.g, b.g, eps); + EXPECT_NEAR(a.b, b.b, eps); + EXPECT_NEAR(a.a, b.a, eps); + return a; +} + +} // namespace + +TEST(GradientRampTest, SampleLinearEndpointsAndMidpoint) +{ + auto ramp = GradientRamp::fromFgBg({0, 0, 0, 1}, {1, 1, 1, 1}); + approxEq(ramp.sample(0.0f), {0, 0, 0, 1}); + approxEq(ramp.sample(1.0f), {1, 1, 1, 1}); + approxEq(ramp.sample(0.5f), {0.5f, 0.5f, 0.5f, 1}); +} + +TEST(GradientRampTest, SampleSteppedHoldsLeftStop) +{ + GradientRamp::Ramp ramp; + ramp.interpolate = GradientRamp::Interpolate::Stepped; + ramp.stops = { + {0.0f, {1, 0, 0, 1}}, + {0.5f, {0, 1, 0, 1}}, + {1.0f, {0, 0, 1, 1}}, + }; + approxEq(ramp.sample(0.25f), {1, 0, 0, 1}); + approxEq(ramp.sample(0.49f), {1, 0, 0, 1}); + approxEq(ramp.sample(0.5f), {0, 1, 0, 1}); +} + +TEST(GradientRampTest, BundledPresetsAreSixAndValid) +{ + const auto presets = GradientRamp::bundledPresets(); + ASSERT_EQ(presets.size(), 6u); + for (const auto& p : presets) { + EXPECT_TRUE(p.isValid()) << p.name; + EXPECT_FALSE(p.name.empty()); + // Endpoints must be sampleable. + const auto a = p.sample(0.0f); + const auto b = p.sample(1.0f); + EXPECT_GE(a.a, 0.0f); + EXPECT_GE(b.a, 0.0f); + } + EXPECT_NE(GradientRamp::findBundled("Sunset"), nullptr); + EXPECT_NE(GradientRamp::findBundled("Hue"), nullptr); + EXPECT_EQ(GradientRamp::findBundled("DoesNotExist"), nullptr); +} + +TEST(GradientRampTest, JsonRoundTrip) +{ + const auto* sunset = GradientRamp::findBundled("Sunset"); + ASSERT_NE(sunset, nullptr); + const std::string json = GradientRamp::toJson(*sunset); + GradientRamp::Ramp loaded; + ASSERT_TRUE(GradientRamp::fromJson(json, loaded)); + EXPECT_EQ(loaded.name, sunset->name); + EXPECT_EQ(loaded.stops.size(), sunset->stops.size()); + approxEq(loaded.sample(0.25f), sunset->sample(0.25f)); +} + +TEST(GradientRampTest, CustomSaveLoadAcrossSessions) +{ + // UnitTests already owns a QCoreApplication. Write into a temp file + // via the JSON helpers (the AppData path is covered by saveCustom + // when a writable location exists). + GradientRamp::Ramp ramp = + GradientRamp::fromFgBg({1, 0, 0, 1}, {0, 0, 1, 1}, "UnitTestRamp"); + const std::string json = GradientRamp::toJson(ramp); + ASSERT_FALSE(json.empty()); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString path = tmp.filePath(QStringLiteral("UnitTestRamp.json")); + { + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Truncate)); + ASSERT_EQ(f.write(json.data(), static_cast(json.size())), + static_cast(json.size())); + } + { + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + GradientRamp::Ramp loaded; + ASSERT_TRUE(GradientRamp::fromJson(f.readAll().toStdString(), loaded)); + EXPECT_EQ(loaded.name, "UnitTestRamp"); + approxEq(loaded.sample(0.5f), {0.5f, 0, 0.5f, 1}); + } + + // Also exercise the AppData helper when a writable location exists. + const std::string dir = GradientRamp::rampsDirectory(); + if (!dir.empty()) { + const std::string saved = GradientRamp::saveCustom(ramp); + EXPECT_FALSE(saved.empty()); + bool found = false; + for (const auto& r : GradientRamp::loadCustomRamps()) { + if (r.name == "UnitTestRamp") { + found = true; + break; + } + } + EXPECT_TRUE(found); + EXPECT_TRUE(GradientRamp::deleteCustom("UnitTestRamp")); + } +} + +TEST(BrushEngineTest, SolidIgnoresRamp) +{ + const auto* hue = GradientRamp::findBundled("Hue"); + ASSERT_NE(hue, nullptr); + BrushEngine::SampleParams p; + p.source = BrushEngine::ColorSource::Solid; + p.solid = {0.1f, 0.2f, 0.3f, 1.0f}; + p.ramp = hue; + p.mode = BrushEngine::GradientMode::Linear; + p.strokeT = 0.5f; + approxEq(BrushEngine::sampleColor(p), {0.1f, 0.2f, 0.3f, 1.0f}); +} + +TEST(BrushEngineTest, LinearUsesStrokeT) +{ + auto ramp = GradientRamp::fromFgBg({0, 0, 0, 1}, {1, 0, 0, 1}); + BrushEngine::SampleParams p; + p.source = BrushEngine::ColorSource::Gradient; + p.ramp = &ramp; + p.mode = BrushEngine::GradientMode::Linear; + p.strokeT = 0.0f; + approxEq(BrushEngine::sampleColor(p), {0, 0, 0, 1}); + p.strokeT = 1.0f; + approxEq(BrushEngine::sampleColor(p), {1, 0, 0, 1}); + p.strokeT = 0.5f; + approxEq(BrushEngine::sampleColor(p), {0.5f, 0, 0, 1}); +} + +TEST(BrushEngineTest, RadialAndAngularStampSelection) +{ + auto ramp = GradientRamp::fromFgBg({0, 0, 0, 1}, {1, 1, 1, 1}); + BrushEngine::SampleParams p; + p.source = BrushEngine::ColorSource::Gradient; + p.ramp = &ramp; + + p.mode = BrushEngine::GradientMode::Radial; + p.dx = 0; p.dy = 0; + approxEq(BrushEngine::sampleColor(p), {0, 0, 0, 1}); + p.dx = 1; p.dy = 0; + approxEq(BrushEngine::sampleColor(p), {1, 1, 1, 1}); + + p.mode = BrushEngine::GradientMode::Angular; + // atan2(0, 1) = 0 → t = 0 → black + p.dx = 1; p.dy = 0; + approxEq(BrushEngine::sampleColor(p), {0, 0, 0, 1}); + // atan2(0, -1) = π → t = 0.5 → mid grey + p.dx = -1; p.dy = 0; + approxEq(BrushEngine::sampleColor(p), {0.5f, 0.5f, 0.5f, 1}); +} + +TEST(BrushEngineTest, LinearStrokeTIsSmoothAndPeriodic) +{ + EXPECT_NEAR(BrushEngine::linearStrokeT(0.0f, 1.0f), 0.0f, 1e-5f); + EXPECT_NEAR(BrushEngine::linearStrokeT(0.5f, 1.0f), 0.5f, 1e-5f); + EXPECT_NEAR(BrushEngine::linearStrokeT(1.0f, 1.0f), 0.0f, 1e-5f); + EXPECT_NEAR(BrushEngine::linearStrokeT(1.25f, 1.0f, 0.1f), 0.35f, 1e-5f); + // Turning the stroke (same length) must not jitter t — length-based. + EXPECT_FLOAT_EQ(BrushEngine::linearStrokeT(0.4f, 2.0f), + BrushEngine::linearStrokeT(0.4f, 2.0f)); +} + +TEST(BrushEngineTest, PaintBrushGradientCallbackWritesRamp) +{ + TexturePaintBuffer buf(64, 64); + buf.clear(Ogre::ColourValue(0, 0, 0, 1)); + auto ramp = GradientRamp::fromFgBg({1, 0, 0, 1}, {0, 0, 1, 1}); + const int n = buf.paintBrush( + Ogre::Vector2(0.5f, 0.5f), + 0.4f, + [&ramp](float dx, float dy) { + BrushEngine::SampleParams p; + p.source = BrushEngine::ColorSource::Gradient; + p.ramp = &ramp; + p.mode = BrushEngine::GradientMode::Radial; + p.dx = dx; + p.dy = dy; + const auto c = BrushEngine::sampleColor(p); + return Ogre::ColourValue(c.r, c.g, c.b, c.a); + }, + 1.0f, 0.0f); + EXPECT_GT(n, 0); + // Centre should be near red (radial t≈0), edge near blue (t≈1). + const auto centre = buf.pixel(32, 32); + EXPECT_GT(centre.r, 0.7f); + EXPECT_LT(centre.b, 0.3f); +} diff --git a/src/TexturePaintBuffer.cpp b/src/TexturePaintBuffer.cpp index 226e0147d..b16ae7cf5 100644 --- a/src/TexturePaintBuffer.cpp +++ b/src/TexturePaintBuffer.cpp @@ -105,9 +105,22 @@ int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, float strength, float falloff, BrushShape shape) +{ + return paintBrush(uv, radiusUV, + [&color](float, float) { return color; }, + strength, falloff, shape); +} + +int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, + float radiusUV, + const ColorAtFn& colorAt, + float strength, + float falloff, + BrushShape shape) { if (m_width <= 0 || m_height <= 0) return 0; if (radiusUV <= 0.0f) return 0; + if (!colorAt) return 0; strength = std::clamp(strength, 0.0f, 1.0f); falloff = std::clamp(falloff, 0.0f, 1.0f); if (strength <= 0.0f) return 0; @@ -154,6 +167,7 @@ int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, blend = strength * w; } if (blend <= 0.0f) continue; + const Ogre::ColourValue color = colorAt(dx, dy); const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; const float prevR = byteToFloat(m_pixels[off + 0]); const float prevG = byteToFloat(m_pixels[off + 1]); diff --git a/src/TexturePaintBuffer.h b/src/TexturePaintBuffer.h index f643fe10a..d65154ab7 100644 --- a/src/TexturePaintBuffer.h +++ b/src/TexturePaintBuffer.h @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -107,6 +108,22 @@ class TexturePaintBuffer float falloff = 0.5f, BrushShape shape = BrushShape::Round); + /** + * @brief Paint a brush stamp with a per-pixel colour callback. + * + * `colorAt(dx, dy)` receives normalised brush-space offsets (−1..1) + * from the stamp centre. Used by gradient radial / angular modes + * (Paint v2 Slice A / #544) so each texel can sample a different + * ramp position without a separate code path for solid stamps. + */ + using ColorAtFn = std::function; + int paintBrush(const Ogre::Vector2& uv, + float radiusUV, + const ColorAtFn& colorAt, + float strength = 1.0f, + float falloff = 0.5f, + BrushShape shape = BrushShape::Round); + /** * @brief Save the buffer to disk as a PNG/JPEG/TGA/BMP/etc. * diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index b2f3cc412..52143dc8a 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1,5 +1,6 @@ #include "TexturePaintController.h" +#include "AppSettingsKeys.h" #include "EditModeController.h" #include "GamificationManager.h" #include "EditableMesh.h" @@ -15,6 +16,7 @@ #include #include +#include #include #include #include @@ -22,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -290,6 +294,28 @@ TexturePaintController::TexturePaintController(QObject* parent) if (auto* em = EditModeController::instance()) { connect(em, &EditModeController::vertexPaintChanged, this, &TexturePaintController::texturePaintChanged); + // FG/BG changes should refresh the FG/BG quick-ramp preview. + connect(em, &EditModeController::vertexPaintChanged, + this, [this]() { + if (m_useFgBgRamp || m_colorSource == ColorGradient) + reloadActiveRamp(); + }); + } + + // Restore Paint v2 Slice A preferences. + { + QSettings s; + m_colorSource = static_cast( + s.value(AppSettingsKeys::paintColorSource(), static_cast(ColorSolid)).toInt()); + m_gradientMode = static_cast( + s.value(AppSettingsKeys::paintGradientMode(), static_cast(GradientLinear)).toInt()); + m_activeRampName = s.value(AppSettingsKeys::paintGradientRampName(), + QStringLiteral("Sunset")).toString(); + if (m_colorSource != ColorSolid && m_colorSource != ColorGradient) + m_colorSource = ColorSolid; + if (m_gradientMode < GradientLinear || m_gradientMode > GradientAngular) + m_gradientMode = GradientLinear; + reloadActiveRamp(); } // Refresh the texture slot list whenever the selection changes — @@ -408,6 +434,396 @@ void TexturePaintController::setBrushTool(int tool) emit brushToolChanged(); } +void TexturePaintController::setColorSource(int source) +{ + const ColorSource s = (source == static_cast(ColorGradient)) + ? ColorGradient + : ColorSolid; + if (s == m_colorSource) return; + m_colorSource = s; + QSettings().setValue(AppSettingsKeys::paintColorSource(), static_cast(m_colorSource)); + if (m_colorSource == ColorGradient) { + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("mode=%1 ramp=%2") + .arg(static_cast(m_gradientMode)) + .arg(m_useFgBgRamp ? QStringLiteral("FG/BG") : m_activeRampName)); + } + reloadActiveRamp(); + emit gradientChanged(); +} + +void TexturePaintController::setGradientMode(int mode) +{ + GradientMode m = GradientLinear; + if (mode == static_cast(GradientRadial)) + m = GradientRadial; + else if (mode == static_cast(GradientAngular)) + m = GradientAngular; + if (m == m_gradientMode) return; + m_gradientMode = m; + QSettings().setValue(AppSettingsKeys::paintGradientMode(), static_cast(m_gradientMode)); + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("mode=%1 ramp=%2") + .arg(static_cast(m_gradientMode)) + .arg(m_useFgBgRamp ? QStringLiteral("FG/BG") : m_activeRampName)); + emit gradientChanged(); +} + +void TexturePaintController::setActiveRampName(const QString& name) +{ + if (name.isEmpty() || name == m_activeRampName) return; + m_activeRampName = name; + m_useFgBgRamp = false; + QSettings().setValue(AppSettingsKeys::paintGradientRampName(), m_activeRampName); + reloadActiveRamp(); + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("mode=%1 ramp=%2") + .arg(static_cast(m_gradientMode)) + .arg(m_activeRampName)); + emit gradientChanged(); +} + +void TexturePaintController::setUseFgBgRamp(bool on) +{ + if (on == m_useFgBgRamp) return; + m_useFgBgRamp = on; + reloadActiveRamp(); + emit gradientChanged(); +} + +void TexturePaintController::setGradientStepped(bool on) +{ + if (on == m_gradientStepped) return; + m_gradientStepped = on; + m_activeRamp.interpolate = on ? GradientRamp::Interpolate::Stepped + : GradientRamp::Interpolate::Linear; + refreshRampPreviewUri(); + emit gradientChanged(); +} + +void TexturePaintController::setRampJitter(double j) +{ + j = std::clamp(j, 0.0, 1.0); + if (std::abs(j - m_rampJitter) < 1e-6) return; + m_rampJitter = j; + emit gradientChanged(); +} + +QStringList TexturePaintController::rampNames() const +{ + QStringList names; + for (const auto& r : GradientRamp::bundledPresets()) + names << QString::fromStdString(r.name); + for (const auto& r : GradientRamp::loadCustomRamps()) { + const QString n = QString::fromStdString(r.name); + if (!names.contains(n)) + names << n; + } + return names; +} + +QVariantList TexturePaintController::activeRampStops() const +{ + QVariantList out; + for (const auto& s : m_activeRamp.stops) { + QVariantMap m; + m.insert(QStringLiteral("t"), static_cast(s.position)); + m.insert(QStringLiteral("r"), static_cast(s.colour.r)); + m.insert(QStringLiteral("g"), static_cast(s.colour.g)); + m.insert(QStringLiteral("b"), static_cast(s.colour.b)); + m.insert(QStringLiteral("a"), static_cast(s.colour.a)); + out.push_back(m); + } + return out; +} + +const GradientRamp::Ramp* TexturePaintController::resolveActiveRamp() const +{ + if (m_useFgBgRamp) + return &m_activeRamp; + if (m_activeRamp.isValid() && m_activeRamp.name == m_activeRampName.toStdString()) + return &m_activeRamp; + if (const auto* bundled = GradientRamp::findBundled(m_activeRampName.toStdString())) + return bundled; + return m_activeRamp.isValid() ? &m_activeRamp : nullptr; +} + +void TexturePaintController::reloadActiveRamp() +{ + if (m_useFgBgRamp) { + const QColor fg = texturePaintColor(); + const QColor bg = bgPaintColor(); + m_activeRamp = GradientRamp::fromFgBg( + {static_cast(fg.redF()), static_cast(fg.greenF()), + static_cast(fg.blueF()), static_cast(fg.alphaF())}, + {static_cast(bg.redF()), static_cast(bg.greenF()), + static_cast(bg.blueF()), static_cast(bg.alphaF())}); + m_activeRamp.interpolate = m_gradientStepped + ? GradientRamp::Interpolate::Stepped + : GradientRamp::Interpolate::Linear; + refreshRampPreviewUri(); + return; + } + + if (const auto* bundled = GradientRamp::findBundled(m_activeRampName.toStdString())) { + m_activeRamp = *bundled; + } else { + bool found = false; + for (const auto& r : GradientRamp::loadCustomRamps()) { + if (r.name == m_activeRampName.toStdString()) { + m_activeRamp = r; + found = true; + break; + } + } + if (!found) { + // Fall back to Sunset so the brush always has a usable ramp. + if (const auto* sunset = GradientRamp::findBundled("Sunset")) { + m_activeRamp = *sunset; + m_activeRampName = QStringLiteral("Sunset"); + } + } + } + m_activeRamp.interpolate = m_gradientStepped + ? GradientRamp::Interpolate::Stepped + : GradientRamp::Interpolate::Linear; + refreshRampPreviewUri(); +} + +void TexturePaintController::refreshRampPreviewUri() +{ + constexpr int W = 256; + constexpr int H = 24; + QImage img(W, H, QImage::Format_RGBA8888); + if (!m_activeRamp.isValid()) { + img.fill(Qt::black); + } else { + for (int x = 0; x < W; ++x) { + const float t = (W == 1) ? 0.0f : static_cast(x) / static_cast(W - 1); + const auto c = m_activeRamp.sample(t); + const QRgb px = qRgba( + static_cast(std::clamp(c.r, 0.0f, 1.0f) * 255.0f + 0.5f), + static_cast(std::clamp(c.g, 0.0f, 1.0f) * 255.0f + 0.5f), + static_cast(std::clamp(c.b, 0.0f, 1.0f) * 255.0f + 0.5f), + static_cast(std::clamp(c.a, 0.0f, 1.0f) * 255.0f + 0.5f)); + for (int y = 0; y < H; ++y) + img.setPixel(x, y, px); + } + } + QByteArray ba; + QBuffer buf(&ba); + buf.open(QIODevice::WriteOnly); + img.save(&buf, "PNG"); + m_rampPreviewUri = QStringLiteral("data:image/png;base64,") + ba.toBase64(); +} + +void TexturePaintController::noteStrokeSample(const Ogre::Vector2& uv, bool isStart) +{ + if (isStart || !m_strokeHavePrevUV) { + m_strokePrevUV = uv; + m_strokeHavePrevUV = true; + m_strokePathLength = 0.0f; + m_strokeDirSmoothed = Ogre::Vector2::ZERO; + return; + } + const Ogre::Vector2 delta = uv - m_strokePrevUV; + const float dist = delta.length(); + if (dist > 1e-8f) { + // EMA-smoothed direction keeps linear sampling stable when the + // stroke turns — the path length still advances by the true + // step distance so the ramp doesn't stutter. + const Ogre::Vector2 dir = delta / dist; + if (m_strokeDirSmoothed.squaredLength() < 1e-12f) + m_strokeDirSmoothed = dir; + else + m_strokeDirSmoothed = (m_strokeDirSmoothed * 0.75f + dir * 0.25f).normalisedCopy(); + m_strokePathLength += dist; + } + m_strokePrevUV = uv; +} + +bool TexturePaintController::saveCustomRamp(const QString& name, const QVariantList& stops, + bool stepped) +{ + if (name.trimmed().isEmpty() || stops.size() < 2) + return false; + GradientRamp::Ramp ramp; + ramp.name = name.trimmed().toStdString(); + ramp.interpolate = stepped ? GradientRamp::Interpolate::Stepped + : GradientRamp::Interpolate::Linear; + for (const QVariant& v : stops) { + const QVariantMap m = v.toMap(); + GradientRamp::Stop s; + s.position = static_cast(m.value(QStringLiteral("t")).toDouble()); + s.colour.r = static_cast(m.value(QStringLiteral("r")).toDouble()); + s.colour.g = static_cast(m.value(QStringLiteral("g")).toDouble()); + s.colour.b = static_cast(m.value(QStringLiteral("b")).toDouble()); + s.colour.a = static_cast(m.value(QStringLiteral("a"), 1.0).toDouble()); + s.position = std::clamp(s.position, 0.0f, 1.0f); + ramp.stops.push_back(s); + } + std::sort(ramp.stops.begin(), ramp.stops.end(), + [](const GradientRamp::Stop& a, const GradientRamp::Stop& b) { + return a.position < b.position; + }); + if (!ramp.isValid()) + return false; + const std::string path = GradientRamp::saveCustom(ramp); + if (path.empty()) + return false; + m_activeRampName = QString::fromStdString(ramp.name); + m_useFgBgRamp = false; + m_gradientStepped = stepped; + m_activeRamp = std::move(ramp); + QSettings().setValue(AppSettingsKeys::paintGradientRampName(), m_activeRampName); + refreshRampPreviewUri(); + SentryReporter::addBreadcrumb("paint.brush.gradient", + QStringLiteral("saved custom ramp=%1").arg(m_activeRampName)); + emit gradientChanged(); + return true; +} + +bool TexturePaintController::deleteCustomRamp(const QString& name) +{ + if (GradientRamp::findBundled(name.toStdString())) + return false; + if (!GradientRamp::deleteCustom(name.toStdString())) + return false; + if (m_activeRampName == name) + setActiveRampName(QStringLiteral("Sunset")); + else + emit gradientChanged(); + return true; +} + +void TexturePaintController::setActiveRampStops(const QVariantList& stops, bool stepped) +{ + if (stops.size() < 2) + return; + GradientRamp::Ramp ramp; + ramp.name = m_useFgBgRamp ? "FG/BG" : m_activeRampName.toStdString(); + ramp.interpolate = stepped ? GradientRamp::Interpolate::Stepped + : GradientRamp::Interpolate::Linear; + for (const QVariant& v : stops) { + const QVariantMap m = v.toMap(); + GradientRamp::Stop s; + s.position = std::clamp(static_cast(m.value(QStringLiteral("t")).toDouble()), 0.0f, 1.0f); + s.colour.r = std::clamp(static_cast(m.value(QStringLiteral("r")).toDouble()), 0.0f, 1.0f); + s.colour.g = std::clamp(static_cast(m.value(QStringLiteral("g")).toDouble()), 0.0f, 1.0f); + s.colour.b = std::clamp(static_cast(m.value(QStringLiteral("b")).toDouble()), 0.0f, 1.0f); + s.colour.a = std::clamp(static_cast(m.value(QStringLiteral("a"), 1.0).toDouble()), 0.0f, 1.0f); + ramp.stops.push_back(s); + } + std::sort(ramp.stops.begin(), ramp.stops.end(), + [](const GradientRamp::Stop& a, const GradientRamp::Stop& b) { + return a.position < b.position; + }); + if (!ramp.isValid()) + return; + m_activeRamp = std::move(ramp); + m_gradientStepped = stepped; + refreshRampPreviewUri(); + emit gradientChanged(); +} + +bool TexturePaintController::sampleRampFromTexture(double u0, double v0, + double u1, double v1, + int numStops) +{ + if (m_buffer.width() <= 0 || m_buffer.height() <= 0) + return false; + numStops = std::clamp(numStops, 2, 16); + GradientRamp::Ramp ramp; + ramp.name = "Eyedropper"; + ramp.interpolate = GradientRamp::Interpolate::Linear; + for (int i = 0; i < numStops; ++i) { + const float t = (numStops == 1) + ? 0.0f + : static_cast(i) / static_cast(numStops - 1); + const float u = static_cast(u0 + (u1 - u0) * static_cast(t)); + const float v = static_cast(v0 + (v1 - v0) * static_cast(t)); + int x = 0, y = 0; + m_buffer.uvToPixel(Ogre::Vector2(u, v), x, y); + const auto c = m_buffer.pixel(x, y); + ramp.stops.push_back({t, {c.r, c.g, c.b, c.a}}); + } + if (!ramp.isValid()) + return false; + m_activeRamp = std::move(ramp); + m_activeRampName = QStringLiteral("Eyedropper"); + m_useFgBgRamp = false; + m_colorSource = ColorGradient; + refreshRampPreviewUri(); + SentryReporter::addBreadcrumb("paint.brush.gradient", + QStringLiteral("mode=%1 ramp=Eyedropper (sampled %2 stops)") + .arg(static_cast(m_gradientMode)) + .arg(numStops)); + emit gradientChanged(); + return true; +} + +void TexturePaintController::openRampEditor() +{ + if (m_rampEditorWindow) { + if (auto* w = qobject_cast(m_rampEditorWindow)) { + w->raise(); + w->requestActivate(); + } + return; + } + 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) { + engine->deleteLater(); + return; + } + m_rampEditorWindow = obj; + if (auto* w = qobject_cast(obj)) { + connect(w, &QQuickWindow::visibleChanged, this, + [this, w, engine](bool vis) { + if (vis || m_rampEditorWindow != w) return; + m_rampEditorWindow = nullptr; + emit rampEditorChanged(); + engine->deleteLater(); + }); + w->show(); + w->raise(); + w->requestActivate(); + } + emit rampEditorChanged(); + }, Qt::DirectConnection); + engine->load(QUrl(QStringLiteral("qrc:/PropertiesPanel/GradientRampEditor.qml"))); + if (!handled) + engine->deleteLater(); + SentryReporter::addBreadcrumb("paint.brush.gradient", "ramp editor opened"); +} + +void TexturePaintController::closeRampEditor() +{ + if (!m_rampEditorWindow) return; + if (auto* w = qobject_cast(m_rampEditorWindow)) { + w->close(); + } else { + m_rampEditorWindow->deleteLater(); + m_rampEditorWindow = nullptr; + emit rampEditorChanged(); + } +} + void TexturePaintController::setPaintTarget(int target) { PaintTarget t = static_cast(target); @@ -1248,6 +1664,19 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree m_strokePreSnapshot = snapshotPixels(); m_wandStrokeActive = false; m_wandStartScreenPos = screenPos; + m_strokeHavePrevUV = false; + m_strokePathLength = 0.0f; + m_strokeDirSmoothed = Ogre::Vector2::ZERO; + m_strokePhaseJitter = (m_rampJitter > 0.0) + ? static_cast(QRandomGenerator::global()->generateDouble() * m_rampJitter) + : 0.0f; + if (m_colorSource == ColorGradient) { + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("mode=%1 ramp=%2") + .arg(static_cast(m_gradientMode)) + .arg(m_useFgBgRamp ? QStringLiteral("FG/BG") : m_activeRampName)); + } SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Paint stroke begin (target=%1 tool=%2 radius=%3 strength=%4 color=%5)") .arg(m_target == TargetVertex ? "vertex" : "texture") @@ -1362,8 +1791,48 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) switch (m_tool) { case ToolPaint: { const QColor c = texturePaintColor(); - const Ogre::ColourValue paint(c.redF(), c.greenF(), c.blueF(), c.alphaF()); - return m_buffer.paintBrush(uv, radius, paint, strength, falloff, shape) > 0; + const Ogre::ColourValue solid(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + if (m_colorSource != ColorGradient) { + return m_buffer.paintBrush(uv, radius, solid, strength, falloff, shape) > 0; + } + + // Paint v2 Slice A — gradient colour source via BrushEngine. + noteStrokeSample(uv, m_strokeJustBegan); + m_strokeJustBegan = false; + const GradientRamp::Ramp* ramp = resolveActiveRamp(); + if (!ramp || !ramp->isValid()) { + return m_buffer.paintBrush(uv, radius, solid, strength, falloff, shape) > 0; + } + // Wavelength ≈ 4× brush radius so one full ramp covers a short stroke. + const float wavelength = std::max(radius * 4.0f, 0.05f); + const float strokeT = BrushEngine::linearStrokeT( + m_strokePathLength, wavelength, m_strokePhaseJitter); + BrushEngine::SampleParams params; + params.source = BrushEngine::ColorSource::Gradient; + params.solid = {solid.r, solid.g, solid.b, solid.a}; + params.ramp = ramp; + params.mode = static_cast(m_gradientMode); + params.strokeT = strokeT; + params.phaseJitter = m_strokePhaseJitter; + // Linear: whole stamp shares strokeT (phase already in strokeT). + // Radial/Angular: per-pixel + per-stroke phase jitter. + if (m_gradientMode == GradientLinear) { + params.phaseJitter = 0.0f; // already folded into strokeT + const auto sampled = BrushEngine::sampleColor(params); + const Ogre::ColourValue paint(sampled.r, sampled.g, sampled.b, sampled.a); + return m_buffer.paintBrush(uv, radius, paint, strength, falloff, shape) > 0; + } + return m_buffer.paintBrush( + uv, radius, + [¶ms](float dx, float dy) { + BrushEngine::SampleParams local = params; + local.dx = dx; + local.dy = dy; + const auto s = BrushEngine::sampleColor(local); + return Ogre::ColourValue(s.r, s.g, s.b, s.a); + }, + strength, falloff, shape) + > 0; } case ToolErase: { // Erase = paint with the user-chosen background color. The BG @@ -1884,6 +2353,19 @@ bool TexturePaintController::beginStrokeUV(double u, double v) // 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); + m_strokeHavePrevUV = false; + m_strokePathLength = 0.0f; + m_strokeDirSmoothed = Ogre::Vector2::ZERO; + m_strokePhaseJitter = (m_rampJitter > 0.0) + ? static_cast(QRandomGenerator::global()->generateDouble() * m_rampJitter) + : 0.0f; + if (m_colorSource == ColorGradient) { + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("mode=%1 ramp=%2") + .arg(static_cast(m_gradientMode)) + .arg(m_useFgBgRamp ? QStringLiteral("FG/BG") : m_activeRampName)); + } emit hoveredUVChanged(u, v); updateStrokeUV(u, v); return true; diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 673da6438..df60baa2e 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -3,6 +3,8 @@ #include "PaintSelectionMask.h" #include "TexturePaintBuffer.h" +#include "BrushEngine.h" +#include "GradientRamp.h" #include #include @@ -78,6 +80,17 @@ class TexturePaintController : public QObject // Brush tool — paint / erase / fill / picker. Q_PROPERTY(int brushTool READ brushTool WRITE setBrushTool NOTIFY brushToolChanged) + // Paint v2 Slice A (#544) — gradient ramp brushes. + Q_PROPERTY(int colorSource READ colorSource WRITE setColorSource NOTIFY gradientChanged) + Q_PROPERTY(int gradientMode READ gradientMode WRITE setGradientMode NOTIFY gradientChanged) + Q_PROPERTY(QString activeRampName READ activeRampName WRITE setActiveRampName NOTIFY gradientChanged) + Q_PROPERTY(bool useFgBgRamp READ useFgBgRamp WRITE setUseFgBgRamp NOTIFY gradientChanged) + Q_PROPERTY(bool gradientStepped READ gradientStepped WRITE setGradientStepped NOTIFY gradientChanged) + Q_PROPERTY(double rampJitter READ rampJitter WRITE setRampJitter NOTIFY gradientChanged) + Q_PROPERTY(QStringList rampNames READ rampNames NOTIFY gradientChanged) + Q_PROPERTY(QString rampPreviewDataUri READ rampPreviewDataUri NOTIFY gradientChanged) + Q_PROPERTY(QVariantList activeRampStops READ activeRampStops NOTIFY gradientChanged) + // Paint target — texture or vertex. Q_PROPERTY(int paintTarget READ paintTarget WRITE setPaintTarget NOTIFY paintTargetChanged) @@ -120,6 +133,21 @@ class TexturePaintController : public QObject }; Q_ENUM(PaintTarget) + /// Paint v2 Slice A — brush colour source (mirrors BrushEngine::ColorSource). + enum ColorSource { + ColorSolid = 0, + ColorGradient = 1, + }; + Q_ENUM(ColorSource) + + /// Paint v2 Slice A — gradient mapping mode (mirrors BrushEngine::GradientMode). + enum GradientMode { + GradientLinear = 0, + GradientRadial = 1, + GradientAngular = 2, + }; + Q_ENUM(GradientMode) + static TexturePaintController* instance(); static TexturePaintController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); static void kill(); @@ -162,6 +190,42 @@ class TexturePaintController : public QObject void setBrushTool(int tool); /// @} + /// @name Paint v2 Slice A — gradient ramp brushes + /// @{ + int colorSource() const { return static_cast(m_colorSource); } + void setColorSource(int source); + int gradientMode() const { return static_cast(m_gradientMode); } + void setGradientMode(int mode); + QString activeRampName() const { return m_activeRampName; } + void setActiveRampName(const QString& name); + bool useFgBgRamp() const { return m_useFgBgRamp; } + void setUseFgBgRamp(bool on); + bool gradientStepped() const { return m_gradientStepped; } + void setGradientStepped(bool on); + double rampJitter() const { return m_rampJitter; } + void setRampJitter(double j); + QStringList rampNames() const; + QString rampPreviewDataUri() const { return m_rampPreviewUri; } + QVariantList activeRampStops() const; + + /// Persist the currently-edited stops as a named custom ramp. + Q_INVOKABLE bool saveCustomRamp(const QString& name, const QVariantList& stops, + bool stepped = false); + /// Delete a custom ramp by name (bundled presets are not removable). + Q_INVOKABLE bool deleteCustomRamp(const QString& name); + /// Replace the active ramp's stops in-memory (editor live preview). + Q_INVOKABLE void setActiveRampStops(const QVariantList& stops, bool stepped = false); + /// Seed a new ramp by sampling N colours along a UV line on the buffer. + Q_INVOKABLE bool sampleRampFromTexture(double u0, double v0, + double u1, double v1, + int numStops = 5); + /// Open the gradient ramp editor window. + Q_INVOKABLE void openRampEditor(); + Q_INVOKABLE void closeRampEditor(); + Q_PROPERTY(bool rampEditorOpen READ rampEditorOpen NOTIFY rampEditorChanged) + bool rampEditorOpen() const { return m_rampEditorWindow != nullptr; } + /// @} + /// @name Paint target (texture or vertex colors) /// @{ int paintTarget() const { return static_cast(m_target); } @@ -380,6 +444,8 @@ class TexturePaintController : public QObject void uvOverlayChanged(); void smartSelectChanged(); void editorWindowChanged(); + void gradientChanged(); + void rampEditorChanged(); /// 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". @@ -453,6 +519,14 @@ class TexturePaintController : public QObject /// Returns true if any pixel changed. bool applyBrushAtUV(const Ogre::Vector2& uv); + /// Resolve the active GradientRamp (FG/BG quick mode, custom, or bundled). + const GradientRamp::Ramp* resolveActiveRamp() const; + /// Rebuild `m_activeRamp` / preview URI after name or stop edits. + void reloadActiveRamp(); + void refreshRampPreviewUri(); + /// Update stroke path-length tracking used by linear gradients. + void noteStrokeSample(const Ogre::Vector2& uv, bool isStart); + /// Draw the hover ring on the mesh at a given local position + /// normal. Shared between viewport-driven and panel-driven hover. void drawHoverRingAt(const Ogre::Vector3& localPos, @@ -491,6 +565,26 @@ class TexturePaintController : public QObject BrushTool m_tool = ToolPaint; PaintTarget m_target = TargetVertex; + // Paint v2 Slice A — gradient ramp state. + ColorSource m_colorSource = ColorSolid; + GradientMode m_gradientMode = GradientLinear; + QString m_activeRampName = QStringLiteral("Sunset"); + bool m_useFgBgRamp = false; + bool m_gradientStepped = false; + double m_rampJitter = 0.0; ///< 0..1 max random phase offset per stroke. + GradientRamp::Ramp m_activeRamp; + QString m_rampPreviewUri; + QObject* m_rampEditorWindow = nullptr; + + // Per-stroke path tracking for linear gradients (smoothed length). + Ogre::Vector2 m_strokePrevUV = Ogre::Vector2::ZERO; + bool m_strokeHavePrevUV = false; + float m_strokePathLength = 0.0f; + float m_strokePhaseJitter = 0.0f; + /// EMA of the stroke direction unit vector — keeps linear sampling + /// stable when the cursor turns sharply mid-stroke. + Ogre::Vector2 m_strokeDirSmoothed = Ogre::Vector2::ZERO; + /// Track every TUS we rebound to the paint texture so closeSession() /// can restore the originals. We keep the *material name* (not a /// raw pointer) so a destroyed/reloaded material doesn't dangle. diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 46ca92d4f..64d822911 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -39,6 +39,7 @@ ../qml/ProfileGraph.qml ../qml/ThemedComboBox.qml ../qml/TextureEditorWindow.qml + ../qml/GradientRampEditor.qml ../qml/ModeBar.qml diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 49fb59587..f7ef69ce8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -140,6 +140,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureAtlasPacker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PaintBufferImageProvider.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PaintSelectionMask.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GradientRamp.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/BrushEngine.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintBuffer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TexturePaintController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp From 3ec4371f5bcce537fccd40c81c2d851a802a31d9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 20 Jul 2026 16:08:39 -0400 Subject: [PATCH 2/6] fix(paint): use non-native color dialogs to avoid Ogre GL freeze Native QColorDialog blocks against the Ogre render surface on Linux; DontUseNativeDialog matches the existing background-color picker path. Co-authored-by: Cursor --- src/TexturePaintController.cpp | 7 +++++-- src/mainwindow.cpp | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 52143dc8a..e33363ffd 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -2098,10 +2098,13 @@ void TexturePaintController::pickBrushColorInteractive() { auto* em = EditModeController::instance(); if (!em) return; - QApplication::processEvents(); + // DontUseNativeDialog: native pickers deadlock / freeze against Ogre's + // GL surface on Linux (and occasionally macOS). Same fix as the + // background-color picker in MainWindow. QWidget* parent = QApplication::activeWindow(); const QColor picked = QColorDialog::getColor( - em->vertexPaintColor(), parent, QStringLiteral("Brush color")); + em->vertexPaintColor(), parent, QStringLiteral("Brush color"), + QColorDialog::ShowAlphaChannel | QColorDialog::DontUseNativeDialog); if (picked.isValid()) em->setVertexPaintColor(picked); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index d6e068f6d..ea79ff44a 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2307,9 +2307,10 @@ void MainWindow::initToolBar() connect(fg, &QPushButton::clicked, this, [this, syncSwatches]() { SentryReporter::addBreadcrumb("ui.action", "Toolbar: FG color picker opened"); auto* em = EditModeController::instance(); + // DontUseNativeDialog — native pickers freeze against Ogre GL. QColor c = QColorDialog::getColor(em->vertexPaintColor(), this, tr("Foreground color"), - QColorDialog::ShowAlphaChannel); + QColorDialog::ShowAlphaChannel | QColorDialog::DontUseNativeDialog); if (c.isValid()) em->setVertexPaintColor(c); syncSwatches(); @@ -2319,7 +2320,7 @@ void MainWindow::initToolBar() auto* em = EditModeController::instance(); QColor c = QColorDialog::getColor(em->vertexPaintBackgroundColor(), this, tr("Background color"), - QColorDialog::ShowAlphaChannel); + QColorDialog::ShowAlphaChannel | QColorDialog::DontUseNativeDialog); if (c.isValid()) em->setVertexPaintBackgroundColor(c); syncSwatches(); From 226dbc9f188b19e6a7ad2b8620dab223512490c9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 20 Jul 2026 16:30:54 -0400 Subject: [PATCH 3/6] fix(paint): move gradient brush controls into the brush portal Keep Solid/Gradient, mode, ramp picker, FG/BG, stepped, and jitter next to radius/strength/falloff on the paint tool menu; drop the inspector copy. Co-authored-by: Cursor --- qml/PropertiesPanel.qml | 238 +--------------------------------------- src/mainwindow.cpp | 187 ++++++++++++++++++++++++++++++- 2 files changed, 188 insertions(+), 237 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index d57538b30..79d4c7802 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4068,15 +4068,6 @@ Rectangle { // Live hover position in UV space, fed by hoveredUVChanged. property real hoverU: -1 property real hoverV: -1 - // Paint v2 Slice A — gradient ramp brushes (#544). - property int colorSource: TexturePaintController.colorSource - property int gradientMode: TexturePaintController.gradientMode - property string activeRampName: TexturePaintController.activeRampName - property bool useFgBgRamp: TexturePaintController.useFgBgRamp - property bool gradientStepped: TexturePaintController.gradientStepped - property real rampJitter: TexturePaintController.rampJitter - property var rampNames: TexturePaintController.rampNames - property string rampPreviewUri: TexturePaintController.rampPreviewDataUri Connections { target: TexturePaintController @@ -4100,16 +4091,6 @@ Rectangle { function onPaintTargetChanged() { texPaintCol.paintTarget = TexturePaintController.paintTarget } - function onGradientChanged() { - texPaintCol.colorSource = TexturePaintController.colorSource - texPaintCol.gradientMode = TexturePaintController.gradientMode - texPaintCol.activeRampName = TexturePaintController.activeRampName - texPaintCol.useFgBgRamp = TexturePaintController.useFgBgRamp - texPaintCol.gradientStepped = TexturePaintController.gradientStepped - texPaintCol.rampJitter = TexturePaintController.rampJitter - texPaintCol.rampNames = TexturePaintController.rampNames - texPaintCol.rampPreviewUri = TexturePaintController.rampPreviewDataUri - } function onHoveredUVChanged(u, v) { texPaintCol.hoverU = u texPaintCol.hoverV = v @@ -4185,223 +4166,8 @@ Rectangle { // / Wand) live in the left toolbar \u2014 see mainwindow.cpp // \u2014 so the user picks the tool with the same buttons // regardless of which target they're painting. - - // Paint v2 Slice A (#544) — gradient ramp brushes. - Column { - spacing: 6 - width: parent.width - 16 - visible: texPaintCol.paintOn - - Text { - text: "Brush Color" - color: PropertiesPanelController.textColor - font.pixelSize: 11 - font.bold: true - } - - Row { - spacing: 4 - Repeater { - model: [ - { src: 0, label: "Solid" }, - { src: 1, label: "Gradient" } - ] - Rectangle { - width: 72; height: 24; radius: 3 - property bool isActive: texPaintCol.colorSource === modelData.src - color: isActive - ? PropertiesPanelController.highlightColor - : (srcMa.containsMouse - ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) - : PropertiesPanelController.headerColor) - border.color: PropertiesPanelController.borderColor; border.width: 1 - Text { - anchors.centerIn: parent - text: modelData.label - color: PropertiesPanelController.textColor - font.pixelSize: 10 - } - MouseArea { - id: srcMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.colorSource = modelData.src - } - } - } - } - - Column { - spacing: 6 - width: parent.width - visible: texPaintCol.colorSource === 1 - - Row { - spacing: 4 - Repeater { - model: [ - { mode: 0, label: "Linear" }, - { mode: 1, label: "Radial" }, - { mode: 2, label: "Angular" } - ] - Rectangle { - width: 58; height: 22; radius: 3 - property bool isActive: texPaintCol.gradientMode === modelData.mode - color: isActive - ? PropertiesPanelController.highlightColor - : (modeMa.containsMouse - ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) - : PropertiesPanelController.headerColor) - border.color: PropertiesPanelController.borderColor; border.width: 1 - Text { - anchors.centerIn: parent - text: modelData.label - color: PropertiesPanelController.textColor - font.pixelSize: 10 - } - MouseArea { - id: modeMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.gradientMode = modelData.mode - } - } - } - } - - Image { - width: parent.width - height: 18 - source: texPaintCol.rampPreviewUri - fillMode: Image.Stretch - asynchronous: false - cache: false - } - - Row { - spacing: 6 - width: parent.width - Text { - text: "Ramp:" - color: PropertiesPanelController.textColor - font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - width: 40 - } - ThemedComboBox { - id: rampCombo - width: Math.min(140, parent.width - 100) - model: texPaintCol.rampNames - currentIndex: Math.max(0, model.indexOf(texPaintCol.activeRampName)) - onActivated: function(index) { - if (index >= 0 && index < model.length) - TexturePaintController.activeRampName = model[index] - } - } - Rectangle { - width: 48; height: 22; radius: 3 - color: editRampMa.containsMouse - ? Qt.lighter(PropertiesPanelController.panelColor, 1.5) - : PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor; border.width: 1 - anchors.verticalCenter: parent.verticalCenter - Text { - anchors.centerIn: parent - text: "Edit…" - color: PropertiesPanelController.textColor - font.pixelSize: 10 - } - MouseArea { - id: editRampMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.openRampEditor() - } - } - } - - Row { - spacing: 8 - Rectangle { - width: 14; height: 14; radius: 3 - color: texPaintCol.useFgBgRamp - ? PropertiesPanelController.highlightColor - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor; border.width: 1 - anchors.verticalCenter: parent.verticalCenter - Text { - anchors.centerIn: parent - text: texPaintCol.useFgBgRamp ? "✓" : "" - color: "white"; font.pixelSize: 9 - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.useFgBgRamp = !texPaintCol.useFgBgRamp - } - } - Text { - text: "Use FG/BG colours" - color: PropertiesPanelController.textColor - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - Rectangle { - width: 14; height: 14; radius: 3 - color: texPaintCol.gradientStepped - ? PropertiesPanelController.highlightColor - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor; border.width: 1 - anchors.verticalCenter: parent.verticalCenter - Text { - anchors.centerIn: parent - text: texPaintCol.gradientStepped ? "✓" : "" - color: "white"; font.pixelSize: 9 - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: TexturePaintController.gradientStepped = !texPaintCol.gradientStepped - } - } - Text { - text: "Stepped" - color: PropertiesPanelController.textColor - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - } - - Row { - spacing: 6 - width: parent.width - Text { - text: "Jitter" - color: PropertiesPanelController.textColor - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - width: 40 - } - Slider { - id: jitterSlider - width: Math.min(120, parent.width - 80) - from: 0; to: 1; stepSize: 0.01 - value: texPaintCol.rampJitter - onMoved: TexturePaintController.rampJitter = value - } - Text { - text: Math.round(texPaintCol.rampJitter * 100) + "%" - color: PropertiesPanelController.textColor - font.pixelSize: 10 - opacity: 0.7 - anchors.verticalCenter: parent.verticalCenter - } - } - } - } + // Brush colour source / gradient ramps also live in the + // brush portal (paint tool ▾), not here. // Smart-select panel. Visible whenever there's an active // session \u2014 tolerance is always meaningful, and once the diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index ea79ff44a..ef64f94c8 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -153,6 +153,7 @@ #include #include #include +#include #include #include #include @@ -1914,15 +1915,199 @@ void MainWindow::initToolBar() shapeRow->addStretch(); paintLay->addLayout(shapeRow); + // Paint v2 Slice A (#544) — colour source + gradient ramp controls. + // Lives in the brush portal (not the inspector) so brush settings stay + // one click away from the paint tool button. + auto* tpcPaint = TexturePaintController::instance(); + paintLay->addWidget(new QLabel(tr("Color:"), paintSettings)); + auto* colorSrcRow = new QHBoxLayout(); + auto* srcSolid = new QPushButton(tr("Solid"), paintSettings); + auto* srcGradient = new QPushButton(tr("Gradient"), paintSettings); + srcSolid->setCheckable(true); + srcGradient->setCheckable(true); + srcSolid->setAutoExclusive(true); + srcGradient->setAutoExclusive(true); + srcSolid->setFixedHeight(22); + srcGradient->setFixedHeight(22); + colorSrcRow->addWidget(srcSolid); + colorSrcRow->addWidget(srcGradient); + colorSrcRow->addStretch(); + paintLay->addLayout(colorSrcRow); + + auto* gradientBox = new QWidget(paintSettings); + auto* gradLay = new QVBoxLayout(gradientBox); + gradLay->setContentsMargins(0, 0, 0, 0); + gradLay->setSpacing(6); + + auto* modeRow = new QHBoxLayout(); + modeRow->addWidget(new QLabel(tr("Mode:"), gradientBox)); + auto* modeLinear = new QPushButton(tr("Linear"), gradientBox); + auto* modeRadial = new QPushButton(tr("Radial"), gradientBox); + auto* modeAngular = new QPushButton(tr("Angular"), gradientBox); + for (auto* b : {modeLinear, modeRadial, modeAngular}) { + b->setCheckable(true); + b->setAutoExclusive(true); + b->setFixedHeight(22); + modeRow->addWidget(b); + } + modeRow->addStretch(); + gradLay->addLayout(modeRow); + + auto* rampPreview = new QLabel(gradientBox); + rampPreview->setFixedHeight(18); + rampPreview->setMinimumWidth(200); + rampPreview->setScaledContents(true); + rampPreview->setStyleSheet(QStringLiteral("QLabel { border: 1px solid #555; }")); + gradLay->addWidget(rampPreview); + + auto* rampRow = new QHBoxLayout(); + rampRow->addWidget(new QLabel(tr("Ramp:"), gradientBox)); + auto* rampCombo = new QComboBox(gradientBox); + rampCombo->setMinimumWidth(120); + rampRow->addWidget(rampCombo, 1); + auto* editRampBtn = new QPushButton(tr("Edit…"), gradientBox); + editRampBtn->setFixedHeight(22); + rampRow->addWidget(editRampBtn); + gradLay->addLayout(rampRow); + + auto* optRow = new QHBoxLayout(); + auto* fgBgCheck = new QCheckBox(tr("FG/BG"), gradientBox); + fgBgCheck->setToolTip(tr("Build a two-stop ramp from the foreground and background colours")); + auto* steppedCheck = new QCheckBox(tr("Stepped"), gradientBox); + optRow->addWidget(fgBgCheck); + optRow->addWidget(steppedCheck); + optRow->addStretch(); + gradLay->addLayout(optRow); + + auto* jitterLabel = new QLabel(gradientBox); + auto* jitterSlider = new QSlider(Qt::Horizontal, gradientBox); + jitterSlider->setRange(0, 100); + gradLay->addWidget(jitterLabel); + gradLay->addWidget(jitterSlider); + + paintLay->addWidget(gradientBox); + + auto refreshRampPreview = [rampPreview, tpcPaint]() { + const QString uri = tpcPaint->rampPreviewDataUri(); + const int comma = uri.indexOf(QLatin1Char(',')); + if (comma < 0) { + rampPreview->clear(); + return; + } + const QByteArray png = QByteArray::fromBase64(uri.mid(comma + 1).toLatin1()); + QPixmap pm; + if (pm.loadFromData(png, "PNG")) + rampPreview->setPixmap(pm); + else + rampPreview->clear(); + }; + + auto syncGradientUi = [srcSolid, srcGradient, gradientBox, modeLinear, modeRadial, + modeAngular, rampCombo, fgBgCheck, steppedCheck, jitterSlider, + jitterLabel, tpcPaint, refreshRampPreview]() { + const bool isGrad = tpcPaint->colorSource() + == static_cast(TexturePaintController::ColorGradient); + { + QSignalBlocker b1(srcSolid); + QSignalBlocker b2(srcGradient); + srcSolid->setChecked(!isGrad); + srcGradient->setChecked(isGrad); + } + gradientBox->setVisible(isGrad); + if (!isGrad) + return; + + const int mode = tpcPaint->gradientMode(); + { + QSignalBlocker bl(modeLinear); + QSignalBlocker br(modeRadial); + QSignalBlocker ba(modeAngular); + modeLinear->setChecked(mode == static_cast(TexturePaintController::GradientLinear)); + modeRadial->setChecked(mode == static_cast(TexturePaintController::GradientRadial)); + modeAngular->setChecked(mode == static_cast(TexturePaintController::GradientAngular)); + } + + { + QSignalBlocker bc(rampCombo); + const QStringList names = tpcPaint->rampNames(); + if (rampCombo->count() != names.size()) { + rampCombo->clear(); + rampCombo->addItems(names); + } else { + for (int i = 0; i < names.size(); ++i) { + if (rampCombo->itemText(i) != names[i]) { + rampCombo->clear(); + rampCombo->addItems(names); + break; + } + } + } + const int idx = rampCombo->findText(tpcPaint->activeRampName()); + if (idx >= 0) + rampCombo->setCurrentIndex(idx); + } + + { + QSignalBlocker bf(fgBgCheck); + QSignalBlocker bs(steppedCheck); + QSignalBlocker bj(jitterSlider); + fgBgCheck->setChecked(tpcPaint->useFgBgRamp()); + steppedCheck->setChecked(tpcPaint->gradientStepped()); + jitterSlider->setValue(qBound(0, static_cast(qRound(tpcPaint->rampJitter() * 100.0)), 100)); + } + jitterLabel->setText(tr("Jitter: %1%") + .arg(static_cast(qRound(tpcPaint->rampJitter() * 100.0)))); + refreshRampPreview(); + }; + syncGradientUi(); + + connect(srcSolid, &QPushButton::clicked, this, [tpcPaint]() { + tpcPaint->setColorSource(static_cast(TexturePaintController::ColorSolid)); + }); + connect(srcGradient, &QPushButton::clicked, this, [tpcPaint]() { + tpcPaint->setColorSource(static_cast(TexturePaintController::ColorGradient)); + }); + connect(modeLinear, &QPushButton::clicked, this, [tpcPaint]() { + tpcPaint->setGradientMode(static_cast(TexturePaintController::GradientLinear)); + }); + connect(modeRadial, &QPushButton::clicked, this, [tpcPaint]() { + tpcPaint->setGradientMode(static_cast(TexturePaintController::GradientRadial)); + }); + connect(modeAngular, &QPushButton::clicked, this, [tpcPaint]() { + tpcPaint->setGradientMode(static_cast(TexturePaintController::GradientAngular)); + }); + connect(rampCombo, QOverload::of(&QComboBox::activated), this, + [tpcPaint, rampCombo](int index) { + if (index >= 0) + tpcPaint->setActiveRampName(rampCombo->itemText(index)); + }); + connect(editRampBtn, &QPushButton::clicked, this, [tpcPaint]() { + tpcPaint->openRampEditor(); + }); + connect(fgBgCheck, &QCheckBox::toggled, this, [tpcPaint](bool on) { + tpcPaint->setUseFgBgRamp(on); + }); + connect(steppedCheck, &QCheckBox::toggled, this, [tpcPaint](bool on) { + tpcPaint->setGradientStepped(on); + }); + connect(jitterSlider, &QSlider::valueChanged, this, [tpcPaint, jitterLabel](int v) { + tpcPaint->setRampJitter(v / 100.0); + jitterLabel->setText(QObject::tr("Jitter: %1%").arg(v)); + }); + connect(tpcPaint, &TexturePaintController::gradientChanged, this, syncGradientUi); + auto* paintWa = new QWidgetAction(vertexPaintMenu); paintWa->setDefaultWidget(paintSettings); vertexPaintMenu->addAction(paintWa); vertexPaintButton->setMenu(vertexPaintMenu); - connect(vertexPaintMenu, &QMenu::aboutToShow, this, [syncRad, syncStr, syncFalloff]() { + connect(vertexPaintMenu, &QMenu::aboutToShow, this, + [syncRad, syncStr, syncFalloff, syncShape, syncGradientUi]() { syncRad(); syncStr(); syncFalloff(); + syncShape(); + syncGradientUi(); }); // The brush button is now a TOOL SELECTOR, not the paint-mode From e6d488763147f7d8f58cde5429da2dba46000450 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 20 Jul 2026 17:10:44 -0400 Subject: [PATCH 4/6] fix(paint): prevent texture-paint crash on GPU size mismatch When the CPU buffer could not load the model texture (blank fallback), in-place blit still targeted the original GPU texture at a different resolution and could crash the driver. Skip in-place upload on mismatch, drop the original handle on blank sessions, and create the texture session eagerly when switching to texture paint. Co-authored-by: Cursor --- src/TexturePaintBuffer.cpp | 2 +- src/TexturePaintController.cpp | 53 ++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/TexturePaintBuffer.cpp b/src/TexturePaintBuffer.cpp index b16ae7cf5..d1356f152 100644 --- a/src/TexturePaintBuffer.cpp +++ b/src/TexturePaintBuffer.cpp @@ -107,7 +107,7 @@ int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, BrushShape shape) { return paintBrush(uv, radiusUV, - [&color](float, float) { return color; }, + [color](float, float) { return color; }, strength, falloff, shape); } diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index e33363ffd..828996099 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -58,6 +58,7 @@ #include #include +#include #include TexturePaintController* TexturePaintController::s_instance = nullptr; @@ -783,10 +784,10 @@ void TexturePaintController::openRampEditor() [](QQmlEngine* e, QJSEngine*) -> QObject* { return TexturePaintController::qmlInstance(e, nullptr); }); - bool handled = false; + auto handled = std::make_shared(false); connect(engine, &QQmlApplicationEngine::objectCreated, this, - [this, engine, &handled](QObject* obj, const QUrl&) { - handled = true; + [this, engine, handled](QObject* obj, const QUrl&) { + *handled = true; if (!obj) { engine->deleteLater(); return; @@ -807,7 +808,7 @@ void TexturePaintController::openRampEditor() emit rampEditorChanged(); }, Qt::DirectConnection); engine->load(QUrl(QStringLiteral("qrc:/PropertiesPanel/GradientRampEditor.qml"))); - if (!handled) + if (!*handled) engine->deleteLater(); SentryReporter::addBreadcrumb("paint.brush.gradient", "ramp editor opened"); } @@ -846,6 +847,18 @@ void TexturePaintController::setPaintTarget(int target) m_target = t; SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Paint target = %1").arg(target == TargetVertex ? "vertex" : "texture")); + // Eagerly create the texture session when switching to texture + // paint while the tool is already active — otherwise the first + // stroke races session setup against GPU flush/rebind. + if (m_target == TargetTexture && m_paintEnabled) { + if (auto* entity = activeEntity()) { + ensureEditableMesh(entity); + if (!hasActiveSession()) { + const int res = m_buffer.width() > 0 ? m_buffer.width() : 1024; + ensurePaintableTexture(res); + } + } + } emit paintTargetChanged(); emit sessionChanged(); emit smartSelectChanged(); // the mask UI is texture-only @@ -1196,6 +1209,11 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) m_buffer.resize(res, res); m_buffer.clear(Ogre::ColourValue::White); m_buffer.clearDirty(); + // The CPU buffer is a new resolution — it won't match the model's + // bound GPU texture. In-place blit with mismatched sizes crashes + // some GL/Metal drivers (OOB HardwarePixelBuffer writes). + m_originalTexture.reset(); + m_useOriginalTexture = false; SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: starting from blank %1×%1 (existing tex='%2', err='%3')") .arg(res).arg(existingTex).arg(loadError)); @@ -1408,6 +1426,23 @@ void TexturePaintController::doFlushDirtyToOgre() // Skip the in-place path once we've rebound the model to the // manual paint texture — the original is no longer what the // renderer samples, so blitting to it would be invisible. + if (m_boundSlots.empty() && m_originalTexture) { + try { + const int bufW = m_buffer.width(); + const int bufH = m_buffer.height(); + const int texW = static_cast(m_originalTexture->getWidth()); + const int texH = static_cast(m_originalTexture->getHeight()); + if (bufW <= 0 || bufH <= 0 || texW != bufW || texH != bufH) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place skipped — size mismatch " + "buffer=%1×%2 tex=%3×%4") + .arg(bufW).arg(bufH).arg(texW).arg(texH)); + m_originalTexture.reset(); + } + } catch (...) { + m_originalTexture.reset(); + } + } if (m_boundSlots.empty() && m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); @@ -1822,10 +1857,11 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) const Ogre::ColourValue paint(sampled.r, sampled.g, sampled.b, sampled.a); return m_buffer.paintBrush(uv, radius, paint, strength, falloff, shape) > 0; } + const BrushEngine::SampleParams paramsCopy = params; return m_buffer.paintBrush( uv, radius, - [¶ms](float dx, float dy) { - BrushEngine::SampleParams local = params; + [paramsCopy](float dx, float dy) { + BrushEngine::SampleParams local = paramsCopy; local.dx = dx; local.dy = dy; const auto s = BrushEngine::sampleColor(local); @@ -1981,6 +2017,11 @@ void TexturePaintController::endStroke() .arg(m_smartSelectTolerance, 0, 'f', 3)); return; } + if (m_target == TargetVertex) { + m_strokePreSnapshot.clear(); + SentryReporter::addBreadcrumb("ui.action", "Vertex paint stroke end"); + 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()) From dc49931f162525af6dc1059ee78a6060662b803d Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 20 Jul 2026 19:15:26 -0400 Subject: [PATCH 5/6] fix(paint): show strokes on model and reduce upload stutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebind the viewport to the paint texture at session start (not on first stroke) and stop requiring direct entity selection — node/sub-entity picks were skipping rebind so pixels only updated in the 2D preview. Use dirty-rect GPU uploads on Linux/Windows and batch flushes at 33ms. Co-authored-by: Cursor --- src/TexturePaintController.cpp | 210 +++++++++++++++++++-------------- src/TexturePaintController.h | 6 + 2 files changed, 130 insertions(+), 86 deletions(-) diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 828996099..0f65a58ba 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -217,6 +217,41 @@ bool loadPaintBufferFromDiskPath(TexturePaintBuffer& buffer, const QString& path return copyQImageToPaintBuffer(buffer, QImage(path)); } +bool isPlainUncompressedFormat(Ogre::PixelFormat fmt) +{ + return fmt == Ogre::PF_BYTE_RGBA + || fmt == Ogre::PF_BYTE_RGB + || fmt == Ogre::PF_BYTE_BGRA + || fmt == Ogre::PF_BYTE_BGR + || fmt == Ogre::PF_A8R8G8B8 + || fmt == Ogre::PF_R8G8B8A8 + || fmt == Ogre::PF_A8B8G8R8 + || fmt == Ogre::PF_X8R8G8B8 + || fmt == Ogre::PF_R8G8B8; +} + +/// True when we can blit dirty rects straight into the model's bound GPU +/// texture without rebinding materials. +bool canWriteOriginalTextureInPlace(const Ogre::TexturePtr& tex, + int bufferW, + int bufferH) +{ + if (!tex || bufferW <= 0 || bufferH <= 0) + return false; + try { + if (static_cast(tex->getWidth()) != bufferW + || static_cast(tex->getHeight()) != bufferH) + return false; + if (!isPlainUncompressedFormat(tex->getFormat())) + return false; + if (!tex->getBuffer()) + return false; + } catch (...) { + return false; + } + return true; +} + // CPU-side sources first — same order as MaterialEditorQML::previewUrlFromOgreTexture. // GPU readback (convertToImage / blitToMemory) is unreliable for imported FBX textures. bool loadPaintBufferFromNonGpuSources(TexturePaintBuffer& buffer, @@ -1209,11 +1244,12 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) m_buffer.resize(res, res); m_buffer.clear(Ogre::ColourValue::White); m_buffer.clearDirty(); - // The CPU buffer is a new resolution — it won't match the model's - // bound GPU texture. In-place blit with mismatched sizes crashes - // some GL/Metal drivers (OOB HardwarePixelBuffer writes). + // CPU buffer size won't match the model's bound GPU texture. In-place + // blit with mismatched sizes crashes some GL/Metal drivers (OOB + // HardwarePixelBuffer writes). m_originalTexture.reset(); m_useOriginalTexture = false; + m_forceManualPaintTexture = true; SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: starting from blank %1×%1 (existing tex='%2', err='%3')") .arg(res).arg(existingTex).arg(loadError)); @@ -1246,6 +1282,20 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) return false; } + // Decide up-front whether the viewport can keep sampling the original + // texture (in-place GPU writes) or must be rebound to our manual + // paint texture. Rebind is scheduled here — before the first stroke — + // so pixels uploaded during painting are visible on the model. + if (!m_forceManualPaintTexture) { + m_forceManualPaintTexture = + !canWriteOriginalTextureInPlace(m_originalTexture, + m_buffer.width(), m_buffer.height()); + } + if (m_forceManualPaintTexture) { + m_originalTexture.reset(); + scheduleRebindToPaintTexture(entity); + } + SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint session: %1×%2 on %3 (existing tex: %4)") .arg(m_buffer.width()).arg(m_buffer.height()) @@ -1318,10 +1368,12 @@ bool TexturePaintController::createOgreTextureFromBuffer(Ogre::Entity* entity, void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* entity) { if (!entity || m_textureName.isEmpty()) return; - // Capture the original texture name from the user's active slot - // (or the first diffuse-like TUS as fallback). + // Use the texture name captured at session-create time — the live + // TUS may already point at a prior paint texture if a rebind failed. auto* tu = findOrCreateActiveTextureUnit(entity); - const std::string originalTexName = tu ? tu->getTextureName() : ""; + const std::string originalTexName = !m_originalTextureName.isEmpty() + ? m_originalTextureName.toStdString() + : (tu ? tu->getTextureName() : std::string{}); const std::string texName = m_textureName.toStdString(); SentryReporter::addBreadcrumb("ui.action", @@ -1386,6 +1438,28 @@ void TexturePaintController::rebindEntityDiffuseToPaintTexture(Ogre::Entity* ent } } +void TexturePaintController::scheduleRebindToPaintTexture(Ogre::Entity* entity) +{ + if (!entity || m_textureName.isEmpty() || !m_ogreTexture) + return; + if (!m_boundSlots.empty()) + return; + if (m_rebindScheduled) + return; + m_rebindScheduled = true; + QTimer::singleShot(0, this, [this, entity]() { + m_rebindScheduled = false; + if (m_sessionEntity != entity || !m_boundSlots.empty()) + return; + if (!m_ogreTexture || m_textureName.isEmpty()) + return; + rebindEntityDiffuseToPaintTexture(entity); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: viewport rebound (%1 TUSes)") + .arg(m_boundSlots.size())); + }); +} + void TexturePaintController::flushDirtyToOgre() { const auto& dirty = m_buffer.dirtyRect(); @@ -1398,7 +1472,7 @@ void TexturePaintController::flushDirtyToOgre() // no pixels are lost — just batched. if (m_gpuFlushScheduled) return; m_gpuFlushScheduled = true; - QTimer::singleShot(16, this, [this]() { + QTimer::singleShot(33, this, [this]() { m_gpuFlushScheduled = false; if (m_buffer.dirtyRect().empty()) return; doFlushDirtyToOgre(); @@ -1413,9 +1487,7 @@ void TexturePaintController::doFlushDirtyToOgre() // Preferred path: paint directly INTO the model's original // texture. No rebind needed — the existing material binding // continues to work, and the paint just modifies pixels in place. - // Re-resolve the handle each flush so a reloaded material - // doesn't dangle. - if (!m_originalTextureName.isEmpty()) { + if (!m_forceManualPaintTexture && !m_originalTextureName.isEmpty()) { try { auto fresh = Ogre::TextureManager::getSingleton().getByName( m_originalTextureName.toStdString(), @@ -1426,56 +1498,20 @@ void TexturePaintController::doFlushDirtyToOgre() // Skip the in-place path once we've rebound the model to the // manual paint texture — the original is no longer what the // renderer samples, so blitting to it would be invisible. - if (m_boundSlots.empty() && m_originalTexture) { - try { - const int bufW = m_buffer.width(); - const int bufH = m_buffer.height(); - const int texW = static_cast(m_originalTexture->getWidth()); - const int texH = static_cast(m_originalTexture->getHeight()); - if (bufW <= 0 || bufH <= 0 || texW != bufW || texH != bufH) { - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: in-place skipped — size mismatch " - "buffer=%1×%2 tex=%3×%4") - .arg(bufW).arg(bufH).arg(texW).arg(texH)); - m_originalTexture.reset(); - } - } catch (...) { - m_originalTexture.reset(); - } - } - if (m_boundSlots.empty() && m_originalTexture) { + if (!m_forceManualPaintTexture && m_boundSlots.empty() && m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); if (pixbuf) { const int W = m_buffer.width(); const int rectW = dirty.width(); const int rectH = dirty.height(); - // Blit in the texture's NATIVE format. blitFromMemory - // does internal conversion if the source PixelBox - // format differs, but on some Metal backends the - // conversion silently produces no upload — so we - // convert our RGBA8 source to the texture's format - // up front and submit it raw. const Ogre::PixelFormat dstFmt = m_originalTexture->getFormat(); - // Only handle plain uncompressed formats in-place. - // Compressed formats (DXT/BC) need real CPU encoders - // which can crash bulkPixelConversion on Metal. - const bool plainFmt = - dstFmt == Ogre::PF_BYTE_RGBA - || dstFmt == Ogre::PF_BYTE_RGB - || dstFmt == Ogre::PF_BYTE_BGRA - || dstFmt == Ogre::PF_BYTE_BGR - || dstFmt == Ogre::PF_A8R8G8B8 - || dstFmt == Ogre::PF_R8G8B8A8 - || dstFmt == Ogre::PF_A8B8G8R8 - || dstFmt == Ogre::PF_X8R8G8B8 - || dstFmt == Ogre::PF_R8G8B8; - if (!plainFmt) { + if (!isPlainUncompressedFormat(dstFmt)) { SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: in-place skipped — compressed format %1") .arg(static_cast(dstFmt))); m_originalTexture.reset(); - // Fall through to manual-texture path. + m_forceManualPaintTexture = true; } else { std::vector srcRow(static_cast(rectW) * static_cast(rectH) * 4u); const auto& src = m_buffer.data(); @@ -1522,56 +1558,57 @@ void TexturePaintController::doFlushDirtyToOgre() SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: in-place blit FAILED → fallback to manual texture (%1)") .arg(QString::fromStdString(e.getDescription()))); - // Fall through to manual-texture path. Clear the - // original-texture handle so we don't keep trying. m_originalTexture.reset(); + m_forceManualPaintTexture = true; } catch (...) { m_originalTexture.reset(); + m_forceManualPaintTexture = true; } } if (!m_ogreTexture) return; - // Fallback path: the model's diffuse TUSes are rebound to our - // manual paint texture on first flush. Defer the rebind via a - // singleShot timer so material compile/reload doesn't run on - // the same call stack as the mouse-move event — that was - // racing against the active stroke and crashing. - if (m_boundSlots.empty() && m_paintMeshEntity && !m_rebindScheduled) { - m_rebindScheduled = true; - Ogre::Entity* ent = m_paintMeshEntity; - QTimer::singleShot(0, this, [this, ent]() { - m_rebindScheduled = false; - // The captured Ogre::Entity* could have been destroyed by the - // time this fires (entity removed, mesh reimport, selection - // change closing the session). Validate it's still both the - // active paint target AND a live entity SelectionSet knows - // about before dereferencing. - if (m_paintMeshEntity != ent || !m_boundSlots.empty()) return; - auto* sel = SelectionSet::getSingleton(); - if (!sel || !sel->contains(ent)) return; - rebindEntityDiffuseToPaintTexture(ent); - }); - } + // Fallback path: upload into our manual paint texture. Rebind once + // (scheduled at session create) so the viewport samples it. + if (m_boundSlots.empty() && m_paintMeshEntity) + scheduleRebindToPaintTexture(m_paintMeshEntity); + try { auto buf = m_ogreTexture->getBuffer(); if (!buf) return; const int W = m_buffer.width(); const int H = m_buffer.height(); - // Upload the FULL buffer each flush. Sub-rect blits via - // blitFromMemory are unreliable on macOS Metal — sometimes - // they don't end up visible. Full-frame upload is heavier - // but always lands. (At 1024² that's ~4 MB per stroke; the - // debounce on previewDataUri amortises CPU cost separately.) - // - // Copy m_buffer.data() into a transient std::vector first. - // Ogre's Metal backend can queue the blit asynchronously; - // pointing the PixelBox at our live buffer caused - // use-after-free crashes when the next stroke modified - // pixels before the GPU finished reading. - std::vector uploadCopy(m_buffer.data().begin(), m_buffer.data().end()); - Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, uploadCopy.data()); - buf->blitFromMemory(pb); + const int rectW = dirty.width(); + const int rectH = dirty.height(); + const bool partialDirty = + rectW > 0 && rectH > 0 + && static_cast(rectW) * static_cast(rectH) + < static_cast(W) * static_cast(H); +#if defined(Q_OS_MACOS) + // Sub-rect blits are unreliable on Metal — upload the full frame. + const bool usePartialUpload = false; +#else + const bool usePartialUpload = partialDirty; +#endif + if (usePartialUpload) { + std::vector uploadCopy(static_cast(rectW) * static_cast(rectH) * 4u); + const auto& src = m_buffer.data(); + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) + + static_cast(dirty.x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(uploadCopy.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); + } + Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, uploadCopy.data()); + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + buf->blitFromMemory(pb, dst); + } else { + // Full-frame upload (macOS Metal, or dirty ≈ whole buffer). + std::vector uploadCopy(m_buffer.data().begin(), m_buffer.data().end()); + Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, uploadCopy.data()); + buf->blitFromMemory(pb); + } } catch (const Ogre::Exception& e) { SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: blit failed — %1") @@ -2356,6 +2393,7 @@ void TexturePaintController::closeSession() m_originalTexture.reset(); m_originalTextureName.clear(); m_useOriginalTexture = false; + m_forceManualPaintTexture = false; m_loggedInPlaceBlit = false; m_rebindScheduled = false; m_gpuFlushScheduled = false; diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index df60baa2e..9ad849ff8 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -503,6 +503,9 @@ class TexturePaintController : public QObject void flushDirtyToOgre(); /// The synchronous GPU upload. Called from the debounce timer. void doFlushDirtyToOgre(); + /// Point the model's diffuse TUSes at `m_ogreTexture` on the next + /// event-loop tick (mat compile/reload must not run mid-stroke). + void scheduleRebindToPaintTexture(Ogre::Entity* entity); /// Regenerate `m_previewUri` from the buffer (PNG, base64). Emits /// previewChanged when the URI actually changed. @@ -550,6 +553,9 @@ class TexturePaintController : public QObject Ogre::TexturePtr m_originalTexture; QString m_originalTextureName; bool m_useOriginalTexture = false; + /// When true, skip in-place blit into `m_originalTexture` and always + /// upload to `m_ogreTexture` (rebind required for the viewport). + bool m_forceManualPaintTexture = false; bool m_loggedInPlaceBlit = false; bool m_rebindScheduled = false; /// Debounce flag for the GPU upload. We accumulate dirty pixels From 21a07cdb32a9a48257c91cb52b82e184932b84b1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 23 Jul 2026 10:23:44 -0400 Subject: [PATCH 6/6] fix(paint): polish gradient brush UX and address PR review Improve stroke visibility with tiled GPU uploads and hit-cache sampling, restore solid as the default colour source, and fix ramp persistence: custom ramps win over bundled presets, stepped mode loads from saved JSON, FG/BG-only reload on colour changes, hash-suffixed custom filenames, and isolated color-source button groups with Sentry breadcrumbs. Co-authored-by: Cursor --- qml/GradientRampEditor.qml | 295 +++++++--- src/AppSettingsKeys.h | 7 + src/GradientRamp.cpp | 31 +- src/TexturePaintBuffer.cpp | 19 +- src/TexturePaintController.cpp | 979 +++++++++++++++++++++++++-------- src/TexturePaintController.h | 77 ++- src/mainwindow.cpp | 113 +++- 7 files changed, 1198 insertions(+), 323 deletions(-) diff --git a/qml/GradientRampEditor.qml b/qml/GradientRampEditor.qml index 29134d5e3..b22651351 100644 --- a/qml/GradientRampEditor.qml +++ b/qml/GradientRampEditor.qml @@ -1,26 +1,20 @@ import QtQuick import QtQuick.Window -import QtQuick.Controls import PropertiesPanel 1.0 /** * Paint v2 Slice A (#544) — custom gradient ramp editor. - * - * Gradient strip with draggable colour stops. Stops can be added - * (double-click the strip), deleted (Delete / Backspace), repositioned - * (drag), and recoloured (click a stop → colour dialog via the - * "Recolour" button). Save writes a named JSON ramp into - * `/paint/ramps/`. */ Window { id: rampWin title: "Gradient Ramp Editor" - width: 480 - height: 280 - minimumWidth: 360 - minimumHeight: 220 - color: "#1e1e1e" - flags: Qt.Window | Qt.WindowCloseButtonHint + width: 520 + height: 320 + minimumWidth: 420 + minimumHeight: 260 + flags: Qt.Dialog | Qt.WindowCloseButtonHint + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor property var stops: TexturePaintController.activeRampStops property bool stepped: TexturePaintController.gradientStepped @@ -36,16 +30,19 @@ Window { rampWin.stepped = TexturePaintController.gradientStepped if (!TexturePaintController.useFgBgRamp) rampWin.rampName = TexturePaintController.activeRampName + rampCanvas.requestPaint() } } + onStopsChanged: rampCanvas.requestPaint() + onSteppedChanged: rampCanvas.requestPaint() + function pushStops() { TexturePaintController.setActiveRampStops(stops, stepped) } - function stopColor(i) { - if (i < 0 || i >= stops.length) return "#888888" - const s = stops[i] + function stopColorFromObj(s) { + if (!s) return "#888888" const r = Math.round((s.r || 0) * 255) const g = Math.round((s.g || 0) * 255) const b = Math.round((s.b || 0) * 255) @@ -54,17 +51,157 @@ Window { + b.toString(16).padStart(2, "0") } + function stopColor(i) { + if (i < 0 || i >= stops.length) return "#888888" + return stopColorFromObj(stops[i]) + } + + function sampleRampAt(stopsList, t, isStepped) { + if (!stopsList || stopsList.length === 0) return "#808080" + const sorted = stopsList.slice().sort(function(a, b) { return a.t - b.t }) + if (sorted.length === 1) return stopColorFromObj(sorted[0]) + t = Math.max(0, Math.min(1, t)) + if (t <= sorted[0].t) return stopColorFromObj(sorted[0]) + if (t >= sorted[sorted.length - 1].t) return stopColorFromObj(sorted[sorted.length - 1]) + + if (isStepped) { + for (let i = sorted.length - 1; i >= 0; --i) { + if (t >= sorted[i].t) return stopColorFromObj(sorted[i]) + } + return stopColorFromObj(sorted[0]) + } + + for (let i = 0; i < sorted.length - 1; ++i) { + const a = sorted[i] + const b = sorted[i + 1] + if (t >= a.t && t <= b.t) { + const span = b.t - a.t + const u = span < 1e-6 ? 0.0 : (t - a.t) / span + const r = Math.round(((a.r || 0) + ((b.r || 0) - (a.r || 0)) * u) * 255) + const g = Math.round(((a.g || 0) + ((b.g || 0) - (a.g || 0)) * u) * 255) + const bb = Math.round(((a.b || 0) + ((b.b || 0) - (a.b || 0)) * u) * 255) + return "#" + r.toString(16).padStart(2, "0") + + g.toString(16).padStart(2, "0") + + bb.toString(16).padStart(2, "0") + } + } + return stopColorFromObj(sorted[sorted.length - 1]) + } + + component InspectorLabel: Text { + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + + component InspectorButton: Rectangle { + id: btn + property string label: "" + property bool buttonEnabled: true + signal clicked() + implicitWidth: labelText.implicitWidth + 20 + implicitHeight: 24 + radius: 3 + color: btnMa.containsMouse && buttonEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + opacity: buttonEnabled ? 1.0 : 0.45 + Text { + id: labelText + anchors.centerIn: parent + text: btn.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: btnMa + anchors.fill: parent + hoverEnabled: true + enabled: btn.buttonEnabled + cursorShape: btn.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: btn.clicked() + } + } + + component InspectorTextField: Rectangle { + id: tfRoot + property alias text: input.text + signal editedText(string newText) + implicitHeight: 24 + implicitWidth: 160 + color: PropertiesPanelController.inputColor + border.color: input.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: input.activeFocus ? 2 : 1 + radius: 3 + TextInput { + id: input + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + clip: true + onTextEdited: tfRoot.editedText(text) + } + } + + component InspectorCheckBox: Item { + id: cbRoot + property string label: "" + property bool checked: false + signal toggled(bool on) + implicitWidth: labelRow.implicitWidth + implicitHeight: 22 + Row { + id: labelRow + spacing: 6 + anchors.verticalCenter: parent.verticalCenter + Rectangle { + width: 16; height: 16; radius: 2 + anchors.verticalCenter: parent.verticalCenter + color: cbRoot.checked + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + visible: cbRoot.checked + text: "✓" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + } + } + InspectorLabel { + visible: cbRoot.label.length > 0 + anchors.verticalCenter: parent.verticalCenter + text: cbRoot.label + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: cbRoot.toggled(!cbRoot.checked) + } + } + Column { anchors.fill: parent anchors.margins: 12 spacing: 10 - Text { + InspectorLabel { text: "Drag stops along the ramp. Double-click the strip to add a stop." - color: "#aaaaaa" - font.pixelSize: 11 width: parent.width wrapMode: Text.Wrap + opacity: 0.75 } Item { @@ -72,25 +209,43 @@ Window { width: parent.width height: 48 - Image { - id: stripImg + Rectangle { + id: stripFrame anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top height: 28 - source: TexturePaintController.rampPreviewDataUri - fillMode: Image.Stretch - asynchronous: false - cache: false + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + clip: true + + Canvas { + id: rampCanvas + anchors.fill: parent + anchors.margins: 1 + renderTarget: Canvas.FramebufferObject + renderStrategy: Canvas.Cooperative + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + const w = Math.max(1, Math.floor(width)) + for (let x = 0; x < w; ++x) { + const t = w === 1 ? 0.0 : x / (w - 1) + ctx.fillStyle = sampleRampAt(stops, t, stepped) + ctx.fillRect(x, 0, 1, height) + } + } + Component.onCompleted: requestPaint() + } MouseArea { anchors.fill: parent onDoubleClicked: function(mouse) { const t = Math.max(0, Math.min(1, mouse.x / Math.max(1, width))) - // Sample current ramp colour at t for the new stop. let r = 0.5, g = 0.5, b = 0.5, a = 1.0 if (stops.length >= 2) { - // Nearest-neighbour seed from neighbouring stops. let best = stops[0] let bestD = Math.abs(best.t - t) for (let i = 1; i < stops.length; ++i) { @@ -113,32 +268,46 @@ Window { Repeater { model: stops - Rectangle { + delegate: Rectangle { + id: stopHandle + required property var modelData + required property int index width: 12; height: 18; radius: 2 y: 30 x: modelData.t * stripArea.width - width / 2 color: stopColor(index) - border.color: index === selectedStop ? "#ffffff" : "#666666" + border.color: index === selectedStop + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor border.width: index === selectedStop ? 2 : 1 MouseArea { + id: dragArea anchors.fill: parent - drag.target: parent + drag.target: stopHandle drag.axis: Drag.XAxis - drag.minimumX: -width / 2 - drag.maximumX: stripArea.width - width / 2 + drag.minimumX: -stopHandle.width / 2 + drag.maximumX: stripArea.width - stopHandle.width / 2 cursorShape: Qt.SizeHorCursor - onPressed: selectedStop = index + onPressed: { + selectedStop = index + stopHandle.z = 1 + } onReleased: { - const next = stops.slice() + stopHandle.z = 0 const t = Math.max(0, Math.min(1, - (parent.x + parent.width / 2) / Math.max(1, stripArea.width))) + (stopHandle.x + stopHandle.width / 2) / Math.max(1, stripArea.width))) + const next = stops.slice() + const cur = next[index] next[index] = { - t: t, r: modelData.r, g: modelData.g, - b: modelData.b, a: modelData.a + t: t, + r: cur.r, g: cur.g, b: cur.b, a: cur.a } next.sort(function(a, b) { return a.t - b.t }) stops = next + selectedStop = next.findIndex(function(s) { + return Math.abs(s.t - t) < 1e-4 + }) pushStops() } } @@ -148,42 +317,36 @@ Window { Row { spacing: 8 - Text { + width: parent.width + InspectorLabel { text: "Name:" - color: "#dddddd" - font.pixelSize: 11 + width: 40 anchors.verticalCenter: parent.verticalCenter } - TextField { + InspectorTextField { id: nameField - width: 160 + width: Math.max(120, parent.width - 40 - 8 - steppedBox.implicitWidth - 8) text: rampWin.rampName - color: "#eeeeee" - background: Rectangle { - color: "#2a2a2a" - border.color: "#555555" - radius: 3 - } - onEditingFinished: rampWin.rampName = text + onEditedText: function(t) { rampWin.rampName = t } } - CheckBox { - text: "Stepped" + InspectorCheckBox { + id: steppedBox + label: "Stepped" checked: rampWin.stepped - onToggled: { - rampWin.stepped = checked + onToggled: function(on) { + rampWin.stepped = on pushStops() } } } - Row { + Flow { + width: parent.width spacing: 8 - Button { - text: "Recolour Stop" - enabled: selectedStop >= 0 && selectedStop < stops.length + InspectorButton { + label: "Recolour Stop" + buttonEnabled: selectedStop >= 0 && selectedStop < stops.length onClicked: { - // Use FG colour as a quick recolour source — the - // toolbar FG picker is the project's colour dialog. const fg = TexturePaintController.texturePaintColor const next = stops.slice() const cur = next[selectedStop] @@ -198,9 +361,9 @@ Window { pushStops() } } - Button { - text: "Delete Stop" - enabled: stops.length > 2 && selectedStop >= 0 && selectedStop < stops.length + InspectorButton { + label: "Delete Stop" + buttonEnabled: stops.length > 2 && selectedStop >= 0 && selectedStop < stops.length onClicked: { const next = stops.slice() next.splice(selectedStop, 1) @@ -209,8 +372,8 @@ Window { pushStops() } } - Button { - text: "Save Ramp" + InspectorButton { + label: "Save Ramp" onClicked: { const name = nameField.text.trim().length > 0 ? nameField.text.trim() : "Custom" @@ -218,8 +381,8 @@ Window { rampWin.rampName = name } } - Button { - text: "Close" + InspectorButton { + label: "Close" onClicked: rampWin.close() } } diff --git a/src/AppSettingsKeys.h b/src/AppSettingsKeys.h index 7fa00a91e..dce4bfa9b 100644 --- a/src/AppSettingsKeys.h +++ b/src/AppSettingsKeys.h @@ -288,6 +288,13 @@ inline const QString& paintGradientRampName() return k; } +/** @brief One-time migration: solid brush default for Paint v2 (#544). */ +inline const QString& paintColorSourceSolidDefaultApplied() +{ + static const QString k(QStringLiteral("Paint/v544SolidDefaultApplied")); + return k; +} + /** @brief Paint brush colour source: 0=solid, 1=gradient. */ inline const QString& paintColorSource() { diff --git a/src/GradientRamp.cpp b/src/GradientRamp.cpp index 97ab3e389..629f40da2 100644 --- a/src/GradientRamp.cpp +++ b/src/GradientRamp.cpp @@ -11,7 +11,9 @@ #include #include +#include #include +#include namespace GradientRamp { namespace { @@ -257,12 +259,21 @@ std::string safeFileStem(const std::string& name) return out; } +std::string customRampFileStem(const std::string& name) +{ + const std::string base = safeFileStem(name.empty() ? "custom" : name); + const auto h = static_cast(std::hash{}(name)); + char suffix[10]; + std::snprintf(suffix, sizeof(suffix), "_%08x", h); + return base + suffix; +} + std::string saveCustom(const Ramp& ramp) { const std::string dir = rampsDirectory(); if (dir.empty() || !ramp.isValid()) return {}; - const std::string stem = safeFileStem(ramp.name.empty() ? "custom" : ramp.name); + const std::string stem = customRampFileStem(ramp.name.empty() ? "custom" : ramp.name); const QString path = QString::fromStdString(dir) + QLatin1Char('/') + QString::fromStdString(stem) + QStringLiteral(".json"); @@ -303,8 +314,22 @@ bool deleteCustom(const std::string& name) return false; const QString path = QString::fromStdString(dir) + QLatin1Char('/') - + QString::fromStdString(safeFileStem(name)) + QStringLiteral(".json"); - return QFile::remove(path); + + QString::fromStdString(customRampFileStem(name)) + QStringLiteral(".json"); + if (QFile::remove(path)) + return true; + // Legacy stems (pre-hash) or hand-edited files: match by ramp.name in JSON. + const QDir qdir(QString::fromStdString(dir)); + const QStringList files = + qdir.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name); + for (const QString& file : files) { + QFile f(qdir.filePath(file)); + if (!f.open(QIODevice::ReadOnly)) + continue; + Ramp ramp; + if (fromJson(f.readAll().toStdString(), ramp) && ramp.name == name) + return QFile::remove(qdir.filePath(file)); + } + return false; } } // namespace GradientRamp diff --git a/src/TexturePaintBuffer.cpp b/src/TexturePaintBuffer.cpp index d1356f152..36fd801fa 100644 --- a/src/TexturePaintBuffer.cpp +++ b/src/TexturePaintBuffer.cpp @@ -169,14 +169,17 @@ int TexturePaintBuffer::paintBrush(const Ogre::Vector2& uv, if (blend <= 0.0f) continue; const Ogre::ColourValue color = colorAt(dx, dy); const size_t off = (static_cast(y) * static_cast(m_width) + static_cast(x)) * 4u; - const float prevR = byteToFloat(m_pixels[off + 0]); - const float prevG = byteToFloat(m_pixels[off + 1]); - const float prevB = byteToFloat(m_pixels[off + 2]); - const float prevA = byteToFloat(m_pixels[off + 3]); - m_pixels[off + 0] = floatToByte(prevR + (color.r - prevR) * blend); - m_pixels[off + 1] = floatToByte(prevG + (color.g - prevG) * blend); - m_pixels[off + 2] = floatToByte(prevB + (color.b - prevB) * blend); - m_pixels[off + 3] = floatToByte(prevA + (color.a - prevA) * blend); + const int blend256 = static_cast(std::lround(blend * 256.0f)); + if (blend256 <= 0) continue; + const int inv256 = 256 - blend256; + const int cr = static_cast(std::lround(color.r * 255.0f)); + const int cg = static_cast(std::lround(color.g * 255.0f)); + const int cb = static_cast(std::lround(color.b * 255.0f)); + const int ca = static_cast(std::lround(color.a * 255.0f)); + m_pixels[off + 0] = static_cast((m_pixels[off + 0] * inv256 + cr * blend256) >> 8); + m_pixels[off + 1] = static_cast((m_pixels[off + 1] * inv256 + cg * blend256) >> 8); + m_pixels[off + 2] = static_cast((m_pixels[off + 2] * inv256 + cb * blend256) >> 8); + m_pixels[off + 3] = static_cast((m_pixels[off + 3] * inv256 + ca * blend256) >> 8); ++affected; touchedX0 = std::min(touchedX0, x); touchedY0 = std::min(touchedY0, y); diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 0f65a58ba..d0baded43 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -13,6 +13,7 @@ #include "UndoManager.h" #include "VertexColorBaker.h" #include "EmbeddedTextureCache.h" +#include "PropertiesPanelController.h" #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -252,6 +254,20 @@ bool canWriteOriginalTextureInPlace(const Ogre::TexturePtr& tex, return true; } +/// Pixel payload copied on a worker thread, consumed on the main thread. +struct GpuUploadPacket { + std::vector rgba; + int x0 = 0; + int y0 = 0; + int x1 = 0; + int y1 = 0; + int bufferW = 0; + int bufferH = 0; + bool partial = false; +}; + +constexpr int kPaintUploadTilePx = 64; + // CPU-side sources first — same order as MaterialEditorQML::previewUrlFromOgreTexture. // GPU readback (convertToImage / blitToMemory) is unreliable for imported FBX textures. bool loadPaintBufferFromNonGpuSources(TexturePaintBuffer& buffer, @@ -333,7 +349,8 @@ TexturePaintController::TexturePaintController(QObject* parent) // FG/BG changes should refresh the FG/BG quick-ramp preview. connect(em, &EditModeController::vertexPaintChanged, this, [this]() { - if (m_useFgBgRamp || m_colorSource == ColorGradient) + // Only FG/BG ramps depend on the live FG/BG colours. + if (m_useFgBgRamp) reloadActiveRamp(); }); } @@ -341,8 +358,16 @@ TexturePaintController::TexturePaintController(QObject* parent) // Restore Paint v2 Slice A preferences. { QSettings s; - m_colorSource = static_cast( - s.value(AppSettingsKeys::paintColorSource(), static_cast(ColorSolid)).toInt()); + // Solid is the product default. One-time migration resets installs + // that picked up gradient during feature testing. + if (!s.contains(AppSettingsKeys::paintColorSourceSolidDefaultApplied())) { + m_colorSource = ColorSolid; + s.setValue(AppSettingsKeys::paintColorSource(), static_cast(ColorSolid)); + s.setValue(AppSettingsKeys::paintColorSourceSolidDefaultApplied(), true); + } else { + m_colorSource = static_cast( + s.value(AppSettingsKeys::paintColorSource(), static_cast(ColorSolid)).toInt()); + } m_gradientMode = static_cast( s.value(AppSettingsKeys::paintGradientMode(), static_cast(GradientLinear)).toInt()); m_activeRampName = s.value(AppSettingsKeys::paintGradientRampName(), @@ -527,6 +552,9 @@ void TexturePaintController::setUseFgBgRamp(bool on) if (on == m_useFgBgRamp) return; m_useFgBgRamp = on; reloadActiveRamp(); + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("fg/bg ramp=%1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); emit gradientChanged(); } @@ -537,6 +565,9 @@ void TexturePaintController::setGradientStepped(bool on) m_activeRamp.interpolate = on ? GradientRamp::Interpolate::Stepped : GradientRamp::Interpolate::Linear; refreshRampPreviewUri(); + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("stepped=%1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); emit gradientChanged(); } @@ -545,6 +576,9 @@ void TexturePaintController::setRampJitter(double j) j = std::clamp(j, 0.0, 1.0); if (std::abs(j - m_rampJitter) < 1e-6) return; m_rampJitter = j; + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("jitter=%1").arg(j, 0, 'f', 2)); emit gradientChanged(); } @@ -604,28 +638,29 @@ void TexturePaintController::reloadActiveRamp() return; } - if (const auto* bundled = GradientRamp::findBundled(m_activeRampName.toStdString())) { - m_activeRamp = *bundled; - } else { - bool found = false; - for (const auto& r : GradientRamp::loadCustomRamps()) { - if (r.name == m_activeRampName.toStdString()) { - m_activeRamp = r; - found = true; - break; - } + bool found = false; + for (const auto& r : GradientRamp::loadCustomRamps()) { + if (r.name == m_activeRampName.toStdString()) { + m_activeRamp = r; + found = true; + break; } - if (!found) { - // Fall back to Sunset so the brush always has a usable ramp. - if (const auto* sunset = GradientRamp::findBundled("Sunset")) { - m_activeRamp = *sunset; - m_activeRampName = QStringLiteral("Sunset"); - } + } + if (!found) { + if (const auto* bundled = GradientRamp::findBundled(m_activeRampName.toStdString())) { + m_activeRamp = *bundled; + found = true; + } + } + if (!found) { + // Fall back to Sunset so the brush always has a usable ramp. + if (const auto* sunset = GradientRamp::findBundled("Sunset")) { + m_activeRamp = *sunset; + m_activeRampName = QStringLiteral("Sunset"); } } - m_activeRamp.interpolate = m_gradientStepped - ? GradientRamp::Interpolate::Stepped - : GradientRamp::Interpolate::Linear; + m_gradientStepped = + m_activeRamp.interpolate == GradientRamp::Interpolate::Stepped; refreshRampPreviewUri(); } @@ -805,37 +840,69 @@ void TexturePaintController::openRampEditor() { if (m_rampEditorWindow) { if (auto* w = qobject_cast(m_rampEditorWindow)) { + w->show(); w->raise(); w->requestActivate(); + return; } - return; + m_rampEditorWindow = nullptr; } auto* engine = new QQmlApplicationEngine(this); const QString appDir = QCoreApplication::applicationDirPath(); engine->addImportPath(appDir + "/qml"); + engine->addImportPath(QStringLiteral("qrc:/")); engine->addImportPath(QLibraryInfo::path(QLibraryInfo::QmlImportsPath)); qmlRegisterSingletonType( "PropertiesPanel", 1, 0, "TexturePaintController", [](QQmlEngine* e, QJSEngine*) -> QObject* { return TexturePaintController::qmlInstance(e, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "PropertiesPanelController", + [](QQmlEngine* e, QJSEngine*) -> QObject* { + return PropertiesPanelController::qmlInstance(e, nullptr); + }); + connect(engine, &QQmlApplicationEngine::warnings, this, + [](const QList& warnings) { + for (const QQmlError& err : warnings) { + SentryReporter::addBreadcrumb( + "ui.action", + QStringLiteral("Gradient ramp editor QML: %1").arg(err.toString())); + } + }); auto handled = std::make_shared(false); connect(engine, &QQmlApplicationEngine::objectCreated, this, [this, engine, handled](QObject* obj, const QUrl&) { *handled = true; if (!obj) { + SentryReporter::addBreadcrumb("ui.action", + "Gradient ramp editor: QML load failed"); engine->deleteLater(); return; } m_rampEditorWindow = obj; if (auto* w = qobject_cast(obj)) { - connect(w, &QQuickWindow::visibleChanged, this, - [this, w, engine](bool vis) { - if (vis || m_rampEditorWindow != w) return; + w->setModality(Qt::ApplicationModal); + QWindow* parentWindow = nullptr; + if (auto* aw = QApplication::activeWindow()) + parentWindow = aw->windowHandle(); + if (!parentWindow) { + for (QWidget* tw : QApplication::topLevelWidgets()) { + if (tw && tw->isVisible() && tw->windowHandle()) { + parentWindow = tw->windowHandle(); + break; + } + } + } + if (parentWindow) + w->setTransientParent(parentWindow); + connect(w, &QQuickWindow::closing, this, + [this, w, engine]() { + if (m_rampEditorWindow != w) return; m_rampEditorWindow = nullptr; emit rampEditorChanged(); engine->deleteLater(); - }); + }, Qt::DirectConnection); w->show(); w->raise(); w->requestActivate(); @@ -945,6 +1012,38 @@ double TexturePaintController::texturePaintRadiusUV() const return uv; } +float TexturePaintController::brushRadiusUV() const +{ + return static_cast(texturePaintRadiusUV()); +} + +bool TexturePaintController::paintBrushAlongSegment(const Ogre::Vector2& from, + const Ogre::Vector2& to) +{ + const Ogre::Vector2 delta = to - from; + const float dist = delta.length(); + if (dist < 1e-6f) + return applyBrushAtUV(to); + + // Space dabs so stamps overlap — fast cursor moves won't leave gaps + // when mouse events arrive slower than the stroke speed. + const float radius = brushRadiusUV(); + const float spacing = std::max(radius * 0.35f, 0.002f); + int steps = std::max(1, static_cast(std::ceil(dist / spacing))); + // Cap work per mouse event — segment interpolation can otherwise + // fire dozens of full brush stamps when the cursor jumps in UV space. + steps = std::min(steps, 8); + + bool changed = false; + for (int i = 1; i <= steps; ++i) { + const float t = static_cast(i) / static_cast(steps); + const Ogre::Vector2 pt(from.x + delta.x * t, from.y + delta.y * t); + if (applyBrushAtUV(pt)) + changed = true; + } + return changed; +} + int TexturePaintController::brushShape() const { auto* em = EditModeController::instance(); @@ -992,6 +1091,7 @@ bool TexturePaintController::ensureEditableMesh(Ogre::Entity* entity) } m_paintMesh = std::move(mesh); m_paintMeshEntity = entity; + m_hitCache.valid = false; return true; } @@ -1462,24 +1562,450 @@ void TexturePaintController::scheduleRebindToPaintTexture(Ogre::Entity* entity) void TexturePaintController::flushDirtyToOgre() { - const auto& dirty = m_buffer.dirtyRect(); - if (dirty.empty()) return; + if (m_buffer.dirtyRect().empty()) return; - // Debounce the GPU upload. Mouse-move fires at 100+ Hz; the - // full-buffer blit is ~4 MB at 1024² and CHUGS at that rate. - // Schedule a single coalesced flush per ~16 ms (one render - // tick). The dirty rect keeps accumulating in the meantime, so - // no pixels are lost — just batched. + if (m_strokeActive) { + scheduleStrokeGpuFlush(); + return; + } + + const int debounceMs = 33; if (m_gpuFlushScheduled) return; m_gpuFlushScheduled = true; - QTimer::singleShot(33, this, [this]() { + QTimer::singleShot(debounceMs, this, [this]() { m_gpuFlushScheduled = false; if (m_buffer.dirtyRect().empty()) return; doFlushDirtyToOgre(); }); } -void TexturePaintController::doFlushDirtyToOgre() +void TexturePaintController::scheduleStrokeGpuFlush() +{ + if (m_buffer.dirtyRect().empty()) return; + + auto kickUpload = [this]() { + if (m_buffer.dirtyRect().empty()) return; + if (m_tiledUploadRunning) { + m_strokeGpuFlushPending = true; + return; + } + m_strokeGpuFlushPending = false; + startTiledGpuUpload(m_strokeEndAfterUpload); + }; + + // First dab in a stroke: upload on the next event-loop tick so colour + // appears on the model immediately (not after a debounce on release). + if (!m_strokeLiveUploadStarted) { + m_strokeLiveUploadStarted = true; + QTimer::singleShot(0, this, kickUpload); + return; + } + + if (m_strokeGpuFlushScheduled) return; + m_strokeGpuFlushScheduled = true; + QTimer::singleShot(16, this, [this, kickUpload]() { + m_strokeGpuFlushScheduled = false; + kickUpload(); + }); +} + +Ogre::TexturePtr TexturePaintController::gpuUploadTargetTexture() const +{ + // When the viewport still samples the model's original diffuse, upload + // there — otherwise strokes only show up after release (manual tex path). + if (!m_forceManualPaintTexture && m_boundSlots.empty() && m_originalTexture) + return m_originalTexture; + return m_ogreTexture; +} + +bool TexturePaintController::blitBufferRectToOgreTexture(int x0, int y0, int x1, int y1) +{ + Ogre::TexturePtr tex = gpuUploadTargetTexture(); + if (!tex || x1 <= x0 || y1 <= y0) return false; + if (tex == m_ogreTexture && m_boundSlots.empty() && m_paintMeshEntity) + scheduleRebindToPaintTexture(m_paintMeshEntity); + + const int W = m_buffer.width(); + const int rectW = x1 - x0; + const int rectH = y1 - y0; + std::vector rgba(static_cast(rectW) * static_cast(rectH) * 4u); + const auto& src = m_buffer.data(); + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(y0 + row) * static_cast(W) + + static_cast(x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(rgba.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); + } + try { + auto buf = tex->getBuffer(); + if (!buf) return false; + const Ogre::PixelFormat dstFmt = tex->getFormat(); + if (dstFmt == Ogre::PF_BYTE_RGBA || tex == m_ogreTexture) { + Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, rgba.data()); + Ogre::Box dst(x0, y0, x1, y1); + buf->blitFromMemory(pb, dst); + } else { + Ogre::PixelBox srcRgba(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, rgba.data()); + const size_t dstBytes = Ogre::PixelUtil::getMemorySize(rectW, rectH, 1, dstFmt); + std::vector slice(dstBytes); + Ogre::PixelBox dstPb(rectW, rectH, 1, dstFmt, slice.data()); + Ogre::PixelUtil::bulkPixelConversion(srcRgba, dstPb); + Ogre::Box dst(x0, y0, x1, y1); + buf->blitFromMemory(dstPb, dst); + } + return true; + } catch (const Ogre::Exception&) { + return false; + } +} + +void TexturePaintController::startTiledGpuUpload(bool finishingStroke) +{ + const auto dirty = m_buffer.dirtyRect(); + if (dirty.empty()) { + if (finishingStroke) + finishStrokeAfterGpuUpload(); + return; + } + + m_strokeEndAfterUpload = finishingStroke; + m_uploadPassDirty = dirty; + m_tiledUploadQueue.clear(); + for (int ty = dirty.y0; ty < dirty.y1; ty += kPaintUploadTilePx) { + for (int tx = dirty.x0; tx < dirty.x1; tx += kPaintUploadTilePx) { + UploadTile t; + t.x0 = tx; + t.y0 = ty; + t.x1 = std::min(dirty.x1, tx + kPaintUploadTilePx); + t.y1 = std::min(dirty.y1, ty + kPaintUploadTilePx); + m_tiledUploadQueue.push_back(t); + } + } + m_tiledUploadIndex = 0; + m_tiledUploadRunning = !m_tiledUploadQueue.empty(); + if (!m_tiledUploadRunning) { + if (finishingStroke) + finishStrokeAfterGpuUpload(); + return; + } + if (gpuUploadTargetTexture() == m_ogreTexture && m_paintMeshEntity && m_boundSlots.empty() + && m_ogreTexture) { + rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); + } + processNextTiledUploadTile(); +} + +void TexturePaintController::processNextTiledUploadTile() +{ + if (!m_tiledUploadRunning) return; + if (m_tiledUploadIndex >= static_cast(m_tiledUploadQueue.size())) { + onTiledUploadPassComplete(); + return; + } + const UploadTile& t = m_tiledUploadQueue[static_cast(m_tiledUploadIndex++)]; + blitBufferRectToOgreTexture(t.x0, t.y0, t.x1, t.y1); + QTimer::singleShot(1, this, [this]() { processNextTiledUploadTile(); }); +} + +void TexturePaintController::onTiledUploadPassComplete() +{ + m_tiledUploadRunning = false; + m_tiledUploadQueue.clear(); + m_tiledUploadIndex = 0; + + const auto now = m_buffer.dirtyRect(); + const bool dirtyGrewDuringPass = + !now.empty() + && (now.x0 < m_uploadPassDirty.x0 || now.y0 < m_uploadPassDirty.y0 + || now.x1 > m_uploadPassDirty.x1 || now.y1 > m_uploadPassDirty.y1); + if (dirtyGrewDuringPass) { + startTiledGpuUpload(m_strokeEndAfterUpload); + return; + } + + m_buffer.clearDirty(); + + if (!m_buffer.dirtyRect().empty()) { + startTiledGpuUpload(m_strokeEndAfterUpload); + return; + } + + if (m_strokeEndAfterUpload) { + m_strokeEndAfterUpload = false; + finishStrokeAfterGpuUpload(); + return; + } + + if (m_strokeGpuFlushPending && m_strokeActive && !m_buffer.dirtyRect().empty()) { + m_strokeGpuFlushPending = false; + scheduleStrokeGpuFlush(); + } + + if (!m_strokeActive && !m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } +} + +void TexturePaintController::finishStrokeAfterGpuUpload() +{ + // Undo snapshot + preview refresh can copy a full 4K buffer — defer so + // mouse-release returns immediately after the last GPU tile. + QTimer::singleShot(0, this, [this]() { finishStrokeAfterGpuUploadDeferred(); }); +} + +void TexturePaintController::ensureStrokePreSnapshot() +{ + if (m_target != TargetTexture) return; + if (!m_strokePreSnapshot.empty()) return; + m_strokePreSnapshot = snapshotPixels(); +} + +void TexturePaintController::finishStrokeAfterGpuUploadDeferred() +{ + refreshPreviewUri(); + auto after = snapshotPixels(); + if (after == m_strokePreSnapshot) { + m_strokePreSnapshot.clear(); + SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (no changes)"); + return; + } + auto* cmd = new TexturePaintStrokeCommand( + this, + std::move(m_strokePreSnapshot), + std::move(after), + m_buffer.width(), m_buffer.height(), + m_textureName); + UndoManager::getSingleton()->push(cmd); + SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (committed)"); + QTimer::singleShot(0, this, [this]() { updateEmbeddedTextureCache(); }); +} + +bool TexturePaintController::localPointFromHitCache(const Ogre::Vector2& uv, + Ogre::Vector3& outLocal, + Ogre::Vector3& outNormal) const +{ + if (!m_hitCache.valid || !m_paintMesh) return false; + if (m_hitCache.submesh < 0 || m_hitCache.triangle < 0) return false; + const auto& subs = m_paintMesh->subMeshes(); + if (m_hitCache.submesh >= static_cast(subs.size())) return false; + const auto& sub = subs[static_cast(m_hitCache.submesh)]; + if (m_hitCache.triangle >= static_cast(sub.triangles.size())) return false; + const auto& tri = sub.triangles[static_cast(m_hitCache.triangle)]; + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) return false; + + const Ogre::Vector2 e1 = v1.uv - v0.uv; + const Ogre::Vector2 e2 = v2.uv - v0.uv; + const Ogre::Vector2 dp = uv - v0.uv; + const float denom = e1.x * e2.y - e2.x * e1.y; + if (std::abs(denom) < 1e-10f) return false; + const float bu = (dp.x * e2.y - e2.x * dp.y) / denom; + const float bv = (e1.x * dp.y - dp.x * e1.y) / denom; + const float bw = 1.0f - bu - bv; + const float eps = 1e-3f; + if (bu < -eps || bv < -eps || bw < -eps) return false; + outLocal = v0.position * bw + v1.position * bu + v2.position * bv; + Ogre::Vector3 n = (v1.position - v0.position).crossProduct(v2.position - v0.position); + if (!n.isZeroLength()) n.normalise(); + else n = Ogre::Vector3::UNIT_Y; + outNormal = n; + return true; +} + +void TexturePaintController::onRenderFrame() +{ +} + +void TexturePaintController::scheduleStrokeUpdate(OgreWidget* widget, const QPoint& screenPos) +{ + m_pendingStrokeWidget = widget; + m_pendingStrokePos = screenPos; + if (m_strokeUpdateScheduled) return; + m_strokeUpdateScheduled = true; + QTimer::singleShot(0, this, [this]() { + m_strokeUpdateScheduled = false; + processPendingStrokeUpdate(); + }); +} + +void TexturePaintController::scheduleStrokeUpdateUV(double u, double v) +{ + m_pendingStrokeU = u; + m_pendingStrokeV = v; + if (m_strokeUpdateUVScheduled) return; + m_strokeUpdateUVScheduled = true; + QTimer::singleShot(0, this, [this]() { + m_strokeUpdateUVScheduled = false; + processPendingStrokeUpdateUV(); + }); +} + +bool TexturePaintController::hitTestUVForStroke(const QPoint& screenPos, + OgreWidget* widget, + Ogre::Vector2& outUV) +{ + if (m_strokeHaveHitScreen) { + const int dx = screenPos.x() - m_strokeLastHitScreen.x(); + const int dy = screenPos.y() - m_strokeLastHitScreen.y(); + const int dist2 = dx * dx + dy * dy; + // Extrapolate for small moves — avoids walking every triangle. + if (dist2 <= 32 * 32 + && (std::abs(m_strokeUvPerScreenX) > 1e-8f + || std::abs(m_strokeUvPerScreenY) > 1e-8f)) { + outUV.x = m_strokeLastHitUV.x + static_cast(dx) * m_strokeUvPerScreenX; + outUV.y = m_strokeLastHitUV.y + static_cast(dy) * m_strokeUvPerScreenY; + outUV.x = std::clamp(outUV.x, 0.0f, 1.0f); + outUV.y = std::clamp(outUV.y, 0.0f, 1.0f); + return true; + } + } + + if (!hitTestUV(screenPos, widget, outUV)) + return false; + + if (m_strokeHaveHitScreen) { + const int dx = screenPos.x() - m_strokeLastHitScreen.x(); + const int dy = screenPos.y() - m_strokeLastHitScreen.y(); + if (dx != 0 || dy != 0) { + m_strokeUvPerScreenX = (outUV.x - m_strokeLastHitUV.x) / static_cast(dx); + m_strokeUvPerScreenY = (outUV.y - m_strokeLastHitUV.y) / static_cast(dy); + } + } else { + m_strokeUvPerScreenX = 0.0f; + m_strokeUvPerScreenY = 0.0f; + } + m_strokeLastHitScreen = screenPos; + m_strokeLastHitUV = outUV; + m_strokeHaveHitScreen = true; + return true; +} + +void TexturePaintController::processPendingStrokeUpdate() +{ + if (!m_strokeActive || !m_paintEnabled) return; + OgreWidget* widget = m_pendingStrokeWidget; + const QPoint screenPos = m_pendingStrokePos; + + if (m_tool == ToolSmartSelect && m_wandStrokeActive) { + if (widget) { + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + const int viewportW = vw > 0 ? vw : 800; + 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) { + if (!m_paintMesh || !m_paintMeshEntity) return; + Ogre::Vector3 localPos, localNormal; + if (!hitTestLocalPoint(widget, screenPos, localPos, localNormal)) return; + drawHoverRingAt(localPos, localNormal); + const QColor c = texturePaintColor(); + const Ogre::ColourValue paint(c.redF(), c.greenF(), c.blueF(), c.alphaF()); + const auto* em = EditModeController::instance(); + const bool square = em && em->vertexPaintShape() == EditModeController::ShapeSquare; + const bool changed = EditModeController::applyVertexColorBrush( + *m_paintMesh, localPos, + static_cast(texturePaintRadius()), + paint, + static_cast(texturePaintStrength()), + static_cast(texturePaintFalloff()), + square); + if (changed) + m_paintMesh->commitVertexColorsToEntity(m_paintMeshEntity); + return; + } + + Ogre::Vector2 uv; + if (!hitTestUVForStroke(screenPos, widget, uv)) + return; + + ensureStrokePreSnapshot(); + + Ogre::Vector3 localPos, localNormal; + if (findMeshPointForUV(uv, localPos, localNormal)) + drawHoverRingAt(localPos, localNormal); + + bool changed = false; + const bool canSegment = + m_strokeHavePrevUV + && m_tool != ToolFill + && m_tool != ToolColorPicker + && m_tool != ToolSmartSelect; + if (canSegment) + changed = paintBrushAlongSegment(m_strokePrevUV, uv); + else + changed = applyBrushAtUV(uv); + + if (canSegment || (m_tool != ToolPaint || m_colorSource != ColorGradient)) { + m_strokePrevUV = uv; + m_strokeHavePrevUV = true; + } + + if (changed) + flushDirtyToOgre(); +} + +void TexturePaintController::processPendingStrokeUpdateUV() +{ + if (!m_strokeActive || !m_paintEnabled) return; + const Ogre::Vector2 uv(static_cast(m_pendingStrokeU), + static_cast(m_pendingStrokeV)); + + if (m_tool == ToolSmartSelect && m_wandStrokeActive) { + const double pressU = static_cast(m_wandStartScreenPos.x()) / 10000.0; + const double du = m_pendingStrokeU - 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; + } + + ensureStrokePreSnapshot(); + + bool changed = false; + const bool canSegment = + m_strokeHavePrevUV + && m_tool != ToolFill + && m_tool != ToolColorPicker + && m_tool != ToolSmartSelect; + if (canSegment) + changed = paintBrushAlongSegment(m_strokePrevUV, uv); + else + changed = applyBrushAtUV(uv); + + if (canSegment || (m_tool != ToolPaint || m_colorSource != ColorGradient)) { + m_strokePrevUV = uv; + m_strokeHavePrevUV = true; + } + + if (changed) + flushDirtyToOgre(); +} + +void TexturePaintController::doFlushDirtyToOgre(bool immediate) { const auto& dirty = m_buffer.dirtyRect(); if (dirty.empty()) return; @@ -1498,7 +2024,12 @@ void TexturePaintController::doFlushDirtyToOgre() // Skip the in-place path once we've rebound the model to the // manual paint texture — the original is no longer what the // renderer samples, so blitting to it would be invisible. - if (!m_forceManualPaintTexture && m_boundSlots.empty() && m_originalTexture) { + // During strokes always use the manual paint texture path — in-place + // blits into imported textures stall the GL driver even for partial + // rects, and format conversion is worse still. + const bool skipInPlaceDuringStroke = m_strokeActive; + if (!m_forceManualPaintTexture && !skipInPlaceDuringStroke + && m_boundSlots.empty() && m_originalTexture) { try { auto pixbuf = m_originalTexture->getBuffer(); if (pixbuf) { @@ -1544,7 +2075,7 @@ void TexturePaintController::doFlushDirtyToOgre() .arg(m_originalTexture->getHeight())); } m_buffer.clearDirty(); - if (!m_previewRefreshScheduled) { + if (!m_strokeActive && !m_previewRefreshScheduled) { m_previewRefreshScheduled = true; QTimer::singleShot(60, this, [this]() { m_previewRefreshScheduled = false; @@ -1573,59 +2104,117 @@ void TexturePaintController::doFlushDirtyToOgre() if (m_boundSlots.empty() && m_paintMeshEntity) scheduleRebindToPaintTexture(m_paintMeshEntity); - try { - auto buf = m_ogreTexture->getBuffer(); - if (!buf) return; - const int W = m_buffer.width(); - const int H = m_buffer.height(); - const int rectW = dirty.width(); - const int rectH = dirty.height(); - const bool partialDirty = - rectW > 0 && rectH > 0 - && static_cast(rectW) * static_cast(rectH) - < static_cast(W) * static_cast(H); + GpuUploadPacket packet; + packet.bufferW = m_buffer.width(); + packet.bufferH = m_buffer.height(); + packet.x0 = dirty.x0; + packet.y0 = dirty.y0; + packet.x1 = dirty.x1; + packet.y1 = dirty.y1; + const int rectW = dirty.width(); + const int rectH = dirty.height(); + packet.partial = + rectW > 0 && rectH > 0 + && static_cast(rectW) * static_cast(rectH) + < static_cast(packet.bufferW) * static_cast(packet.bufferH); #if defined(Q_OS_MACOS) - // Sub-rect blits are unreliable on Metal — upload the full frame. - const bool usePartialUpload = false; -#else - const bool usePartialUpload = partialDirty; + packet.partial = false; #endif - if (usePartialUpload) { - std::vector uploadCopy(static_cast(rectW) * static_cast(rectH) * 4u); - const auto& src = m_buffer.data(); - for (int row = 0; row < rectH; ++row) { - const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) - + static_cast(dirty.x0)) * 4u; - const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; - std::memcpy(uploadCopy.data() + dstOff, src.data() + srcOff, - static_cast(rectW) * 4u); - } - Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, uploadCopy.data()); - Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); - buf->blitFromMemory(pb, dst); - } else { - // Full-frame upload (macOS Metal, or dirty ≈ whole buffer). - std::vector uploadCopy(m_buffer.data().begin(), m_buffer.data().end()); - Ogre::PixelBox pb(W, H, 1, Ogre::PF_BYTE_RGBA, uploadCopy.data()); - buf->blitFromMemory(pb); + const size_t copyBytes = + packet.partial + ? static_cast(rectW) * static_cast(rectH) * 4u + : static_cast(packet.bufferW) * static_cast(packet.bufferH) * 4u; + packet.rgba.resize(copyBytes); + const auto& src = m_buffer.data(); + if (packet.partial) { + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(packet.bufferW) + + static_cast(dirty.x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(packet.rgba.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); } - } catch (const Ogre::Exception& e) { - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: blit failed — %1") - .arg(QString::fromStdString(e.getDescription()))); + } else { + std::memcpy(packet.rgba.data(), src.data(), copyBytes); } m_buffer.clearDirty(); - // Debounce the 2D preview refresh — encoding a 1024×1024 PNG + - // base64 on every stroke move is ~150ms of CPU work, which makes - // dragging hitchy. Schedule one refresh per ~60ms; the buffer ↔ - // preview drift during that window is invisible to the user. - if (!m_previewRefreshScheduled) { - m_previewRefreshScheduled = true; - QTimer::singleShot(60, this, [this]() { - m_previewRefreshScheduled = false; - refreshPreviewUri(); - }); + + auto blitPacket = [this](GpuUploadPacket packet) { + if (!m_ogreTexture) return; + try { + auto buf = m_ogreTexture->getBuffer(); + if (!buf) return; + if (packet.partial) { + const int rectW = packet.x1 - packet.x0; + const int rectH = packet.y1 - packet.y0; + Ogre::PixelBox pb(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, packet.rgba.data()); + Ogre::Box dst(packet.x0, packet.y0, packet.x1, packet.y1); + buf->blitFromMemory(pb, dst); + } else { + Ogre::PixelBox pb(packet.bufferW, packet.bufferH, 1, + Ogre::PF_BYTE_RGBA, packet.rgba.data()); + buf->blitFromMemory(pb); + } + } catch (const Ogre::Exception& e) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: blit failed — %1") + .arg(QString::fromStdString(e.getDescription()))); + } + if (!m_strokeActive && !m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } + }; + + if (immediate) { + blitPacket(std::move(packet)); + return; } + + // Yield one event-loop tick before the GPU blit so rapid mouse-move + // events can be processed (the pixel copy above is already done). + QTimer::singleShot(0, this, [blitPacket, packet = std::move(packet)]() mutable { + blitPacket(std::move(packet)); + }); +} + +bool TexturePaintController::tryHitTestCachedTriangle(const Ogre::Vector3& localOrigin, + const Ogre::Vector3& localDir, + Ogre::Vector2& outUV) const +{ + if (!m_hitCache.valid || !m_paintMesh) return false; + if (m_hitCache.submesh < 0 || m_hitCache.triangle < 0) return false; + const auto& subs = m_paintMesh->subMeshes(); + if (m_hitCache.submesh >= static_cast(subs.size())) return false; + const auto& sub = subs[static_cast(m_hitCache.submesh)]; + if (m_hitCache.triangle >= static_cast(sub.triangles.size())) return false; + const auto& tri = sub.triangles[static_cast(m_hitCache.triangle)]; + const auto& v0 = sub.vertices[tri.indices[0]]; + const auto& v1 = sub.vertices[tri.indices[1]]; + const auto& v2 = sub.vertices[tri.indices[2]]; + if (!v0.hasUV || !v1.hasUV || !v2.hasUV) return false; + + const Ogre::Vector3 e1 = v1.position - v0.position; + const Ogre::Vector3 e2 = v2.position - v0.position; + const Ogre::Vector3 pvec = localDir.crossProduct(e2); + const Ogre::Real det = e1.dotProduct(pvec); + if (std::abs(det) < 1e-8f) return false; + const Ogre::Real invDet = 1.0f / det; + const Ogre::Vector3 tvec = localOrigin - v0.position; + const Ogre::Real u = tvec.dotProduct(pvec) * invDet; + if (u < 0.0f || u > 1.0f) return false; + const Ogre::Vector3 qvec = tvec.crossProduct(e1); + const Ogre::Real v = localDir.dotProduct(qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) return false; + const Ogre::Real tHit = e2.dotProduct(qvec) * invDet; + if (tHit <= 0.0f) return false; + + const Ogre::Real w = 1.0f - u - v; + outUV = v0.uv * w + v1.uv * u + v2.uv * v; + return true; } bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const @@ -1652,12 +2241,26 @@ bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widg Ogre::Vector3 localDir = worldToLocal.linear() * ray.getDirection(); localDir.normalise(); - // Walk every triangle and keep the closest hit. (We don't have - // EditModeController's optimized bbox/octree, but for typical asset - // meshes the linear walk is fine.) + // Fast path: while dragging along the same surface patch, re-test + // only the last hit triangle instead of walking the whole mesh. + if (m_hitCache.valid) { + const int dx = screenPos.x() - m_hitCache.screenPos.x(); + const int dy = screenPos.y() - m_hitCache.screenPos.y(); + if (dx * dx + dy * dy <= 24 * 24) { + if (tryHitTestCachedTriangle(localOrigin, localDir, outUV)) { + m_hitCache.screenPos = screenPos; + return true; + } + } + } + + // Walk every triangle and keep the closest hit. Ogre::Real bestT = std::numeric_limits::infinity(); Ogre::Vector2 bestUV(0, 0); bool found = false; + int hitSubmesh = -1; + int hitTriangle = -1; + int subIdx = 0; for (const auto& sub : mesh->subMeshes()) { for (size_t ti = 0; ti < sub.triangles.size(); ++ti) { const auto& tri = sub.triangles[ti]; @@ -1683,10 +2286,20 @@ bool TexturePaintController::hitTestUV(const QPoint& screenPos, OgreWidget* widg const Ogre::Real w = 1.0f - u - v; bestT = tHit; bestUV = v0.uv * w + v1.uv * u + v2.uv * v; + hitSubmesh = subIdx; + hitTriangle = static_cast(ti); found = true; } + ++subIdx; + } + if (!found) { + m_hitCache.valid = false; + return false; } - if (!found) return false; + m_hitCache.submesh = hitSubmesh; + m_hitCache.triangle = hitTriangle; + m_hitCache.screenPos = screenPos; + m_hitCache.valid = true; outUV = bestUV; return true; } @@ -1733,12 +2346,19 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree m_strokeActive = true; m_strokeJustBegan = true; m_smudgeHavePrev = false; - m_strokePreSnapshot = snapshotPixels(); + m_strokePreSnapshot.clear(); + m_strokeLiveUploadStarted = false; + m_strokeGpuFlushPending = false; + m_strokeGpuFlushScheduled = false; m_wandStrokeActive = false; m_wandStartScreenPos = screenPos; m_strokeHavePrevUV = false; m_strokePathLength = 0.0f; m_strokeDirSmoothed = Ogre::Vector2::ZERO; + m_strokeHaveHitScreen = false; + m_strokeUvPerScreenX = 0.0f; + m_strokeUvPerScreenY = 0.0f; + m_hitCache.valid = false; m_strokePhaseJitter = (m_rampJitter > 0.0) ? static_cast(QRandomGenerator::global()->generateDouble() * m_rampJitter) : 0.0f; @@ -1756,98 +2376,26 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree .arg(texturePaintRadius(), 0, 'f', 3) .arg(texturePaintStrength(), 0, 'f', 3) .arg(texturePaintColor().name(QColor::HexRgb))); - updateStroke(widget, screenPos); + if (m_target == TargetTexture && m_paintMeshEntity && m_ogreTexture + && m_boundSlots.empty()) { + rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); + } + m_pendingStrokeWidget = widget; + m_pendingStrokePos = screenPos; + processPendingStrokeUpdate(); return true; } void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& screenPos) { 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. - if (!m_paintMesh || !m_paintMeshEntity) return; - Ogre::Vector3 localPos, localNormal; - if (!hitTestLocalPoint(widget, screenPos, localPos, localNormal)) return; - drawHoverRingAt(localPos, localNormal); - const QColor c = texturePaintColor(); - const Ogre::ColourValue paint(c.redF(), c.greenF(), c.blueF(), c.alphaF()); - const auto* em = EditModeController::instance(); - const bool square = em && em->vertexPaintShape() == EditModeController::ShapeSquare; - const bool changed = EditModeController::applyVertexColorBrush( - *m_paintMesh, localPos, - static_cast(texturePaintRadius()), - paint, - static_cast(texturePaintStrength()), - static_cast(texturePaintFalloff()), - square); - if (changed) - m_paintMesh->commitVertexColorsToEntity(m_paintMeshEntity); - return; - } - - Ogre::Vector2 uv; - if (!hitTestUV(screenPos, widget, uv)) { - clearHoveredUV(); - return; - } - emit hoveredUVChanged(uv.x, uv.y); - // Keep the brush-ring overlay tracking the cursor during a stroke. - Ogre::Vector3 localPos, localNormal; - if (findMeshPointForUV(uv, localPos, localNormal)) - drawHoverRingAt(localPos, localNormal); - if (applyBrushAtUV(uv)) - flushDirtyToOgre(); + scheduleStrokeUpdate(widget, screenPos); } bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) { if (m_buffer.width() <= 0) return false; - // The shared brush radius is in local mesh units. Map it to UV - // space by dividing by the mesh's bounding-box size — that's a - // reasonable first approximation when UVs are unwrapped onto a - // [0..1] square. Clamp to [0.005..1.0] so absurd sizes don't - // produce zero-pixel or whole-texture stamps. - float radius = static_cast(texturePaintRadius()); - if (m_paintMesh) { - const auto bbox = m_paintMesh->calculateBounds(); - if (bbox.isFinite()) { - const float meshExtent = bbox.getSize().length() * 0.5f; - if (meshExtent > 0.0f) { - radius = static_cast(texturePaintRadius()) / meshExtent; - } - } - } - radius = std::clamp(radius, 0.005f, 1.0f); + const float radius = brushRadiusUV(); const float strength = static_cast(texturePaintStrength()); const float falloff = static_cast(texturePaintFalloff()); // Shape is sourced from EditModeController (the canonical brush @@ -2043,6 +2591,17 @@ void TexturePaintController::pickColorAtUV(const Ogre::Vector2& uv) void TexturePaintController::endStroke() { if (!m_strokeActive) return; + + // Drain any coalesced move still queued on the event loop. + if (m_strokeUpdateScheduled) { + m_strokeUpdateScheduled = false; + processPendingStrokeUpdate(); + } + if (m_strokeUpdateUVScheduled) { + m_strokeUpdateUVScheduled = false; + processPendingStrokeUpdateUV(); + } + 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. @@ -2059,34 +2618,18 @@ void TexturePaintController::endStroke() SentryReporter::addBreadcrumb("ui.action", "Vertex paint stroke end"); 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()) - doFlushDirtyToOgre(); - // If nothing changed, drop the snapshot. - auto after = snapshotPixels(); - if (after == m_strokePreSnapshot) { - m_strokePreSnapshot.clear(); - SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (no changes)"); + + m_strokeEndAfterUpload = true; + if (m_tiledUploadRunning) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint stroke end (waiting for tiled GPU upload)"); return; } - auto* cmd = new TexturePaintStrokeCommand( - this, - std::move(m_strokePreSnapshot), - std::move(after), - m_buffer.width(), m_buffer.height(), - m_textureName); - UndoManager::getSingleton()->push(cmd); - SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (committed)"); - // 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(). - updateEmbeddedTextureCache(); + if (!m_buffer.dirtyRect().empty()) { + startTiledGpuUpload(/*finishingStroke=*/true); + return; + } + finishStrokeAfterGpuUpload(); } void TexturePaintController::updateEmbeddedTextureCache() @@ -2397,6 +2940,10 @@ void TexturePaintController::closeSession() m_loggedInPlaceBlit = false; m_rebindScheduled = false; m_gpuFlushScheduled = false; + m_strokeGpuFlushScheduled = false; + m_tiledUploadRunning = false; + m_tiledUploadQueue.clear(); + m_strokeEndAfterUpload = false; m_sessionEntity = nullptr; m_strokePreSnapshot.clear(); if (!m_uvOverlayUri.isEmpty()) { @@ -2430,7 +2977,10 @@ bool TexturePaintController::beginStrokeUV(double u, double v) m_strokeActive = true; m_strokeJustBegan = true; m_smudgeHavePrev = false; - m_strokePreSnapshot = snapshotPixels(); + m_strokePreSnapshot.clear(); + m_strokeLiveUploadStarted = false; + m_strokeGpuFlushPending = false; + m_strokeGpuFlushScheduled = false; 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. @@ -2438,6 +2988,7 @@ bool TexturePaintController::beginStrokeUV(double u, double v) m_strokeHavePrevUV = false; m_strokePathLength = 0.0f; m_strokeDirSmoothed = Ogre::Vector2::ZERO; + m_strokeHaveHitScreen = false; m_strokePhaseJitter = (m_rampJitter > 0.0) ? static_cast(QRandomGenerator::global()->generateDouble() * m_rampJitter) : 0.0f; @@ -2449,40 +3000,16 @@ bool TexturePaintController::beginStrokeUV(double u, double v) .arg(m_useFgBgRamp ? QStringLiteral("FG/BG") : m_activeRampName)); } emit hoveredUVChanged(u, v); - updateStrokeUV(u, v); + m_pendingStrokeU = u; + m_pendingStrokeV = v; + processPendingStrokeUpdateUV(); return true; } void TexturePaintController::updateStrokeUV(double u, double v) { if (!m_strokeActive || !m_paintEnabled) return; - const Ogre::Vector2 uv(static_cast(u), static_cast(v)); - emit hoveredUVChanged(u, v); - - // 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; - if (findMeshPointForUV(uv, localPos, localNormal)) - drawHoverRingAt(localPos, localNormal); - if (applyBrushAtUV(uv)) - flushDirtyToOgre(); + scheduleStrokeUpdateUV(u, v); } void TexturePaintController::endStrokeUV() diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 9ad849ff8..6e66f0a86 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -283,6 +284,8 @@ class TexturePaintController : public QObject bool beginStroke(OgreWidget* widget, const QPoint& screenPos); void updateStroke(OgreWidget* widget, const QPoint& screenPos); void endStroke(); + /// @deprecated Stroke GPU sync is deferred to stroke end / live-preview timer. + void onRenderFrame(); /// @} /** @@ -472,6 +475,20 @@ class TexturePaintController : public QObject /// recover the barycentric-interpolated UV at the hit point. Returns /// false on miss. bool hitTestUV(const QPoint& screenPos, OgreWidget* widget, Ogre::Vector2& outUV) const; + /// Ray-test only the cached triangle (see m_hitCache). Used while + /// dragging along a continuous surface patch. + bool tryHitTestCachedTriangle(const Ogre::Vector3& localOrigin, + const Ogre::Vector3& localDir, + Ogre::Vector2& outUV) const; + /// Stroke-only hit test: extrapolates UV from recent screen deltas + /// to skip full mesh raycasts on most mouse-move events. + bool hitTestUVForStroke(const QPoint& screenPos, OgreWidget* widget, + Ogre::Vector2& outUV); + /// Coalesce rapid mouse-move events into one paint step per tick. + void scheduleStrokeUpdate(OgreWidget* widget, const QPoint& screenPos); + void processPendingStrokeUpdate(); + void scheduleStrokeUpdateUV(double u, double v); + void processPendingStrokeUpdateUV(); /// Same hit-test but returns the local-space position and normal /// at the hit (for vertex paint, which works in 3D space). @@ -502,7 +519,23 @@ class TexturePaintController : public QObject /// actual GPU work happens in doFlushDirtyToOgre on a timer. void flushDirtyToOgre(); /// The synchronous GPU upload. Called from the debounce timer. - void doFlushDirtyToOgre(); + void doFlushDirtyToOgre(bool immediate = false); + /// Upload one dirty rect as 64×64 tiles, one tile per event-loop + /// tick, so GL blits never stall the UI for long. + void scheduleStrokeGpuFlush(); + void startTiledGpuUpload(bool finishingStroke); + void processNextTiledUploadTile(); + void onTiledUploadPassComplete(); + /// Texture the viewport samples — original (in-place) or manual paint tex. + Ogre::TexturePtr gpuUploadTargetTexture() const; + bool blitBufferRectToOgreTexture(int x0, int y0, int x1, int y1); + void finishStrokeAfterGpuUpload(); + void finishStrokeAfterGpuUploadDeferred(); + void ensureStrokePreSnapshot(); + /// 3D brush ring during strokes — uses the cached hit triangle only. + bool localPointFromHitCache(const Ogre::Vector2& uv, + Ogre::Vector3& outLocal, + Ogre::Vector3& outNormal) const; /// Point the model's diffuse TUSes at `m_ogreTexture` on the next /// event-loop tick (mat compile/reload must not run mid-stroke). void scheduleRebindToPaintTexture(Ogre::Entity* entity); @@ -522,6 +555,13 @@ class TexturePaintController : public QObject /// Returns true if any pixel changed. bool applyBrushAtUV(const Ogre::Vector2& uv); + /// Brush radius mapped into UV space (matches applyBrushAtUV). + float brushRadiusUV() const; + + /// Stamp along a UV segment so fast cursor moves don't leave gaps. + /// Returns true if any dab modified pixels. + bool paintBrushAlongSegment(const Ogre::Vector2& from, const Ogre::Vector2& to); + /// Resolve the active GradientRamp (FG/BG quick mode, custom, or bundled). const GradientRamp::Ramp* resolveActiveRamp() const; /// Rebuild `m_activeRamp` / preview URI after name or stop edits. @@ -563,6 +603,30 @@ class TexturePaintController : public QObject /// blitting on every mouse-move (which hits 100+ Hz and uploads /// 4 MB each time on macOS Metal). bool m_gpuFlushScheduled = false; + /// Set during an active stroke when CPU pixels changed; consumed + /// by scheduleStrokeGpuFlush() for tiled GPU uploads. + bool m_strokeGpuFlushScheduled = false; + bool m_strokeLiveUploadStarted = false; + bool m_strokeGpuFlushPending = false; + struct UploadTile { int x0 = 0; int y0 = 0; int x1 = 0; int y1 = 0; }; + std::vector m_tiledUploadQueue; + int m_tiledUploadIndex = 0; + bool m_tiledUploadRunning = false; + bool m_strokeEndAfterUpload = false; + TexturePaintBuffer::DirtyRect m_uploadPassDirty; + /// Batches hundreds of mouse-move events into one paint/upload step. + bool m_strokeUpdateScheduled = false; + OgreWidget* m_pendingStrokeWidget = nullptr; + QPoint m_pendingStrokePos; + bool m_strokeUpdateUVScheduled = false; + double m_pendingStrokeU = 0.0; + double m_pendingStrokeV = 0.0; + /// Screen→UV gradient for cheap extrapolation between raycasts. + bool m_strokeHaveHitScreen = false; + QPoint m_strokeLastHitScreen; + Ogre::Vector2 m_strokeLastHitUV = Ogre::Vector2::ZERO; + float m_strokeUvPerScreenX = 0.0f; + float m_strokeUvPerScreenY = 0.0f; Ogre::Entity* m_sessionEntity = nullptr; bool m_strokeActive = false; @@ -591,6 +655,17 @@ class TexturePaintController : public QObject /// stable when the cursor turns sharply mid-stroke. Ogre::Vector2 m_strokeDirSmoothed = Ogre::Vector2::ZERO; + /// Last ray-hit triangle for fast stroke tracking (avoids walking + /// every triangle on each mouse-move while the cursor stays on the + /// same surface patch). + struct PaintHitCache { + int submesh = -1; + int triangle = -1; + QPoint screenPos; + bool valid = false; + }; + mutable PaintHitCache m_hitCache; + /// Track every TUS we rebound to the paint texture so closeSession() /// can restore the originals. We keep the *material name* (not a /// raw pointer) so a destroyed/reloaded material doesn't dangle. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index ef64f94c8..8f6874a08 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -152,6 +152,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -1881,20 +1884,61 @@ void MainWindow::initToolBar() paintLay->addWidget(falloffLabel); paintLay->addWidget(falloffSlider); - // Brush shape selector: Round (circular falloff) vs Square - // (axis-aligned constant strength, no falloff). Falloff slider - // is ignored when Square is selected — kept enabled for - // discoverability of "switch back to Round". + // Inspector-style toggle buttons (blue QPalette::Highlight when checked). + auto inspectorToggleStyle = []() -> QString { + const QPalette pal = QApplication::palette(); + const QColor hi = pal.color(QPalette::Highlight); + const QColor hiText = pal.color(QPalette::HighlightedText); + const QColor btn = pal.color(QPalette::Button); + const QColor text = pal.color(QPalette::ButtonText); + const QColor border = pal.color(QPalette::Mid); + return QStringLiteral( + "QPushButton {" + " background-color: %1; color: %2;" + " border: 1px solid %3; border-radius: 3px;" + " padding: 2px 10px; min-width: 44px;" + "}" + "QPushButton:checked {" + " background-color: %4; color: %5;" + " border: 1px solid %6;" + "}" + "QPushButton:hover:!checked { background-color: %7; }" + "QPushButton:disabled { color: %8; }") + .arg(btn.name(QColor::HexRgb), text.name(QColor::HexRgb), border.name(QColor::HexRgb), + hi.name(QColor::HexRgb), hiText.name(QColor::HexRgb), + hi.lighter(130).name(QColor::HexRgb), + btn.lighter(115).name(QColor::HexRgb), + pal.color(QPalette::Disabled, QPalette::ButtonText).name(QColor::HexRgb)); + }; + const QString paintToggleStyle = inspectorToggleStyle(); + + auto addSectionSeparator = [paintSettings, paintLay]() { + auto* line = new QFrame(paintSettings); + line->setFrameShape(QFrame::HLine); + line->setFrameShadow(QFrame::Sunken); + line->setFixedHeight(2); + paintLay->addWidget(line); + }; + + addSectionSeparator(); + + // Brush shape: Round vs Square. QButtonGroup keeps exclusivity + // scoped to this pair — do NOT use setAutoExclusive on siblings + // that share paintSettings as parent (Qt treats them as one group). auto* shapeRow = new QHBoxLayout(); shapeRow->addWidget(new QLabel(tr("Shape:"), paintSettings)); auto* shapeRound = new QPushButton(tr("Round"), paintSettings); auto* shapeSquare = new QPushButton(tr("Square"), paintSettings); shapeRound->setCheckable(true); shapeSquare->setCheckable(true); - shapeRound->setAutoExclusive(true); - shapeSquare->setAutoExclusive(true); shapeRound->setFixedHeight(22); shapeSquare->setFixedHeight(22); + shapeRound->setStyleSheet(paintToggleStyle); + shapeSquare->setStyleSheet(paintToggleStyle); + auto* shapeGroup = new QButtonGroup(paintSettings); + shapeGroup->setExclusive(true); + shapeGroup->addButton(shapeRound); + shapeGroup->addButton(shapeSquare); auto syncShape = [shapeRound, shapeSquare, emPaint]() { const bool square = emPaint->vertexPaintShape() == EditModeController::ShapeSquare; QSignalBlocker br(shapeRound); @@ -1915,24 +1959,30 @@ void MainWindow::initToolBar() shapeRow->addStretch(); paintLay->addLayout(shapeRow); + addSectionSeparator(); + // Paint v2 Slice A (#544) — colour source + gradient ramp controls. // Lives in the brush portal (not the inspector) so brush settings stay // one click away from the paint tool button. auto* tpcPaint = TexturePaintController::instance(); - paintLay->addWidget(new QLabel(tr("Color:"), paintSettings)); - auto* colorSrcRow = new QHBoxLayout(); + auto* colorRow = new QHBoxLayout(); + colorRow->addWidget(new QLabel(tr("Color:"), paintSettings)); auto* srcSolid = new QPushButton(tr("Solid"), paintSettings); auto* srcGradient = new QPushButton(tr("Gradient"), paintSettings); srcSolid->setCheckable(true); srcGradient->setCheckable(true); - srcSolid->setAutoExclusive(true); - srcGradient->setAutoExclusive(true); srcSolid->setFixedHeight(22); srcGradient->setFixedHeight(22); - colorSrcRow->addWidget(srcSolid); - colorSrcRow->addWidget(srcGradient); - colorSrcRow->addStretch(); - paintLay->addLayout(colorSrcRow); + srcSolid->setStyleSheet(paintToggleStyle); + srcGradient->setStyleSheet(paintToggleStyle); + auto* colorGroup = new QButtonGroup(paintSettings); + colorGroup->setExclusive(true); + colorGroup->addButton(srcSolid); + colorGroup->addButton(srcGradient); + colorRow->addWidget(srcSolid); + colorRow->addWidget(srcGradient); + colorRow->addStretch(); + paintLay->addLayout(colorRow); auto* gradientBox = new QWidget(paintSettings); auto* gradLay = new QVBoxLayout(gradientBox); @@ -1946,10 +1996,15 @@ void MainWindow::initToolBar() auto* modeAngular = new QPushButton(tr("Angular"), gradientBox); for (auto* b : {modeLinear, modeRadial, modeAngular}) { b->setCheckable(true); - b->setAutoExclusive(true); b->setFixedHeight(22); + b->setStyleSheet(paintToggleStyle); modeRow->addWidget(b); } + auto* modeGroup = new QButtonGroup(gradientBox); + modeGroup->setExclusive(true); + modeGroup->addButton(modeLinear); + modeGroup->addButton(modeRadial); + modeGroup->addButton(modeAngular); modeRow->addStretch(); gradLay->addLayout(modeRow); @@ -2081,8 +2136,11 @@ void MainWindow::initToolBar() if (index >= 0) tpcPaint->setActiveRampName(rampCombo->itemText(index)); }); - connect(editRampBtn, &QPushButton::clicked, this, [tpcPaint]() { - tpcPaint->openRampEditor(); + connect(editRampBtn, &QPushButton::clicked, this, [this, tpcPaint, vertexPaintMenu]() { + vertexPaintMenu->close(); + QTimer::singleShot(0, this, [tpcPaint]() { + tpcPaint->openRampEditor(); + }); }); connect(fgBgCheck, &QCheckBox::toggled, this, [tpcPaint](bool on) { tpcPaint->setUseFgBgRamp(on); @@ -2096,18 +2154,35 @@ void MainWindow::initToolBar() }); connect(tpcPaint, &TexturePaintController::gradientChanged, this, syncGradientUi); + paintSettings->setMinimumWidth(280); + paintSettings->adjustSize(); + + // QMenu + QWidgetAction often clips tall panels on Linux/GTK — wrap in a + // scroll area with an explicit minimum height so every control is reachable. + auto* paintPortal = new QScrollArea(); + paintPortal->setWidget(paintSettings); + paintPortal->setWidgetResizable(true); + paintPortal->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + paintPortal->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + paintPortal->setFrameShape(QFrame::NoFrame); + paintPortal->setMinimumWidth(296); + paintPortal->setMinimumHeight(420); + paintPortal->setMaximumHeight(640); + auto* paintWa = new QWidgetAction(vertexPaintMenu); - paintWa->setDefaultWidget(paintSettings); + paintWa->setDefaultWidget(paintPortal); vertexPaintMenu->addAction(paintWa); vertexPaintButton->setMenu(vertexPaintMenu); connect(vertexPaintMenu, &QMenu::aboutToShow, this, - [syncRad, syncStr, syncFalloff, syncShape, syncGradientUi]() { + [paintSettings, paintPortal, syncRad, syncStr, syncFalloff, syncShape, syncGradientUi]() { syncRad(); syncStr(); syncFalloff(); syncShape(); syncGradientUi(); + paintSettings->adjustSize(); + paintPortal->updateGeometry(); }); // The brush button is now a TOOL SELECTOR, not the paint-mode