diff --git a/qml/GradientRampEditor.qml b/qml/GradientRampEditor.qml new file mode 100644 index 000000000..b22651351 --- /dev/null +++ b/qml/GradientRampEditor.qml @@ -0,0 +1,390 @@ +import QtQuick +import QtQuick.Window +import PropertiesPanel 1.0 + +/** + * Paint v2 Slice A (#544) — custom gradient ramp editor. + */ +Window { + id: rampWin + title: "Gradient Ramp Editor" + 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 + 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 + rampCanvas.requestPaint() + } + } + + onStopsChanged: rampCanvas.requestPaint() + onSteppedChanged: rampCanvas.requestPaint() + + function pushStops() { + TexturePaintController.setActiveRampStops(stops, stepped) + } + + 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) + return "#" + r.toString(16).padStart(2, "0") + + g.toString(16).padStart(2, "0") + + 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 + + InspectorLabel { + text: "Drag stops along the ramp. Double-click the strip to add a stop." + width: parent.width + wrapMode: Text.Wrap + opacity: 0.75 + } + + Item { + id: stripArea + width: parent.width + height: 48 + + Rectangle { + id: stripFrame + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 28 + 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))) + let r = 0.5, g = 0.5, b = 0.5, a = 1.0 + if (stops.length >= 2) { + 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 + 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 + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: index === selectedStop ? 2 : 1 + + MouseArea { + id: dragArea + anchors.fill: parent + drag.target: stopHandle + drag.axis: Drag.XAxis + drag.minimumX: -stopHandle.width / 2 + drag.maximumX: stripArea.width - stopHandle.width / 2 + cursorShape: Qt.SizeHorCursor + onPressed: { + selectedStop = index + stopHandle.z = 1 + } + onReleased: { + stopHandle.z = 0 + const t = Math.max(0, Math.min(1, + (stopHandle.x + stopHandle.width / 2) / Math.max(1, stripArea.width))) + const next = stops.slice() + const cur = next[index] + next[index] = { + 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() + } + } + } + } + } + + Row { + spacing: 8 + width: parent.width + InspectorLabel { + text: "Name:" + width: 40 + anchors.verticalCenter: parent.verticalCenter + } + InspectorTextField { + id: nameField + width: Math.max(120, parent.width - 40 - 8 - steppedBox.implicitWidth - 8) + text: rampWin.rampName + onEditedText: function(t) { rampWin.rampName = t } + } + InspectorCheckBox { + id: steppedBox + label: "Stepped" + checked: rampWin.stepped + onToggled: function(on) { + rampWin.stepped = on + pushStops() + } + } + } + + Flow { + width: parent.width + spacing: 8 + InspectorButton { + label: "Recolour Stop" + buttonEnabled: selectedStop >= 0 && selectedStop < stops.length + onClicked: { + 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() + } + } + InspectorButton { + label: "Delete Stop" + buttonEnabled: 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() + } + } + InspectorButton { + label: "Save Ramp" + onClicked: { + const name = nameField.text.trim().length > 0 + ? nameField.text.trim() : "Custom" + if (TexturePaintController.saveCustomRamp(name, stops, stepped)) + rampWin.rampName = name + } + } + InspectorButton { + label: "Close" + onClicked: rampWin.close() + } + } + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index ea8cfc6af..79d4c7802 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4166,6 +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. + // 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/AppSettingsKeys.h b/src/AppSettingsKeys.h index 2093ec119..dce4bfa9b 100644 --- a/src/AppSettingsKeys.h +++ b/src/AppSettingsKeys.h @@ -281,6 +281,34 @@ 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 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() +{ + 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..629f40da2 --- /dev/null +++ b/src/GradientRamp.cpp @@ -0,0 +1,335 @@ +#include "GradientRamp.h" + +#include +#include +#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 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 = customRampFileStem(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(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/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..36fd801fa 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,15 +167,19 @@ 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]); - 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/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..d0baded43 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" @@ -12,16 +13,20 @@ #include "UndoManager.h" #include "VertexColorBaker.h" #include "EmbeddedTextureCache.h" +#include "PropertiesPanelController.h" #include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include @@ -29,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +60,7 @@ #include #include +#include #include TexturePaintController* TexturePaintController::s_instance = nullptr; @@ -212,6 +219,55 @@ 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; +} + +/// 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, @@ -290,6 +346,37 @@ 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]() { + // Only FG/BG ramps depend on the live FG/BG colours. + if (m_useFgBgRamp) + reloadActiveRamp(); + }); + } + + // Restore Paint v2 Slice A preferences. + { + QSettings s; + // 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(), + 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 +495,438 @@ 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(); + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("fg/bg ramp=%1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); + 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(); + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("stepped=%1").arg(on ? QStringLiteral("on") : QStringLiteral("off"))); + 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; + SentryReporter::addBreadcrumb( + "paint.brush.gradient", + QStringLiteral("jitter=%1").arg(j, 0, 'f', 2)); + 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; + } + + bool found = false; + for (const auto& r : GradientRamp::loadCustomRamps()) { + if (r.name == m_activeRampName.toStdString()) { + m_activeRamp = r; + found = true; + break; + } + } + 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_gradientStepped = + m_activeRamp.interpolate == GradientRamp::Interpolate::Stepped; + 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->show(); + w->raise(); + w->requestActivate(); + 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)) { + 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(); + } + 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); @@ -430,6 +949,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 @@ -481,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(); @@ -528,6 +1091,7 @@ bool TexturePaintController::ensureEditableMesh(Ogre::Entity* entity) } m_paintMesh = std::move(mesh); m_paintMeshEntity = entity; + m_hitCache.valid = false; return true; } @@ -780,6 +1344,12 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) m_buffer.resize(res, res); m_buffer.clear(Ogre::ColourValue::White); m_buffer.clearDirty(); + // 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)); @@ -812,6 +1382,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()) @@ -884,10 +1468,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", @@ -952,192 +1538,683 @@ 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(); - if (dirty.empty()) return; + if (m_buffer.dirtyRect().empty()) return; + + if (m_strokeActive) { + scheduleStrokeGpuFlush(); + 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. + const int debounceMs = 33; if (m_gpuFlushScheduled) return; m_gpuFlushScheduled = true; - QTimer::singleShot(16, this, [this]() { + QTimer::singleShot(debounceMs, this, [this]() { m_gpuFlushScheduled = false; if (m_buffer.dirtyRect().empty()) return; doFlushDirtyToOgre(); }); } -void TexturePaintController::doFlushDirtyToOgre() +void TexturePaintController::scheduleStrokeGpuFlush() { - const auto& dirty = m_buffer.dirtyRect(); - if (dirty.empty()) return; + if (m_buffer.dirtyRect().empty()) return; - // Preferred path: paint directly INTO the model's original - // texture. No rebind needed — the existing material binding - // continues to work, and the paint just modifies pixels in place. - // Re-resolve the handle each flush so a reloaded material - // doesn't dangle. - if (!m_originalTextureName.isEmpty()) { - try { - auto fresh = Ogre::TextureManager::getSingleton().getByName( - m_originalTextureName.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); - if (fresh) m_originalTexture = fresh; - } catch (...) {} + 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; } - // Skip the in-place path once we've rebound the model to the - // manual paint texture — the original is no longer what the - // renderer samples, so blitting to it would be invisible. - if (m_boundSlots.empty() && m_originalTexture) { - try { - auto pixbuf = m_originalTexture->getBuffer(); - if (pixbuf) { - 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) { - 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. - } else { - std::vector srcRow(static_cast(rectW) * static_cast(rectH) * 4u); - const auto& src = m_buffer.data(); - for (int row = 0; row < rectH; ++row) { - const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) - + static_cast(dirty.x0)) * 4u; - const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; - std::memcpy(srcRow.data() + dstOff, src.data() + srcOff, - static_cast(rectW) * 4u); - } - Ogre::PixelBox srcRgba(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, srcRow.data()); - if (dstFmt == Ogre::PF_BYTE_RGBA) { - Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); - pixbuf->blitFromMemory(srcRgba, dst); - } else { - const size_t dstBytes = Ogre::PixelUtil::getMemorySize(rectW, rectH, 1, dstFmt); - std::vector slice(dstBytes); - Ogre::PixelBox dstPb(rectW, rectH, 1, dstFmt, slice.data()); - Ogre::PixelUtil::bulkPixelConversion(srcRgba, dstPb); - Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); - pixbuf->blitFromMemory(dstPb, dst); - } - m_useOriginalTexture = true; - if (!m_loggedInPlaceBlit) { - m_loggedInPlaceBlit = true; - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: in-place blit fmt=%1 size=%2x%3") - .arg(static_cast(dstFmt)) - .arg(m_originalTexture->getWidth()) - .arg(m_originalTexture->getHeight())); - } - m_buffer.clearDirty(); - if (!m_previewRefreshScheduled) { - m_previewRefreshScheduled = true; - QTimer::singleShot(60, this, [this]() { - m_previewRefreshScheduled = false; - refreshPreviewUri(); - }); - } - return; + + 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; + + // 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. + if (!m_forceManualPaintTexture && !m_originalTextureName.isEmpty()) { + try { + auto fresh = Ogre::TextureManager::getSingleton().getByName( + m_originalTextureName.toStdString(), + Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + if (fresh) m_originalTexture = fresh; + } catch (...) {} + } + // 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. + // 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) { + const int W = m_buffer.width(); + const int rectW = dirty.width(); + const int rectH = dirty.height(); + const Ogre::PixelFormat dstFmt = m_originalTexture->getFormat(); + if (!isPlainUncompressedFormat(dstFmt)) { + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place skipped — compressed format %1") + .arg(static_cast(dstFmt))); + m_originalTexture.reset(); + m_forceManualPaintTexture = true; + } else { + std::vector srcRow(static_cast(rectW) * static_cast(rectH) * 4u); + const auto& src = m_buffer.data(); + for (int row = 0; row < rectH; ++row) { + const size_t srcOff = (static_cast(dirty.y0 + row) * static_cast(W) + + static_cast(dirty.x0)) * 4u; + const size_t dstOff = static_cast(row) * static_cast(rectW) * 4u; + std::memcpy(srcRow.data() + dstOff, src.data() + srcOff, + static_cast(rectW) * 4u); + } + Ogre::PixelBox srcRgba(rectW, rectH, 1, Ogre::PF_BYTE_RGBA, srcRow.data()); + if (dstFmt == Ogre::PF_BYTE_RGBA) { + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(srcRgba, dst); + } else { + const size_t dstBytes = Ogre::PixelUtil::getMemorySize(rectW, rectH, 1, dstFmt); + std::vector slice(dstBytes); + Ogre::PixelBox dstPb(rectW, rectH, 1, dstFmt, slice.data()); + Ogre::PixelUtil::bulkPixelConversion(srcRgba, dstPb); + Ogre::Box dst(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + pixbuf->blitFromMemory(dstPb, dst); + } + m_useOriginalTexture = true; + if (!m_loggedInPlaceBlit) { + m_loggedInPlaceBlit = true; + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Texture paint: in-place blit fmt=%1 size=%2x%3") + .arg(static_cast(dstFmt)) + .arg(m_originalTexture->getWidth()) + .arg(m_originalTexture->getHeight())); + } + m_buffer.clearDirty(); + if (!m_strokeActive && !m_previewRefreshScheduled) { + m_previewRefreshScheduled = true; + QTimer::singleShot(60, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + }); + } + return; } } } catch (const Ogre::Exception& e) { SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Texture paint: in-place blit FAILED → fallback to manual texture (%1)") .arg(QString::fromStdString(e.getDescription()))); - // Fall through to manual-texture path. 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); - }); - } - 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); - } catch (const Ogre::Exception& e) { - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Texture paint: blit failed — %1") - .arg(QString::fromStdString(e.getDescription()))); + // 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); + + 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) + packet.partial = false; +#endif + 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); + } + } 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 @@ -1164,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]; @@ -1195,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; } @@ -1245,9 +2346,29 @@ 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; + 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") @@ -1255,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 @@ -1362,8 +2411,49 @@ 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; + } + const BrushEngine::SampleParams paramsCopy = params; + return m_buffer.paintBrush( + uv, radius, + [paramsCopy](float dx, float dy) { + BrushEngine::SampleParams local = paramsCopy; + 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 @@ -1501,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. @@ -1512,34 +2613,23 @@ void TexturePaintController::endStroke() .arg(m_smartSelectTolerance, 0, 'f', 3)); return; } - // Ensure any pending debounced GPU upload runs immediately so - // the final stroke pixels are visible before the user releases. - if (!m_buffer.dirtyRect().empty()) - doFlushDirtyToOgre(); - // If nothing changed, drop the snapshot. - auto after = snapshotPixels(); - if (after == m_strokePreSnapshot) { + if (m_target == TargetVertex) { m_strokePreSnapshot.clear(); - SentryReporter::addBreadcrumb("ui.action", "Texture paint stroke end (no changes)"); + SentryReporter::addBreadcrumb("ui.action", "Vertex paint stroke end"); 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(); + + m_strokeEndAfterUpload = true; + if (m_tiledUploadRunning) { + SentryReporter::addBreadcrumb("ui.action", + "Texture paint stroke end (waiting for tiled GPU upload)"); + return; + } + if (!m_buffer.dirtyRect().empty()) { + startTiledGpuUpload(/*finishingStroke=*/true); + return; + } + finishStrokeAfterGpuUpload(); } void TexturePaintController::updateEmbeddedTextureCache() @@ -1629,10 +2719,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); } @@ -1843,9 +2936,14 @@ void TexturePaintController::closeSession() m_originalTexture.reset(); m_originalTextureName.clear(); m_useOriginalTexture = false; + m_forceManualPaintTexture = false; 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()) { @@ -1879,46 +2977,39 @@ 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. m_wandStartScreenPos = QPoint(static_cast(u * 10000.0), 0); + 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; + 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); + 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 673da6438..6e66f0a86 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 @@ -11,6 +13,7 @@ #include #include #include +#include #include #include @@ -78,6 +81,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 +134,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 +191,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); } @@ -219,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(); /// @} /** @@ -380,6 +447,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". @@ -406,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). @@ -436,7 +519,26 @@ 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); /// Regenerate `m_previewUri` from the buffer (PNG, base64). Emits /// previewChanged when the URI actually changed. @@ -453,6 +555,21 @@ 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. + 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, @@ -476,6 +593,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 @@ -483,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; @@ -491,6 +635,37 @@ 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; + + /// 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 d6e068f6d..8f6874a08 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -152,7 +152,11 @@ #include #include #include +#include +#include +#include #include +#include #include #include #include @@ -1880,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); @@ -1914,15 +1959,230 @@ 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(); + 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->setFixedHeight(22); + srcGradient->setFixedHeight(22); + 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); + 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->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); + + 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, [this, tpcPaint, vertexPaintMenu]() { + vertexPaintMenu->close(); + QTimer::singleShot(0, 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); + + 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]() { + connect(vertexPaintMenu, &QMenu::aboutToShow, this, + [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 @@ -2307,9 +2567,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 +2580,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(); 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