diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 46f53bb7..b7015935 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4415,6 +4415,9 @@ Rectangle { property bool hasMask: TexturePaintController.hasSelectionMask property int maskCount: TexturePaintController.selectedPixelCount property real smartTolerance: TexturePaintController.smartSelectTolerance + property int layerCount: TexturePaintController.layerCount + property int activeLayerIndex: TexturePaintController.activeLayerIndex + property var paintLayers: TexturePaintController.paintLayers // Live hover position in UV space, fed by hoveredUVChanged. property real hoverU: -1 property real hoverV: -1 @@ -4451,6 +4454,11 @@ Rectangle { texPaintCol.maskCount = TexturePaintController.selectedPixelCount texPaintCol.smartTolerance = TexturePaintController.smartSelectTolerance } + function onLayersChanged() { + texPaintCol.layerCount = TexturePaintController.layerCount + texPaintCol.activeLayerIndex = TexturePaintController.activeLayerIndex + texPaintCol.paintLayers = TexturePaintController.paintLayers + } } Text { @@ -4699,6 +4707,270 @@ Rectangle { wrapMode: Text.Wrap } + // ---- Layers (Paint v2 Slice C #546) ---- + Column { + id: layersCol + spacing: 6 + width: parent.width - 16 + visible: texPaintCol.hasSession && texPaintCol.paintTarget === 0 + + property var activeLayer: { + const layers = texPaintCol.paintLayers || [] + return (layers.length > texPaintCol.activeLayerIndex) + ? layers[texPaintCol.activeLayerIndex] : null + } + + Text { + text: "Layers" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + + // Toolbar — same action-string pattern as the smart-select row. + Row { + spacing: 3 + width: parent.width + Repeater { + model: [ + { label: "+", action: "add", hint: "Add layer" }, + { label: "Dup", action: "dup", hint: "Duplicate" }, + { label: "\u2191", action: "up", hint: "Move up" }, + { label: "\u2193", action: "down", hint: "Move down" }, + { label: "Mrg", action: "merge", hint: "Merge down" }, + { label: "Flat", action: "flatten", hint: "Flatten all" }, + { label: "\u2715", action: "delete", hint: "Delete layer" } + ] + Rectangle { + width: 34; height: 22; radius: 3 + property bool btnEnabled: modelData.action !== "delete" + || texPaintCol.layerCount > 1 + opacity: btnEnabled ? 1.0 : 0.35 + color: btnEnabled && layerBtnMa.containsMouse + ? Qt.lighter(PropertiesPanelController.panelColor, 1.4) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: modelData.label + color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: layerBtnMa + anchors.fill: parent + hoverEnabled: btnEnabled + enabled: btnEnabled + cursorShape: btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + ToolTip.text: !btnEnabled && modelData.action === "delete" + ? "Cannot delete the last layer" + : modelData.hint + ToolTip.visible: containsMouse + ToolTip.delay: 400 + onClicked: { + const idx = texPaintCol.activeLayerIndex + switch (modelData.action) { + case "add": TexturePaintController.addPaintLayer(""); break + case "dup": TexturePaintController.duplicatePaintLayer(idx); break + case "up": TexturePaintController.movePaintLayerUp(idx); break + case "down": TexturePaintController.movePaintLayerDown(idx); break + case "merge": TexturePaintController.mergePaintLayerDown(idx); break + case "flatten": TexturePaintController.flattenPaintLayers(); break + case "delete": TexturePaintController.deletePaintLayer(idx); break + } + } + } + } + } + } + + ListView { + id: layerList + width: parent.width + height: Math.min(130, Math.max(36, count * 36)) + clip: true + spacing: 2 + model: texPaintCol.paintLayers + + delegate: Rectangle { + width: layerList.width + height: 34 + radius: 3 + color: modelData.active + ? Qt.darker(PropertiesPanelController.highlightColor, 1.2) + : (layerRowMa.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.3) + : PropertiesPanelController.headerColor) + border.color: modelData.active ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + + Row { + anchors.fill: parent + anchors.margins: 3 + spacing: 4 + + Image { + width: 28; height: 28 + source: modelData.thumbnailUrl + fillMode: Image.PreserveAspectFit + smooth: false + cache: false + } + + Text { + width: Math.max(40, layerList.width - 130) + anchors.verticalCenter: parent.verticalCenter + text: modelData.name + color: PropertiesPanelController.textColor + font.pixelSize: 10 + elide: Text.ElideRight + } + + // Visible toggle (eye) + Rectangle { + width: 18; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: modelData.visible + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.visible ? "\u2713" : "" + color: "white"; font.pixelSize: 9 + } + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.setPaintLayerVisible( + modelData.index, !modelData.visible) + } + } + + // Solo toggle + Rectangle { + width: 18; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: modelData.solo ? "#806622" : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: "S" + color: modelData.solo ? "#ffcc00" : PropertiesPanelController.textColor + font.pixelSize: 8; font.bold: true + } + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.setPaintLayerSolo( + modelData.index, !modelData.solo) + } + } + + // Lock toggle + Rectangle { + width: 18; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: modelData.locked + ? "#804040" : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: modelData.locked ? "L" : "" + color: modelData.locked ? "#ffcccc" : PropertiesPanelController.textColor + font.pixelSize: 8; font.bold: true + } + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: TexturePaintController.setPaintLayerLocked( + modelData.index, !modelData.locked) + } + } + } + + MouseArea { + id: layerRowMa + anchors.fill: parent + z: -1 + hoverEnabled: true + onClicked: TexturePaintController.activeLayerIndex = modelData.index + } + } + } + + Row { + spacing: 6 + width: parent.width + Text { + text: "Opacity" + width: 52 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + id: layerOpacitySlider + width: 120 + from: 0; to: 1; stepSize: 0.01 + property bool updating: false + value: layersCol.activeLayer ? layersCol.activeLayer.opacity : 1 + onPressedChanged: { + if (pressed) + TexturePaintController.beginPaintLayerOpacityDrag() + else + TexturePaintController.endPaintLayerOpacityDrag() + } + onMoved: { + if (!layersCol.activeLayer) return + TexturePaintController.setPaintLayerOpacity( + texPaintCol.activeLayerIndex, value) + } + Connections { + target: TexturePaintController + function onLayersChanged() { + if (!layersCol.activeLayer) return + layerOpacitySlider.updating = true + layerOpacitySlider.value = layersCol.activeLayer.opacity + layerOpacitySlider.updating = false + } + } + } + Text { + text: layersCol.activeLayer + ? Math.round(layersCol.activeLayer.opacity * 100) + "%" : "100%" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + spacing: 6 + width: parent.width + Text { + text: "Blend" + width: 52 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + ThemedComboBox { + id: layerBlendCombo + width: Math.max(120, layersCol.width - 58) + model: TexturePaintController.blendModeNames + currentIndex: layersCol.activeLayer ? layersCol.activeLayer.blendMode : 0 + onActivated: function(index) { + TexturePaintController.setPaintLayerBlendMode( + texPaintCol.activeLayerIndex, index) + } + Connections { + target: TexturePaintController + function onLayersChanged() { + if (!layersCol.activeLayer) return + layerBlendCombo.currentIndex = layersCol.activeLayer.blendMode + } + } + } + } + } + // ---- 2D preview / paint surface ---- // Live image of the paint buffer; clicking and dragging // paints into the texture in UV space. Crosshair indicator diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4fd1b51c..6253a58f 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -148,6 +148,8 @@ MultiViewTextureBaker.cpp TextureChannelPacker.cpp TextureAtlasPacker.cpp PaintBufferImageProvider.cpp +PaintLayerBlend.cpp +PaintLayerStack.cpp PaintSelectionMask.cpp GradientRamp.cpp BrushEngine.cpp @@ -367,6 +369,8 @@ MultiViewTextureBaker.h TextureChannelPacker.h TextureAtlasPacker.h PaintBufferImageProvider.h +PaintLayerBlend.h +PaintLayerStack.h PaintSelectionMask.h GradientRamp.h BrushEngine.h diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 1a3c9dd6..8cf6c3d4 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -93,6 +93,7 @@ THE SOFTWARE. #include "PS1/PS1TIM.h" #include "EditableMesh.h" #include "EditModeController.h" +#include "TexturePaintController.h" #include #include #include @@ -3379,6 +3380,13 @@ QString MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, QWidget* pare return QString(); } + if (Manager::getSingleton()->getSceneMgr()->hasEntity(_sn->getName())) { + if (auto* tpc = TexturePaintController::instance()) { + if (!tpc->confirmFlattenLayersForExport(parent)) + return QString(); + } + } + QString filter = "Ogre Mesh (*.mesh)"; QString fileName = QFileDialog::getSaveFileName(parent, QObject::tr("Export Mesh"), _sn->getName().data(), @@ -3410,6 +3418,8 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // Vertex paint defers GPU upload; export reads Ogre buffers — sync first. EditModeController::instance()->flushPendingVertexPaintForEntity( const_cast(e)); + if (auto* tpc = TexturePaintController::instance()) + tpc->flushPaintTextureForExport(const_cast(e)); if(_format=="Ogre XML (*.mesh.xml)") { diff --git a/src/PaintBufferImageProvider.cpp b/src/PaintBufferImageProvider.cpp index 85c38aeb..d9bc6184 100644 --- a/src/PaintBufferImageProvider.cpp +++ b/src/PaintBufferImageProvider.cpp @@ -10,12 +10,22 @@ PaintBufferImageProvider::PaintBufferImageProvider() QImage PaintBufferImageProvider::requestImage( const QString& id, QSize* size, const QSize& requestedSize) { - Q_UNUSED(id); // We only have one "image" — the live buffer. - Q_UNUSED(requestedSize); // QML's sourceSize hint is honoured by Image{}. + Q_UNUSED(requestedSize); auto* tpc = TexturePaintController::instance(); if (!tpc) return {}; - QImage img = tpc->snapshotBufferImage(); + + QImage img; + if (id.startsWith(QStringLiteral("layer/"))) { + const QString idxStr = id.mid(6); + bool ok = false; + const int layerIndex = idxStr.toInt(&ok); + if (ok) + img = tpc->snapshotLayerImage(layerIndex); + } else { + img = tpc->snapshotBufferImage(); + } + if (size) *size = img.size(); return img; } diff --git a/src/PaintLayerBlend.cpp b/src/PaintLayerBlend.cpp new file mode 100644 index 00000000..c0ef4ccb --- /dev/null +++ b/src/PaintLayerBlend.cpp @@ -0,0 +1,302 @@ +#include "PaintLayerBlend.h" + +#include +#include +#include + +namespace PaintLayerBlend { +namespace { + +constexpr float kEps = 1e-6f; + +float clamp01(float v) { return std::clamp(v, 0.f, 1.f); } + +struct Hsl { + float h = 0.f; + float s = 0.f; + float l = 0.f; +}; + +Hsl rgbToHsl(float r, float g, float b) +{ + const float maxC = std::max({r, g, b}); + const float minC = std::min({r, g, b}); + const float l = (maxC + minC) * 0.5f; + if (maxC - minC < kEps) return {0.f, 0.f, l}; + + const float d = maxC - minC; + float h = 0.f; + if (maxC == r) + h = std::fmod((g - b) / d + (g < b ? 6.f : 0.f), 6.f); + else if (maxC == g) + h = (b - r) / d + 2.f; + else + h = (r - g) / d + 4.f; + h /= 6.f; + + const float s = d / (1.f - std::abs(2.f * l - 1.f)); + return {h, s, l}; +} + +float hueToRgb(float p, float q, float t) +{ + if (t < 0.f) t += 1.f; + if (t > 1.f) t -= 1.f; + if (t < 1.f / 6.f) return p + (q - p) * 6.f * t; + if (t < 1.f / 2.f) return q; + if (t < 2.f / 3.f) return p + (q - p) * (2.f / 3.f - t) * 6.f; + return p; +} + +Rgba hslToRgb(float h, float s, float l) +{ + if (s < kEps) return {l, l, l, 1.f}; + const float q = l < 0.5f ? l * (1.f + s) : l + s - l * s; + const float p = 2.f * l - q; + return { + hueToRgb(p, q, h + 1.f / 3.f), + hueToRgb(p, q, h), + hueToRgb(p, q, h - 1.f / 3.f), + 1.f, + }; +} + +Rgba blendChannelMode(const Rgba& dst, const Rgba& src, Mode mode) +{ + switch (mode) { + case Mode::Multiply: + return {dst.r * src.r, dst.g * src.g, dst.b * src.b, src.a}; + case Mode::Screen: + return {1.f - (1.f - dst.r) * (1.f - src.r), + 1.f - (1.f - dst.g) * (1.f - src.g), + 1.f - (1.f - dst.b) * (1.f - src.b), + src.a}; + case Mode::Overlay: { + auto ov = [](float b, float s) { + return b < 0.5f ? 2.f * b * s : 1.f - 2.f * (1.f - b) * (1.f - s); + }; + return {ov(dst.r, src.r), ov(dst.g, src.g), ov(dst.b, src.b), src.a}; + } + case Mode::Add: + return {clamp01(dst.r + src.r), + clamp01(dst.g + src.g), + clamp01(dst.b + src.b), + src.a}; + case Mode::Subtract: + return {clamp01(dst.r - src.r), + clamp01(dst.g - src.g), + clamp01(dst.b - src.b), + src.a}; + case Mode::SoftLight: { + auto sl = [](float b, float s) { + return s < 0.5f ? b - (1.f - 2.f * s) * b * (1.f - b) + : b + (2.f * s - 1.f) * (std::sqrt(b) - b); + }; + return {clamp01(sl(dst.r, src.r)), + clamp01(sl(dst.g, src.g)), + clamp01(sl(dst.b, src.b)), + src.a}; + } + case Mode::Hue: { + const Hsl dstHsl = rgbToHsl(dst.r, dst.g, dst.b); + const Hsl srcHsl = rgbToHsl(src.r, src.g, src.b); + Rgba out = hslToRgb(srcHsl.h, dstHsl.s, dstHsl.l); + out.a = src.a; + return out; + } + case Mode::Normal: + default: + return src; + } +} + +Rgba normalComposite(const Rgba& dst, const Rgba& src, float alpha) +{ + const float a = clamp01(alpha); + if (a <= kEps) return dst; + if (a >= 1.f - kEps) return src; + return { + src.r * a + dst.r * (1.f - a), + src.g * a + dst.g * (1.f - a), + src.b * a + dst.b * (1.f - a), + a + dst.a * (1.f - a), + }; +} + +Rgba compositePixelAt(size_t pixelIndex, + const std::vector& layers) +{ + Rgba acc{1.f, 1.f, 1.f, 1.f}; + bool haveAcc = false; + + for (const auto& layer : layers) { + if (!layer.visible || !layer.rgba) continue; + const size_t off = pixelIndex * 4u; + const uint8_t* px = layer.rgba + off; + const uint8_t mask = layer.maskAlpha ? layer.maskAlpha[pixelIndex] : 255; + Rgba src = rgbaFromBytes(px[0], px[1], px[2], px[3]); + + if (!haveAcc) { + const float a = clamp01(src.a * clamp01(layer.opacity) * byteToF(mask)); + if (a <= kEps) + continue; + acc = {src.r, src.g, src.b, a}; + haveAcc = true; + continue; + } + + acc = blendPixel(acc, src, layer.blendMode, layer.opacity, mask); + } + + if (!haveAcc) + return {0.f, 0.f, 0.f, 0.f}; + return acc; +} + +} // namespace + +const char* modeName(Mode mode) +{ + switch (mode) { + case Mode::Normal: return "Normal"; + case Mode::Multiply: return "Multiply"; + case Mode::Screen: return "Screen"; + case Mode::Overlay: return "Overlay"; + case Mode::Add: return "Add"; + case Mode::Subtract: return "Subtract"; + case Mode::SoftLight: return "Soft Light"; + case Mode::Hue: return "Hue"; + } + return "Normal"; +} + +Mode modeFromName(const char* name) +{ + if (!name) return Mode::Normal; + struct Entry { const char* n; Mode m; }; + static const Entry kTable[] = { + {"Normal", Mode::Normal}, + {"Multiply", Mode::Multiply}, + {"Screen", Mode::Screen}, + {"Overlay", Mode::Overlay}, + {"Add", Mode::Add}, + {"Subtract", Mode::Subtract}, + {"Soft Light", Mode::SoftLight}, + {"SoftLight", Mode::SoftLight}, + {"Hue", Mode::Hue}, + }; + for (const auto& e : kTable) { + if (std::strcmp(name, e.n) == 0) return e.m; + } + return Mode::Normal; +} + +Rgba rgbaFromBytes(uint8_t r, uint8_t g, uint8_t b, uint8_t a) +{ + return {byteToF(r), byteToF(g), byteToF(b), byteToF(a)}; +} + +void rgbaToBytes(const Rgba& c, uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) +{ + r = fToByte(c.r); + g = fToByte(c.g); + b = fToByte(c.b); + a = fToByte(c.a); +} + +Rgba blendPixel(const Rgba& dst, const Rgba& src, Mode mode, float opacity, uint8_t mask) +{ + const float maskF = byteToF(mask); + const float effectiveAlpha = clamp01(src.a * clamp01(opacity) * maskF); + if (effectiveAlpha <= kEps) return dst; + + Rgba blended = src; + if (mode != Mode::Normal) + blended = blendChannelMode(dst, src, mode); + + if (mode == Mode::Normal) + return normalComposite(dst, blended, effectiveAlpha); + + // Non-normal modes: blend result over dst using effective alpha. + Rgba out = blended; + out.a = effectiveAlpha; + return normalComposite(dst, out, effectiveAlpha); +} + +void compositeLayers(int width, int height, + const std::vector& layers, + std::vector& out) +{ + const size_t n = static_cast(width) * static_cast(height); + out.assign(n * 4u, 0); + if (width <= 0 || height <= 0 || layers.empty()) return; + + bool anyVisible = false; + for (const auto& layer : layers) { + if (layer.visible && layer.rgba) { + anyVisible = true; + break; + } + } + if (!anyVisible) { + for (size_t i = 0; i < n; ++i) { + out[i * 4u + 0] = 255; + out[i * 4u + 1] = 255; + out[i * 4u + 2] = 255; + out[i * 4u + 3] = 255; + } + return; + } + + for (size_t i = 0; i < n; ++i) { + const Rgba acc = compositePixelAt(i, layers); + if (acc.a <= kEps && acc.r <= kEps && acc.g <= kEps && acc.b <= kEps) { + out[i * 4u + 0] = 0; + out[i * 4u + 1] = 0; + out[i * 4u + 2] = 0; + out[i * 4u + 3] = 0; + } else { + rgbaToBytes(acc, out[i * 4u + 0], out[i * 4u + 1], out[i * 4u + 2], out[i * 4u + 3]); + } + } +} + +void compositeLayersRegion(int width, int height, + const std::vector& layers, + uint8_t* inOut, + int x0, int y0, int x1, int y1) +{ + if (!inOut || width <= 0 || height <= 0 || layers.empty()) return; + x0 = std::clamp(x0, 0, width); + y0 = std::clamp(y0, 0, height); + x1 = std::clamp(x1, 0, width); + y1 = std::clamp(y1, 0, height); + if (x1 <= x0 || y1 <= y0) return; + + bool anyVisible = false; + for (const auto& layer : layers) { + if (layer.visible && layer.rgba) { + anyVisible = true; + break; + } + } + + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + const size_t i = static_cast(y) * static_cast(width) + + static_cast(x); + if (!anyVisible) { + inOut[i * 4u + 0] = 255; + inOut[i * 4u + 1] = 255; + inOut[i * 4u + 2] = 255; + inOut[i * 4u + 3] = 255; + continue; + } + const Rgba acc = compositePixelAt(i, layers); + rgbaToBytes(acc, inOut[i * 4u + 0], inOut[i * 4u + 1], inOut[i * 4u + 2], + inOut[i * 4u + 3]); + } + } +} + +} // namespace PaintLayerBlend diff --git a/src/PaintLayerBlend.h b/src/PaintLayerBlend.h new file mode 100644 index 00000000..4ec17798 --- /dev/null +++ b/src/PaintLayerBlend.h @@ -0,0 +1,75 @@ +#ifndef PAINTLAYERBLEND_H +#define PAINTLAYERBLEND_H + +#include +#include + +/** + * @brief Per-pixel blend modes for the Paint v2 layer stack (#546). + * + * Pure data — no Qt or Ogre runtime dependencies. Operates on + * straight (non-premultiplied) RGBA in [0..1]. + */ +namespace PaintLayerBlend { + +enum class Mode { + Normal = 0, + Multiply, + Screen, + Overlay, + Add, + Subtract, + SoftLight, + Hue, +}; + +/// Human-readable names for UI / persistence. +const char* modeName(Mode mode); +Mode modeFromName(const char* name); + +struct Rgba { + float r = 0.f; + float g = 0.f; + float b = 0.f; + float a = 1.f; +}; + +/// Blend `src` over `dst` with `mode`, `opacity`, and optional mask +/// (mask 0..255, 255 = full layer contribution at this pixel). +Rgba blendPixel(const Rgba& dst, const Rgba& src, Mode mode, float opacity, uint8_t mask = 255); + +/// Composite `layers` bottom-up into `out` (RGBA8, width*height*4). +/// Each layer entry is RGBA8 + optional maskAlpha (empty = all 255). +struct LayerInput { + const uint8_t* rgba = nullptr; + const uint8_t* maskAlpha = nullptr; ///< nullptr → fully visible + Mode blendMode = Mode::Normal; + float opacity = 1.f; + bool visible = true; +}; + +void compositeLayers(int width, int height, + const std::vector& layers, + std::vector& out); + +/// Recomposite only @p x0..x1 × @p y0..y1 into @p inOut (existing RGBA8). +void compositeLayersRegion(int width, int height, + const std::vector& layers, + uint8_t* inOut, + int x0, int y0, int x1, int y1); + +/// Convenience: byte [0..255] → float [0..1]. +inline float byteToF(uint8_t v) { return static_cast(v) * (1.f / 255.f); } +inline uint8_t fToByte(float v) +{ + if (v <= 0.f) return 0; + if (v >= 1.f) return 255; + return static_cast(v * 255.f + 0.5f); +} + +Rgba rgbaFromBytes(uint8_t r, uint8_t g, uint8_t b, uint8_t a); +void rgbaToBytes(const Rgba& c, uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a); + +} // namespace PaintLayerBlend + +#endif // PAINTLAYERBLEND_H diff --git a/src/PaintLayerBlend_test.cpp b/src/PaintLayerBlend_test.cpp new file mode 100644 index 00000000..430a40b9 --- /dev/null +++ b/src/PaintLayerBlend_test.cpp @@ -0,0 +1,282 @@ +#include + +#include "PaintLayerBlend.h" +#include "PaintLayerStack.h" +#include "TexturePaintBuffer.h" + +#include +#include + +namespace { + +PaintLayerBlend::Rgba px(const std::vector& buf, int w, int x, int y) +{ + const size_t off = (static_cast(y) * static_cast(w) + static_cast(x)) * 4u; + return PaintLayerBlend::rgbaFromBytes(buf[off], buf[off + 1], buf[off + 2], buf[off + 3]); +} + +void setLayerPixel(TexturePaintBuffer& buf, int x, int y, + uint8_t r, uint8_t g, uint8_t b, uint8_t a) +{ + buf.setPixel(x, y, Ogre::ColourValue( + PaintLayerBlend::byteToF(r), + PaintLayerBlend::byteToF(g), + PaintLayerBlend::byteToF(b), + PaintLayerBlend::byteToF(a))); +} + +} // namespace + +TEST(PaintLayerBlendTest, NormalOpacityComposite) +{ + const PaintLayerBlend::Rgba dst{0.2f, 0.2f, 0.2f, 1.f}; + const PaintLayerBlend::Rgba src{1.f, 0.f, 0.f, 0.5f}; + const auto out = PaintLayerBlend::blendPixel(dst, src, PaintLayerBlend::Mode::Normal, 1.f); + EXPECT_NEAR(out.r, 0.6f, 0.02f); + EXPECT_NEAR(out.g, 0.1f, 0.02f); +} + +TEST(PaintLayerBlendTest, MultiplyMode) +{ + const PaintLayerBlend::Rgba dst{0.5f, 0.5f, 0.5f, 1.f}; + const PaintLayerBlend::Rgba src{0.5f, 1.f, 0.f, 1.f}; + const auto out = PaintLayerBlend::blendPixel(dst, src, PaintLayerBlend::Mode::Multiply, 1.f); + EXPECT_NEAR(out.r, 0.25f, 0.02f); + EXPECT_NEAR(out.g, 0.5f, 0.02f); + EXPECT_NEAR(out.b, 0.f, 0.02f); +} + +TEST(PaintLayerBlendTest, ScreenMode) +{ + const PaintLayerBlend::Rgba dst{0.2f, 0.2f, 0.2f, 1.f}; + const PaintLayerBlend::Rgba src{0.5f, 0.5f, 0.5f, 1.f}; + const auto out = PaintLayerBlend::blendPixel(dst, src, PaintLayerBlend::Mode::Screen, 1.f); + EXPECT_GT(out.r, 0.5f); +} + +TEST(PaintLayerBlendTest, LayerMaskReducesContribution) +{ + const PaintLayerBlend::Rgba dst{0.f, 0.f, 0.f, 1.f}; + const PaintLayerBlend::Rgba src{1.f, 0.f, 0.f, 1.f}; + const auto full = PaintLayerBlend::blendPixel(dst, src, PaintLayerBlend::Mode::Normal, 1.f, 255); + const auto half = PaintLayerBlend::blendPixel(dst, src, PaintLayerBlend::Mode::Normal, 1.f, 128); + EXPECT_NEAR(full.r, 1.f, 0.02f); + EXPECT_NEAR(half.r, 0.5f, 0.04f); +} + +TEST(PaintLayerBlendTest, ThreeLayerFixtureAllBlendModes) +{ + constexpr int W = 4; + constexpr int H = 1; + std::vector bottom(W * H * 4, 0); + std::vector mid(W * H * 4, 0); + std::vector top(W * H * 4, 0); + for (int i = 0; i < W; ++i) { + bottom[i * 4 + 0] = 64; + bottom[i * 4 + 1] = 64; + bottom[i * 4 + 2] = 64; + bottom[i * 4 + 3] = 255; + mid[i * 4 + 0] = 128; + mid[i * 4 + 1] = 0; + mid[i * 4 + 2] = 0; + mid[i * 4 + 3] = 255; + top[i * 4 + 0] = 0; + top[i * 4 + 1] = 128; + top[i * 4 + 2] = 0; + top[i * 4 + 3] = 128; + } + + const PaintLayerBlend::Mode modes[] = { + PaintLayerBlend::Mode::Normal, + PaintLayerBlend::Mode::Multiply, + PaintLayerBlend::Mode::Screen, + PaintLayerBlend::Mode::Overlay, + PaintLayerBlend::Mode::Add, + PaintLayerBlend::Mode::Subtract, + PaintLayerBlend::Mode::SoftLight, + PaintLayerBlend::Mode::Hue, + }; + + for (auto mode : modes) { + std::vector layers(3); + layers[0] = {bottom.data(), nullptr, PaintLayerBlend::Mode::Normal, 1.f, true}; + layers[1] = {mid.data(), nullptr, mode, 1.f, true}; + layers[2] = {top.data(), nullptr, PaintLayerBlend::Mode::Normal, 0.5f, true}; + std::vector out; + PaintLayerBlend::compositeLayers(W, H, layers, out); + ASSERT_EQ(out.size(), static_cast(W * H * 4)); + const auto c = px(out, W, 0, 0); + EXPECT_GE(c.r, 0.f); + EXPECT_LE(c.r, 1.f); + EXPECT_GE(c.g, 0.f); + EXPECT_LE(c.g, 1.f); + } +} + +TEST(PaintLayerBlendTest, CompositeRegionMatchesFull) +{ + const int w = 4; + const int h = 4; + std::vector red(w * h * 4, 0); + for (int i = 0; i < w * h; ++i) { + red[i * 4u + 0] = 255; + red[i * 4u + 3] = 255; + } + std::vector blue(w * h * 4, 0); + for (int y = 1; y < 3; ++y) + for (int x = 1; x < 3; ++x) { + const size_t off = (static_cast(y) * static_cast(w) + + static_cast(x)) * 4u; + blue[off + 2] = 255; + blue[off + 3] = 128; + } + + std::vector layers = { + {red.data(), nullptr, PaintLayerBlend::Mode::Normal, 1.f, true}, + {blue.data(), nullptr, PaintLayerBlend::Mode::Normal, 1.f, true}, + }; + + std::vector full; + PaintLayerBlend::compositeLayers(w, h, layers, full); + + std::vector region = full; + PaintLayerBlend::compositeLayersRegion(w, h, layers, region.data(), 1, 1, 3, 3); + + for (int y = 1; y < 3; ++y) { + for (int x = 1; x < 3; ++x) { + EXPECT_FLOAT_EQ(px(full, w, x, y).b, px(region, w, x, y).b); + EXPECT_FLOAT_EQ(px(full, w, x, y).r, px(region, w, x, y).r); + } + } +} + +TEST(PaintLayerStackTest, InitFromFlatBufferCreatesLayer0) +{ + TexturePaintBuffer flat(2, 2); + flat.clear(Ogre::ColourValue(0.5f, 0.25f, 0.f, 1.f)); + flat.clearDirty(); + + PaintLayerStack stack; + stack.initFromFlatBuffer(flat); + EXPECT_EQ(stack.layerCount(), 1); + EXPECT_EQ(stack.layer(0).name, QStringLiteral("Layer 0")); + EXPECT_EQ(stack.layer(0).buffer.width(), 2); +} + +TEST(PaintLayerStackTest, SoloShowsOnlyOneLayer) +{ + PaintLayerStack stack; + TexturePaintBuffer red(2, 2); + red.clear(Ogre::ColourValue(1.f, 0.f, 0.f, 1.f)); + red.clearDirty(); + stack.initFromFlatBuffer(red); + + stack.addEmpty(QStringLiteral("Blue")); + setLayerPixel(stack.layer(1).buffer, 0, 0, 0, 0, 255, 255); + + std::vector composite; + stack.compositeTo(composite); + auto cBoth = px(composite, 2, 0, 0); + EXPECT_GT(cBoth.b, 0.5f); // blue layer visible in stack + + stack.setSolo(0, true); + stack.compositeTo(composite); + auto cSolo = px(composite, 2, 0, 0); + EXPECT_GT(cSolo.r, 0.9f); + EXPECT_LT(cSolo.b, 0.1f); +} + +TEST(PaintLayerStackTest, MergeDownCombinesLayers) +{ + PaintLayerStack stack; + TexturePaintBuffer base(2, 2); + base.clear(Ogre::ColourValue(1.f, 1.f, 1.f, 1.f)); + base.clearDirty(); + stack.initFromFlatBuffer(base); + stack.addEmpty(QStringLiteral("Paint")); + setLayerPixel(stack.activeLayer().buffer, 0, 0, 255, 0, 0, 255); + + stack.mergeDown(1); + EXPECT_EQ(stack.layerCount(), 1); + const auto c = stack.layer(0).buffer.pixel(0, 0); + EXPECT_GT(c.r, 0.5f); +} + +TEST(PaintLayerStackTest, LayerMaskHidesStrokeRegion) +{ + PaintLayerStack stack; + TexturePaintBuffer base(4, 4); + base.clear(Ogre::ColourValue(0.f, 0.f, 0.f, 1.f)); + base.clearDirty(); + stack.initFromFlatBuffer(base); + + auto& mask = stack.ensureLayerMask(0); + for (int y = 0; y < 4; ++y) + for (int x = 0; x < 2; ++x) + mask[static_cast(y) * 4u + static_cast(x)] = 0; + + setLayerPixel(stack.layer(0).buffer, 0, 0, 255, 255, 255, 255); + setLayerPixel(stack.layer(0).buffer, 3, 2, 255, 255, 255, 255); + + std::vector out; + stack.compositeTo(out); + auto hidden = px(out, 4, 0, 0); + auto shown = px(out, 4, 3, 2); + EXPECT_LT(hidden.r, 0.1f); + EXPECT_GT(shown.r, 0.9f); +} + +TEST(PaintLayerStackTest, DuplicateAdjustsSoloIndex) +{ + PaintLayerStack stack; + TexturePaintBuffer flat(2, 2); + flat.clear(Ogre::ColourValue::White); + flat.clearDirty(); + stack.initFromFlatBuffer(flat); + stack.addEmpty(QStringLiteral("L2")); + stack.setSolo(1, true); + EXPECT_EQ(stack.soloIndex(), 1); + + stack.duplicateLayer(0); + EXPECT_EQ(stack.soloIndex(), 2); + EXPECT_EQ(stack.activeIndex(), 1); +} + +TEST(PaintLayerStackTest, RemoveLayerRejectsInvalidIndex) +{ + PaintLayerStack stack; + TexturePaintBuffer flat(2, 2); + flat.clear(Ogre::ColourValue::White); + flat.clearDirty(); + stack.initFromFlatBuffer(flat); + stack.addEmpty(QStringLiteral("L2")); + stack.removeLayer(-1); + stack.removeLayer(99); + EXPECT_EQ(stack.layerCount(), 2); +} + +TEST(PaintLayerStackTest, AllHiddenCompositeMatchesRegion) +{ + PaintLayerStack stack; + TexturePaintBuffer flat(4, 4); + flat.clear(Ogre::ColourValue::White); + flat.clearDirty(); + stack.initFromFlatBuffer(flat); + stack.addEmpty(QStringLiteral("L2")); + stack.setVisible(0, false); + stack.setVisible(1, false); + + std::vector full; + stack.compositeTo(full); + const auto fullPx = px(full, 4, 0, 0); + EXPECT_NEAR(fullPx.r, 1.f, 0.02f); + EXPECT_NEAR(fullPx.a, 1.f, 0.02f); + + std::vector region(4 * 4 * 4, 0); + stack.compositeRegionTo(region.data(), 0, 0, 2, 2); + const auto regionPx = px(region, 4, 0, 0); + EXPECT_NEAR(regionPx.r, fullPx.r, 0.001f); + EXPECT_NEAR(regionPx.g, fullPx.g, 0.001f); + EXPECT_NEAR(regionPx.b, fullPx.b, 0.001f); + EXPECT_NEAR(regionPx.a, fullPx.a, 0.001f); +} diff --git a/src/PaintLayerStack.cpp b/src/PaintLayerStack.cpp new file mode 100644 index 00000000..a093c7da --- /dev/null +++ b/src/PaintLayerStack.cpp @@ -0,0 +1,415 @@ +#include "PaintLayerStack.h" + +#include + +#include +#include + +namespace { + +void copyBuffer(const TexturePaintBuffer& src, TexturePaintBuffer& dst) +{ + dst.resize(src.width(), src.height()); + if (!src.data().empty()) + std::memcpy(dst.data().data(), src.data().data(), src.data().size()); + dst.clearDirty(); +} + +} // namespace + +int PaintLayerStack::width() const +{ + return m_layers.empty() ? 0 : m_layers.front().buffer.width(); +} + +int PaintLayerStack::height() const +{ + return m_layers.empty() ? 0 : m_layers.front().buffer.height(); +} + +const PaintLayerStack::Layer& PaintLayerStack::layer(int index) const +{ + return m_layers.at(static_cast(index)); +} + +PaintLayerStack::Layer& PaintLayerStack::layer(int index) +{ + return m_layers.at(static_cast(index)); +} + +PaintLayerStack::Layer& PaintLayerStack::activeLayer() +{ + return m_layers.at(static_cast(m_activeIndex)); +} + +const PaintLayerStack::Layer& PaintLayerStack::activeLayer() const +{ + return m_layers.at(static_cast(m_activeIndex)); +} + +void PaintLayerStack::initFromFlatBuffer(const TexturePaintBuffer& flat, + const QString& layerName) +{ + m_layers.clear(); + m_soloIndex = -1; + Layer bg; + bg.name = layerName.isEmpty() ? numberedLayerName(0) : layerName; + bg.type = LayerType::Paint; + copyBuffer(flat, bg.buffer); + m_layers.push_back(std::move(bg)); + m_activeIndex = 0; +} + +QString PaintLayerStack::numberedLayerName(int index) +{ + return QStringLiteral("Layer %1").arg(index); +} + +int PaintLayerStack::nextLayerNumber() const +{ + static const QRegularExpression re(QStringLiteral("^Layer (\\d+)$")); + int maxNum = -1; + for (const auto& L : m_layers) { + const auto match = re.match(L.name); + if (match.hasMatch()) + maxNum = std::max(maxNum, match.captured(1).toInt()); + } + return maxNum + 1; +} + +QString PaintLayerStack::allocateLayerName() const +{ + int n = nextLayerNumber(); + QString name = numberedLayerName(n); + auto exists = [&](const QString& candidate) { + for (const auto& L : m_layers) { + if (L.name == candidate) return true; + } + return false; + }; + while (exists(name)) + name = numberedLayerName(++n); + return name; +} + +void PaintLayerStack::resizeAll(int width, int height) +{ + const int oldW = this->width(); + const int oldH = this->height(); + for (auto& L : m_layers) { + if (!L.maskAlpha.empty() && oldW > 0 && oldH > 0) { + std::vector oldMask = std::move(L.maskAlpha); + L.buffer.resize(width, height); + L.maskAlpha.assign(static_cast(width) * static_cast(height), 255); + const int copyW = std::min(oldW, width); + const int copyH = std::min(oldH, height); + for (int y = 0; y < copyH; ++y) { + for (int x = 0; x < copyW; ++x) { + L.maskAlpha[static_cast(y) * static_cast(width) + + static_cast(x)] + = oldMask[static_cast(y) * static_cast(oldW) + + static_cast(x)]; + } + } + } else { + L.buffer.resize(width, height); + } + } +} + +QString PaintLayerStack::uniqueLayerName(const std::vector& layers, + const QString& base) +{ + QString name = base; + int suffix = 1; + auto exists = [&](const QString& n) { + for (const auto& L : layers) { + if (L.name == n) return true; + } + return false; + }; + while (exists(name)) + name = QStringLiteral("%1 %2").arg(base).arg(++suffix); + return name; +} + +int PaintLayerStack::addEmpty(const QString& name, LayerType type) +{ + const int w = width(); + const int h = height(); + Layer L; + L.name = name.isEmpty() ? allocateLayerName() : uniqueLayerName(m_layers, name); + L.type = type; + if (w > 0 && h > 0) { + L.buffer.resize(w, h); + L.buffer.clear(Ogre::ColourValue(0.f, 0.f, 0.f, 0.f)); // transparent + L.buffer.clearDirty(); + } + m_layers.push_back(std::move(L)); + m_activeIndex = layerCount() - 1; + return m_activeIndex; +} + +int PaintLayerStack::addFromBuffer(const TexturePaintBuffer& src, + const QString& name, + LayerType type) +{ + Layer L; + L.name = name.isEmpty() ? allocateLayerName() : uniqueLayerName(m_layers, name); + L.type = type; + copyBuffer(src, L.buffer); + if (m_layers.empty()) { + m_layers.push_back(std::move(L)); + m_activeIndex = 0; + return 0; + } + const int w = width(); + const int h = height(); + if (src.width() != w || src.height() != h) + L.buffer.resize(w, h); + m_layers.push_back(std::move(L)); + m_activeIndex = layerCount() - 1; + return m_activeIndex; +} + +int PaintLayerStack::duplicateLayer(int index) +{ + if (index < 0 || index >= layerCount()) return m_activeIndex; + const Layer& src = layer(index); + Layer copy = src; + copy.name = allocateLayerName(); + copy.locked = false; + m_layers.insert(m_layers.begin() + index + 1, std::move(copy)); + if (m_soloIndex >= 0 && m_soloIndex > index) + ++m_soloIndex; + m_activeIndex = index + 1; + return m_activeIndex; +} + +void PaintLayerStack::removeLayer(int index) +{ + if (index < 0 || index >= layerCount()) return; + if (layerCount() <= 1) return; // keep at least one layer + m_layers.erase(m_layers.begin() + index); + if (m_soloIndex == index) m_soloIndex = -1; + else if (m_soloIndex > index) --m_soloIndex; + clampActiveIndex(); +} + +void PaintLayerStack::moveLayer(int from, int to) +{ + if (from < 0 || from >= layerCount() || to < 0 || to >= layerCount()) return; + if (from == to) return; + Layer item = std::move(m_layers[static_cast(from)]); + m_layers.erase(m_layers.begin() + from); + m_layers.insert(m_layers.begin() + to, std::move(item)); + if (m_activeIndex == from) m_activeIndex = to; + else if (from < m_activeIndex && to >= m_activeIndex) --m_activeIndex; + else if (from > m_activeIndex && to <= m_activeIndex) ++m_activeIndex; + if (m_soloIndex == from) m_soloIndex = to; + else if (from < m_soloIndex && to >= m_soloIndex) --m_soloIndex; + else if (from > m_soloIndex && to <= m_soloIndex) ++m_soloIndex; +} + +void PaintLayerStack::renameLayer(int index, const QString& name) +{ + if (name.isEmpty()) return; + layer(index).name = name; +} + +void PaintLayerStack::mergeDown(int index) +{ + if (index <= 0 || index >= layerCount()) return; + Layer& upper = layer(index); + Layer& lower = layer(index - 1); + if (upper.buffer.width() != lower.buffer.width() + || upper.buffer.height() != lower.buffer.height()) + return; + + std::vector composite; + std::vector inputs(2); + inputs[0] = {lower.buffer.data().data(), lower.maskAlpha.empty() ? nullptr : lower.maskAlpha.data(), + lower.blendMode, lower.opacity, true}; + inputs[1] = {upper.buffer.data().data(), upper.maskAlpha.empty() ? nullptr : upper.maskAlpha.data(), + upper.blendMode, upper.opacity, true}; + PaintLayerBlend::compositeLayers(lower.buffer.width(), lower.buffer.height(), inputs, composite); + std::memcpy(lower.buffer.data().data(), composite.data(), composite.size()); + lower.buffer.markDirty(0, 0, lower.buffer.width(), lower.buffer.height()); + lower.blendMode = PaintLayerBlend::Mode::Normal; + lower.opacity = 1.f; + lower.maskAlpha.clear(); + removeLayer(index); + m_activeIndex = index - 1; +} + +void PaintLayerStack::flattenAll() +{ + if (layerCount() <= 1) return; + std::vector flat; + compositeTo(flat); + Layer merged; + merged.name = numberedLayerName(0); + merged.buffer.resize(width(), height()); + std::memcpy(merged.buffer.data().data(), flat.data(), flat.size()); + merged.buffer.clearDirty(); + m_layers.clear(); + m_layers.push_back(std::move(merged)); + m_activeIndex = 0; + m_soloIndex = -1; +} + +void PaintLayerStack::setActiveIndex(int index) +{ + if (index < 0 || index >= layerCount()) return; + m_activeIndex = index; +} + +void PaintLayerStack::setVisible(int index, bool visible) +{ + layer(index).visible = visible; +} + +void PaintLayerStack::setLocked(int index, bool locked) +{ + layer(index).locked = locked; +} + +void PaintLayerStack::setOpacity(int index, float opacity) +{ + layer(index).opacity = std::clamp(opacity, 0.f, 1.f); +} + +void PaintLayerStack::setBlendMode(int index, PaintLayerBlend::Mode mode) +{ + layer(index).blendMode = mode; +} + +void PaintLayerStack::setSolo(int index, bool solo) +{ + if (solo && index >= 0 && index < layerCount()) + m_soloIndex = index; + else if (m_soloIndex == index) + m_soloIndex = -1; +} + +void PaintLayerStack::clearSolo() +{ + m_soloIndex = -1; +} + +void PaintLayerStack::compositeTo(std::vector& out) const +{ + const int w = width(); + const int h = height(); + if (w <= 0 || h <= 0) { + out.clear(); + return; + } + + std::vector inputs; + inputs.reserve(static_cast(layerCount())); + for (int i = 0; i < layerCount(); ++i) { + const Layer& L = layer(i); + const bool show = (m_soloIndex >= 0) ? (i == m_soloIndex) : L.visible; + inputs.push_back({ + L.buffer.data().data(), + L.maskAlpha.empty() ? nullptr : L.maskAlpha.data(), + L.blendMode, + L.opacity, + show, + }); + } + PaintLayerBlend::compositeLayers(w, h, inputs, out); +} + +void PaintLayerStack::compositeTo(TexturePaintBuffer& out) const +{ + std::vector pixels; + compositeTo(pixels); + if (pixels.empty()) return; + const int w = width(); + const int h = height(); + out.resize(w, h); + std::memcpy(out.data().data(), pixels.data(), pixels.size()); + out.markDirty(0, 0, w, h); +} + +TexturePaintBuffer::DirtyRect PaintLayerStack::layerDirtyUnion() const +{ + TexturePaintBuffer::DirtyRect u; + for (const auto& L : m_layers) { + const auto& d = L.buffer.dirtyRect(); + if (d.empty()) continue; + if (u.empty()) { + u = d; + continue; + } + u.x0 = std::min(u.x0, d.x0); + u.y0 = std::min(u.y0, d.y0); + u.x1 = std::max(u.x1, d.x1); + u.y1 = std::max(u.y1, d.y1); + } + return u; +} + +void PaintLayerStack::compositeRegionTo(uint8_t* outRgba, int x0, int y0, int x1, int y1) const +{ + const int w = width(); + const int h = height(); + if (!outRgba || w <= 0 || h <= 0) return; + + std::vector inputs; + inputs.reserve(static_cast(layerCount())); + for (int i = 0; i < layerCount(); ++i) { + const Layer& L = layer(i); + const bool show = (m_soloIndex >= 0) ? (i == m_soloIndex) : L.visible; + inputs.push_back({ + L.buffer.data().data(), + L.maskAlpha.empty() ? nullptr : L.maskAlpha.data(), + L.blendMode, + L.opacity, + show, + }); + } + PaintLayerBlend::compositeLayersRegion(w, h, inputs, outRgba, x0, y0, x1, y1); +} + +PaintLayerStack::Snapshot PaintLayerStack::snapshot() const +{ + Snapshot s; + s.layers = m_layers; + s.activeIndex = m_activeIndex; + s.soloIndex = m_soloIndex; + return s; +} + +void PaintLayerStack::restore(const Snapshot& snap) +{ + m_layers = snap.layers; + m_activeIndex = snap.activeIndex; + m_soloIndex = snap.soloIndex; + clampActiveIndex(); +} + +std::vector& PaintLayerStack::ensureLayerMask(int index) +{ + Layer& L = layer(index); + const size_t n = static_cast(L.buffer.width()) * static_cast(L.buffer.height()); + if (L.maskAlpha.size() != n) + L.maskAlpha.assign(n, 255); + return L.maskAlpha; +} + +bool PaintLayerStack::hasLayerMask(int index) const +{ + return !layer(index).maskAlpha.empty(); +} + +void PaintLayerStack::clampActiveIndex() +{ + if (m_layers.empty()) { + m_activeIndex = 0; + return; + } + m_activeIndex = std::clamp(m_activeIndex, 0, layerCount() - 1); +} diff --git a/src/PaintLayerStack.h b/src/PaintLayerStack.h new file mode 100644 index 00000000..dbcd5346 --- /dev/null +++ b/src/PaintLayerStack.h @@ -0,0 +1,112 @@ +#ifndef PAINTLAYERSTACK_H +#define PAINTLAYERSTACK_H + +#include "PaintLayerBlend.h" +#include "PaintSelectionMask.h" +#include "TexturePaintBuffer.h" + +#include +#include + +/** + * @brief Ordered paint layers for one texture paint session (#546). + * + * Pure data core — no Qt/Ogre beyond QString for names. Owned by + * TexturePaintController (the paint QML singleton). + */ +class PaintLayerStack +{ +public: + enum class LayerType { + Paint = 0, + Fill, + Gradient, + Generated, + }; + + struct Layer { + QString name; + LayerType type = LayerType::Paint; + PaintLayerBlend::Mode blendMode = PaintLayerBlend::Mode::Normal; + float opacity = 1.f; + bool visible = true; + bool locked = false; + TexturePaintBuffer buffer; + /// Per-layer mask: 255 = fully visible, 0 = fully hidden. + /// Empty vector means no mask (all visible). + std::vector maskAlpha; + }; + + /// Full stack snapshot for undo of structural ops. + struct Snapshot { + std::vector layers; + int activeIndex = 0; + int soloIndex = -1; + }; + + int width() const; + int height() const; + bool empty() const { return m_layers.empty(); } + int layerCount() const { return static_cast(m_layers.size()); } + int activeIndex() const { return m_activeIndex; } + int soloIndex() const { return m_soloIndex; } + + const Layer& layer(int index) const; + Layer& layer(int index); + Layer& activeLayer(); + const Layer& activeLayer() const; + + /// Create a stack from a flat buffer (migration / session init). + /// Empty @p layerName defaults to "Layer 0". + void initFromFlatBuffer(const TexturePaintBuffer& flat, const QString& layerName = QString()); + + void resizeAll(int width, int height); + + /// Returns index of the new layer. + int addEmpty(const QString& name, LayerType type = LayerType::Paint); + int addFromBuffer(const TexturePaintBuffer& src, const QString& name, + LayerType type = LayerType::Paint); + int duplicateLayer(int index); + void removeLayer(int index); + void moveLayer(int from, int to); + void renameLayer(int index, const QString& name); + void mergeDown(int index); + void flattenAll(); + + void setActiveIndex(int index); + void setVisible(int index, bool visible); + void setLocked(int index, bool locked); + void setOpacity(int index, float opacity); + void setBlendMode(int index, PaintLayerBlend::Mode mode); + void setSolo(int index, bool solo); + void clearSolo(); + + /// Composite visible layers into `out` RGBA8 buffer. + void compositeTo(std::vector& out) const; + void compositeTo(TexturePaintBuffer& out) const; + /// Recomposite only a pixel region into an existing RGBA8 buffer. + void compositeRegionTo(uint8_t* outRgba, int x0, int y0, int x1, int y1) const; + + /// Union of every layer buffer's dirty rect (empty when none pending). + TexturePaintBuffer::DirtyRect layerDirtyUnion() const; + + Snapshot snapshot() const; + void restore(const Snapshot& snap); + + /// Ensure layer mask is sized; returns mutable mask bytes. + std::vector& ensureLayerMask(int index); + bool hasLayerMask(int index) const; + +private: + void clampActiveIndex(); + static QString numberedLayerName(int index); + int nextLayerNumber() const; + QString allocateLayerName() const; + static QString uniqueLayerName(const std::vector& layers, const QString& base); + + std::vector m_layers; + int m_activeIndex = 0; + int m_soloIndex = -1; ///< -1 = no solo +}; + +#endif // PAINTLAYERSTACK_H diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 521c19f2..54b973dd 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include @@ -68,12 +69,12 @@ TexturePaintController* TexturePaintController::s_instance = nullptr; namespace { -/// Undo command for one texture-paint stroke. Snapshots the buffer -/// before/after and restores via memcpy into the Ogre texture. +/// Undo command for one texture-paint stroke on a single layer. class TexturePaintStrokeCommand : public QUndoCommand { public: TexturePaintStrokeCommand(TexturePaintController* controller, + int layerIndex, std::vector before, std::vector after, int width, @@ -81,6 +82,7 @@ class TexturePaintStrokeCommand : public QUndoCommand QString textureName) : QUndoCommand(QStringLiteral("Texture paint")) , m_controller(controller) + , m_layerIndex(layerIndex) , m_before(std::move(before)) , m_after(std::move(after)) , m_width(width) @@ -104,18 +106,55 @@ class TexturePaintStrokeCommand : public QUndoCommand { if (!m_controller) return; if (m_controller->currentTextureName() != m_textureName) return; - const auto& buf = m_controller->buffer(); - if (buf.width() != m_width || buf.height() != m_height) return; - m_controller->applyPixelSnapshot(pixels); + m_controller->applyLayerPixelSnapshot(m_layerIndex, pixels); } TexturePaintController* m_controller = nullptr; + int m_layerIndex = 0; std::vector m_before; std::vector m_after; int m_width = 0; int m_height = 0; QString m_textureName; - bool m_skipFirstRedo = true; // command is pushed *after* the stroke applied + bool m_skipFirstRedo = true; +}; + +/// Undo command for structural layer-stack changes (#546). +class PaintLayerOpCommand : public QUndoCommand +{ +public: + PaintLayerOpCommand(TexturePaintController* controller, + QString label, + QString textureName, + PaintLayerStack::Snapshot before, + PaintLayerStack::Snapshot after) + : QUndoCommand(std::move(label)) + , m_controller(controller) + , m_textureName(std::move(textureName)) + , m_before(std::move(before)) + , m_after(std::move(after)) + {} + + void undo() override { apply(m_before); } + void redo() override + { + if (m_skipFirstRedo) { m_skipFirstRedo = false; return; } + apply(m_after); + } + +private: + void apply(const PaintLayerStack::Snapshot& snap) + { + if (!m_controller) return; + if (m_controller->currentTextureName() != m_textureName) return; + m_controller->applyLayerStackSnapshot(snap); + } + + TexturePaintController* m_controller = nullptr; + QString m_textureName; + PaintLayerStack::Snapshot m_before; + PaintLayerStack::Snapshot m_after; + bool m_skipFirstRedo = true; }; /// Undo command for a one-shot selection-mask action (fill FG / fill BG @@ -127,6 +166,7 @@ class TexturePaintMaskActionCommand : public QUndoCommand { public: TexturePaintMaskActionCommand(TexturePaintController* controller, + int layerIndex, std::vector before, std::vector after, int width, @@ -135,6 +175,7 @@ class TexturePaintMaskActionCommand : public QUndoCommand QString label) : QUndoCommand(label) , m_controller(controller) + , m_layerIndex(layerIndex) , m_before(std::move(before)) , m_after(std::move(after)) , m_width(width) @@ -154,12 +195,11 @@ class TexturePaintMaskActionCommand : public QUndoCommand { if (!m_controller) return; if (m_controller->currentTextureName() != m_textureName) return; - const auto& buf = m_controller->buffer(); - if (buf.width() != m_width || buf.height() != m_height) return; - m_controller->applyPixelSnapshot(pixels); + m_controller->applyLayerPixelSnapshot(m_layerIndex, pixels); } TexturePaintController* m_controller = nullptr; + int m_layerIndex = 0; std::vector m_before; std::vector m_after; int m_width = 0; @@ -256,6 +296,25 @@ bool canWriteOriginalTextureInPlace(const Ogre::TexturePtr& tex, } /// Pixel payload copied on a worker thread, consumed on the main thread. +namespace { + +void copyBufferRect(const TexturePaintBuffer& src, TexturePaintBuffer& dst, + const TexturePaintBuffer::DirtyRect& rect) +{ + const int W = src.width(); + if (W <= 0 || rect.empty()) return; + auto& dstData = dst.data(); + const auto& srcData = src.data(); + for (int row = rect.y0; row < rect.y1; ++row) { + const size_t off = (static_cast(row) * static_cast(W) + + static_cast(rect.x0)) * 4u; + const size_t bytes = static_cast(rect.width()) * 4u; + std::memcpy(dstData.data() + off, srcData.data() + off, bytes); + } +} + +} // namespace + struct GpuUploadPacket { std::vector rgba; int x0 = 0; @@ -268,6 +327,8 @@ struct GpuUploadPacket { }; constexpr int kPaintUploadTilePx = 64; +constexpr int kStrokeDirectUploadMaxPx = 512 * 512; +constexpr int kStrokeTilesPerTick = 8; // CPU-side sources first — same order as MaterialEditorQML::previewUrlFromOgreTexture. // GPU readback (convertToImage / blitToMemory) is unreliable for imported FBX textures. @@ -1350,7 +1411,7 @@ bool TexturePaintController::paintColorFootprintAtUV(const Ogre::Vector2& uv, fl const float radiusCopy = radiusUv; const BrushFootprint::ImageRgba& tilingRef = m_tilingImage; const BrushFootprint::TilingSettings& settingsRef = m_tilingSettings; - return m_buffer.paintBrush( + return activePaintBuffer().paintBrush( uv, radiusUv, [colorAt, center, radiusCopy, &tilingRef, &settingsRef](float dx, float dy) { float tu = 0.0f; @@ -1389,7 +1450,7 @@ bool TexturePaintController::paintColorFootprintAtUV(const Ogre::Vector2& uv, fl strength, m_stampSettings.opacityJitter, r3); const float angle = BrushFootprint::stampRotationRad( m_stampSettings, strokeDirectionRad(), static_cast(rng->generateDouble())); - return m_buffer.paintStamp( + return activePaintBuffer().paintStamp( stampUv, stampRadius, m_stampCache, angle, colorAt, stampStrength) > 0; } @@ -1397,14 +1458,14 @@ bool TexturePaintController::paintColorFootprintAtUV(const Ogre::Vector2& uv, fl if (m_colorSource != ColorGradient) { const QColor qc = texturePaintColor(); const Ogre::ColourValue paint(qc.redF(), qc.greenF(), qc.blueF(), qc.alphaF()); - return m_buffer.paintBrush(uv, radiusUv, paint, strength, falloff, shape) > 0; + return activePaintBuffer().paintBrush(uv, radiusUv, paint, strength, falloff, shape) > 0; } if (m_gradientMode == GradientLinear) { const auto sampled = colorAt(0.0f, 0.0f); - return m_buffer.paintBrush(uv, radiusUv, sampled, strength, falloff, shape) > 0; + return activePaintBuffer().paintBrush(uv, radiusUv, sampled, strength, falloff, shape) > 0; } - return m_buffer.paintBrush(uv, radiusUv, colorAt, strength, falloff, shape) > 0; + return activePaintBuffer().paintBrush(uv, radiusUv, colorAt, strength, falloff, shape) > 0; } void TexturePaintController::setPaintTarget(int target) @@ -1908,9 +1969,15 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) m_mask.resize(m_buffer.width(), m_buffer.height()); m_maskOverlayUri.clear(); + // Paint v2 Slice C (#546): wrap the flat texture in a layer stack. + m_layerStack.initFromFlatBuffer(m_buffer); + recomposeComposite(/*fullBuffer=*/true); + m_layerStrokeBaseline = snapshotActiveLayerPixels(); + refreshPreviewUri(); if (m_uvOverlayVisible) refreshUvOverlay(); emit sessionChanged(); + emit layersChanged(); emit smartSelectChanged(); return true; } @@ -2061,15 +2128,69 @@ void TexturePaintController::scheduleRebindToPaintTexture(Ogre::Entity* entity) }); } +void TexturePaintController::recomposePaintBufferIfNeeded() +{ + if (m_layerStack.layerCount() > 0 && !m_layerStack.layerDirtyUnion().empty()) + recomposeComposite(/*fullBuffer=*/false); +} + +void TexturePaintController::scheduleThrottledLiveGpuFlush() +{ + if (m_buffer.dirtyRect().empty()) return; + + if (!m_strokeLiveUploadStarted) { + m_strokeLiveUploadStarted = true; + QTimer::singleShot(0, this, [this]() { flushLiveStrokeToGpu(); }); + return; + } + + if (m_strokeGpuFlushScheduled) { + m_strokeGpuFlushPending = true; + return; + } + m_strokeGpuFlushScheduled = true; + QTimer::singleShot(16, this, [this]() { + m_strokeGpuFlushScheduled = false; + flushLiveStrokeToGpu(); + if (m_strokeGpuFlushPending) { + m_strokeGpuFlushPending = false; + scheduleThrottledLiveGpuFlush(); + } + }); +} + +bool TexturePaintController::flushLiveStrokeToGpu() +{ + const auto dirty = m_buffer.dirtyRect(); + if (dirty.empty()) return true; + + if (m_paintMeshEntity && m_ogreTexture && m_boundSlots.empty()) + rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); + + if (blitBufferRectToOgreTexture(dirty.x0, dirty.y0, dirty.x1, dirty.y1)) + m_buffer.clearDirty(); + return m_buffer.dirtyRect().empty(); +} + void TexturePaintController::flushDirtyToOgre() { + recomposePaintBufferIfNeeded(); if (m_buffer.dirtyRect().empty()) return; if (m_strokeActive) { - scheduleStrokeGpuFlush(); + schedulePreviewRefresh(); + // Preview-panel strokes are low-frequency — upload immediately so + // the 2D thumbnail and 3D mesh stay in lockstep. Viewport drags + // stay throttled (~60 Hz) to avoid flooding the GL driver. + if (m_strokeFromUvPreview) + flushLiveStrokeToGpu(); + else + scheduleThrottledLiveGpuFlush(); return; } + schedulePreviewRefresh(); + const int debounceMs = 33; if (m_gpuFlushScheduled) return; m_gpuFlushScheduled = true; @@ -2080,6 +2201,15 @@ void TexturePaintController::flushDirtyToOgre() }); } +void TexturePaintController::cancelInFlightGpuUpload() +{ + if (!m_tiledUploadRunning && m_tiledUploadQueue.empty()) + return; + m_tiledUploadRunning = false; + m_tiledUploadQueue.clear(); + m_tiledUploadIndex = 0; +} + void TexturePaintController::scheduleStrokeGpuFlush() { if (m_buffer.dirtyRect().empty()) return; @@ -2087,11 +2217,15 @@ void TexturePaintController::scheduleStrokeGpuFlush() auto kickUpload = [this]() { if (m_buffer.dirtyRect().empty()) return; if (m_tiledUploadRunning) { - m_strokeGpuFlushPending = true; - return; + if (m_activeUploadGeneration != m_gpuUploadGeneration) { + cancelInFlightGpuUpload(); + } else { + m_strokeGpuFlushPending = true; + return; + } } m_strokeGpuFlushPending = false; - startTiledGpuUpload(m_strokeEndAfterUpload); + startTiledGpuUpload(/*finishingStroke=*/false); }; // First dab in a stroke: upload on the next event-loop tick so colour @@ -2102,9 +2236,12 @@ void TexturePaintController::scheduleStrokeGpuFlush() return; } - if (m_strokeGpuFlushScheduled) return; + if (m_strokeGpuFlushScheduled) { + m_strokeGpuFlushPending = true; + return; + } m_strokeGpuFlushScheduled = true; - QTimer::singleShot(16, this, [this, kickUpload]() { + QTimer::singleShot(8, this, [this, kickUpload]() { m_strokeGpuFlushScheduled = false; kickUpload(); }); @@ -2163,14 +2300,33 @@ bool TexturePaintController::blitBufferRectToOgreTexture(int x0, int y0, int x1, void TexturePaintController::startTiledGpuUpload(bool finishingStroke) { + if (m_tiledUploadRunning) { + if (m_activeUploadGeneration != m_gpuUploadGeneration) + cancelInFlightGpuUpload(); + else + return; + } + const auto dirty = m_buffer.dirtyRect(); if (dirty.empty()) { - if (finishingStroke) - finishStrokeAfterGpuUpload(); + m_uploadFinishingStroke = false; + return; + } + + m_uploadFinishingStroke = finishingStroke; + m_activeUploadGeneration = m_gpuUploadGeneration; + m_uploadEpochSnapshot = m_bufferDirtyEpoch; + + const int dirtyPx = dirty.width() * dirty.height(); + if (m_strokeActive && dirtyPx <= kStrokeDirectUploadMaxPx) { + if (blitBufferRectToOgreTexture(dirty.x0, dirty.y0, dirty.x1, dirty.y1) + && m_bufferDirtyEpoch == m_uploadEpochSnapshot) { + m_buffer.clearDirty(); + } + onTiledUploadPassComplete(); return; } - m_strokeEndAfterUpload = finishingStroke; m_uploadPassDirty = dirty; m_tiledUploadQueue.clear(); for (int ty = dirty.y0; ty < dirty.y1; ty += kPaintUploadTilePx) { @@ -2185,11 +2341,8 @@ void TexturePaintController::startTiledGpuUpload(bool finishingStroke) } m_tiledUploadIndex = 0; m_tiledUploadRunning = !m_tiledUploadQueue.empty(); - if (!m_tiledUploadRunning) { - if (finishingStroke) - finishStrokeAfterGpuUpload(); + if (!m_tiledUploadRunning) return; - } if (gpuUploadTargetTexture() == m_ogreTexture && m_paintMeshEntity && m_boundSlots.empty() && m_ogreTexture) { rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); @@ -2200,13 +2353,37 @@ void TexturePaintController::startTiledGpuUpload(bool finishingStroke) void TexturePaintController::processNextTiledUploadTile() { if (!m_tiledUploadRunning) return; + if (m_activeUploadGeneration != m_gpuUploadGeneration) { + m_tiledUploadRunning = false; + m_tiledUploadQueue.clear(); + m_tiledUploadIndex = 0; + return; + } + if (m_tiledUploadIndex >= static_cast(m_tiledUploadQueue.size())) { + onTiledUploadPassComplete(); + return; + } + + const bool fastUpload = m_strokeActive || m_uploadFinishingStroke; + const int batch = fastUpload ? kStrokeTilesPerTick : 1; + for (int b = 0; b < batch && m_tiledUploadIndex < static_cast(m_tiledUploadQueue.size()); + ++b) { + const UploadTile& t = m_tiledUploadQueue[static_cast(m_tiledUploadIndex++)]; + blitBufferRectToOgreTexture(t.x0, t.y0, t.x1, t.y1); + } + 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(); }); + + const int delayMs = fastUpload ? 0 : 1; + const uint64_t uploadGen = m_activeUploadGeneration; + QTimer::singleShot(delayMs, this, [this, uploadGen]() { + if (!m_tiledUploadRunning || uploadGen != m_activeUploadGeneration) + return; + processNextTiledUploadTile(); + }); } void TexturePaintController::onTiledUploadPassComplete() @@ -2215,75 +2392,87 @@ void TexturePaintController::onTiledUploadPassComplete() 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); + if (m_activeUploadGeneration != m_gpuUploadGeneration) { + if (m_strokeActive && !m_buffer.dirtyRect().empty()) + scheduleStrokeGpuFlush(); + else if (m_strokeGpuFlushPending && !m_buffer.dirtyRect().empty()) + scheduleStrokeGpuFlush(); + schedulePreviewRefresh(); return; } - m_buffer.clearDirty(); - - if (!m_buffer.dirtyRect().empty()) { - startTiledGpuUpload(m_strokeEndAfterUpload); + if (m_bufferDirtyEpoch != m_uploadEpochSnapshot) { + startTiledGpuUpload(m_uploadFinishingStroke); return; } - if (m_strokeEndAfterUpload) { - m_strokeEndAfterUpload = false; - finishStrokeAfterGpuUpload(); - return; - } + m_buffer.clearDirty(); + m_uploadFinishingStroke = false; 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(); - }); - } + schedulePreviewRefresh(); } -void TexturePaintController::finishStrokeAfterGpuUpload() +void TexturePaintController::commitStrokeUndo(std::vector prePixels, int layerIndex) { - // 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(); }); + if (prePixels.empty()) return; + + auto after = snapshotActiveLayerPixels(); + UndoManager::getSingleton()->push( + new TexturePaintStrokeCommand( + this, + layerIndex, + std::move(prePixels), + std::move(after), + activePaintBuffer().width(), + activePaintBuffer().height(), + m_textureName)); + m_layerStrokeBaseline = snapshotActiveLayerPixels(); } -void TexturePaintController::ensureStrokePreSnapshot() +void TexturePaintController::resetStrokePaintState() { - if (m_target != TargetTexture) return; - if (!m_strokePreSnapshot.empty()) return; - m_strokePreSnapshot = snapshotPixels(); + m_strokeJustBegan = true; + m_smudgeHavePrev = false; + m_strokeLiveUploadStarted = false; + m_strokeGpuFlushPending = false; + m_strokeGpuFlushScheduled = false; + m_uploadFinishingStroke = false; + m_wandStrokeActive = false; + m_strokeHavePrevUV = false; + m_strokePathLength = 0.0f; + m_lastStampDabPathLength = 0.0f; + m_strokeDirSmoothed = Ogre::Vector2::ZERO; + m_strokeHaveHitScreen = false; + m_strokeUvPerScreenX = 0.0f; + m_strokeUvPerScreenY = 0.0f; + m_strokeMadeChanges = false; + m_strokePhaseJitter = (m_rampJitter > 0.0) + ? static_cast(QRandomGenerator::global()->generateDouble() * m_rampJitter) + : 0.0f; } -void TexturePaintController::finishStrokeAfterGpuUploadDeferred() +void TexturePaintController::invalidateLayerStrokeBaseline() { - 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(); }); + m_layerStrokeBaseline.clear(); +} + +void TexturePaintController::scheduleEmbeddedTextureCacheUpdate() +{ + if (m_embeddedCacheUpdateScheduled) return; + m_embeddedCacheUpdateScheduled = true; + QTimer::singleShot(300, this, [this]() { + m_embeddedCacheUpdateScheduled = false; + if (m_strokeActive) { + scheduleEmbeddedTextureCacheUpdate(); + return; + } + updateEmbeddedTextureCache(); + }); } bool TexturePaintController::localPointFromHitCache(const Ogre::Vector2& uv, @@ -2320,34 +2509,6 @@ bool TexturePaintController::localPointFromHitCache(const Ogre::Vector2& uv, 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) @@ -2438,12 +2599,6 @@ void TexturePaintController::processPendingStrokeUpdate() 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 @@ -2455,13 +2610,14 @@ void TexturePaintController::processPendingStrokeUpdate() else changed = applyBrushAtUV(uv); - if (canSegment || (m_tool != ToolPaint || m_colorSource != ColorGradient)) { + if (changed) { m_strokePrevUV = uv; m_strokeHavePrevUV = true; - } - - if (changed) + m_strokeMadeChanges = true; + if (m_layerStack.layerCount() <= 0) + ++m_bufferDirtyEpoch; flushDirtyToOgre(); + } } void TexturePaintController::processPendingStrokeUpdateUV() @@ -2484,8 +2640,6 @@ void TexturePaintController::processPendingStrokeUpdateUV() return; } - ensureStrokePreSnapshot(); - bool changed = false; const bool canSegment = m_strokeHavePrevUV @@ -2497,13 +2651,14 @@ void TexturePaintController::processPendingStrokeUpdateUV() else changed = applyBrushAtUV(uv); - if (canSegment || (m_tool != ToolPaint || m_colorSource != ColorGradient)) { + if (changed) { m_strokePrevUV = uv; m_strokeHavePrevUV = true; - } - - if (changed) + m_strokeMadeChanges = true; + if (m_layerStack.layerCount() <= 0) + ++m_bufferDirtyEpoch; flushDirtyToOgre(); + } } void TexturePaintController::doFlushDirtyToOgre(bool immediate) @@ -2576,13 +2731,7 @@ void TexturePaintController::doFlushDirtyToOgre(bool immediate) .arg(m_originalTexture->getHeight())); } m_buffer.clearDirty(); - if (!m_strokeActive && !m_previewRefreshScheduled) { - m_previewRefreshScheduled = true; - QTimer::singleShot(60, this, [this]() { - m_previewRefreshScheduled = false; - refreshPreviewUri(); - }); - } + schedulePreviewRefresh(); return; } } @@ -2661,13 +2810,7 @@ void TexturePaintController::doFlushDirtyToOgre(bool immediate) 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(); - }); - } + schedulePreviewRefresh(); }; if (immediate) { @@ -2844,26 +2987,16 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree } } } + if (m_tiledUploadRunning) + cancelInFlightGpuUpload(); + ++m_gpuUploadGeneration; + ++m_strokeUndoGeneration; m_strokeActive = true; - m_strokeJustBegan = true; - m_smudgeHavePrev = false; + m_strokeFromUvPreview = false; m_strokePreSnapshot.clear(); - m_strokeLiveUploadStarted = false; - m_strokeGpuFlushPending = false; - m_strokeGpuFlushScheduled = false; - m_wandStrokeActive = false; + resetStrokePaintState(); m_wandStartScreenPos = screenPos; - m_strokeHavePrevUV = false; - m_strokePathLength = 0.0f; - m_lastStampDabPathLength = 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", @@ -2887,6 +3020,11 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree .arg(texturePaintRadius(), 0, 'f', 3) .arg(texturePaintStrength(), 0, 'f', 3) .arg(texturePaintColor().name(QColor::HexRgb))); + if (m_target == TargetTexture) { + if (m_layerStrokeBaseline.empty()) + m_layerStrokeBaseline = snapshotActiveLayerPixels(); + m_strokePreSnapshot = std::move(m_layerStrokeBaseline); + } if (m_target == TargetTexture && m_paintMeshEntity && m_ogreTexture && m_boundSlots.empty()) { rebindEntityDiffuseToPaintTexture(m_paintMeshEntity); @@ -2900,12 +3038,16 @@ bool TexturePaintController::beginStroke(OgreWidget* widget, const QPoint& scree void TexturePaintController::updateStroke(OgreWidget* widget, const QPoint& screenPos) { if (!m_strokeActive || !m_paintEnabled) return; - scheduleStrokeUpdate(widget, screenPos); + m_pendingStrokeWidget = widget; + m_pendingStrokePos = screenPos; + processPendingStrokeUpdate(); } bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) { if (m_buffer.width() <= 0) return false; + if (m_layerStack.layerCount() > 0 && m_layerStack.activeLayer().locked) + return false; const float radius = brushRadiusUV(); const float strength = static_cast(texturePaintStrength()); const float falloff = static_cast(texturePaintFalloff()); @@ -2935,7 +3077,7 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) static_cast(bg.greenF()), static_cast(bg.blueF()), static_cast(bg.alphaF())); - return m_buffer.paintBrush(uv, radius, eraseTo, strength, falloff, shape) > 0; + return activePaintBuffer().paintBrush(uv, radius, eraseTo, strength, falloff, shape) > 0; } case ToolFill: { // Fill is a single-stamp operation — apply once per stroke @@ -2993,14 +3135,14 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) int sx = 0, sy = 0; m_buffer.uvToPixel(sampleUV, sx, sy); const auto src = m_buffer.pixel(sx, sy); - const auto dst = m_buffer.pixel(x, y); + const auto dst = activePaintBuffer().pixel(x, y); if (src.a <= 0.0f) continue; const Ogre::ColourValue blended( dst.r + (src.r - dst.r) * blend, dst.g + (src.g - dst.g) * blend, dst.b + (src.b - dst.b) * blend, dst.a + (src.a - dst.a) * blend); - m_buffer.setPixel(x, y, blended); + activePaintBuffer().setPixel(x, y, blended); changed = true; touchedX0 = std::min(touchedX0, x); touchedY0 = std::min(touchedY0, y); @@ -3038,10 +3180,10 @@ bool TexturePaintController::applyBrushAtUV(const Ogre::Vector2& uv) bool TexturePaintController::floodFillAtUV(const Ogre::Vector2& uv) { int sx = 0, sy = 0; - m_buffer.uvToPixel(uv, sx, sy); + activePaintBuffer().uvToPixel(uv, sx, sy); const QColor c = texturePaintColor(); const Ogre::ColourValue fill(c.redF(), c.greenF(), c.blueF(), c.alphaF()); - return m_buffer.floodFill(sx, sy, fill) > 0; + return activePaintBuffer().floodFill(sx, sy, fill) > 0; } void TexturePaintController::pickColorAtUV(const Ogre::Vector2& uv) @@ -3059,22 +3201,12 @@ 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. if (m_wandStrokeActive) { m_wandStrokeActive = false; m_strokePreSnapshot.clear(); + m_strokeActive = false; SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Wand stroke end (final tolerance=%1)") .arg(m_smartSelectTolerance, 0, 'f', 3)); @@ -3082,21 +3214,41 @@ void TexturePaintController::endStroke() } if (m_target == TargetVertex) { m_strokePreSnapshot.clear(); + m_strokeActive = false; SentryReporter::addBreadcrumb("ui.action", "Vertex paint stroke end"); return; } - 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; + m_strokeActive = false; + + recomposePaintBufferIfNeeded(); + if (!flushLiveStrokeToGpu()) + doFlushDirtyToOgre(/*immediate=*/true); + if (m_buffer.dirtyRect().empty()) + m_buffer.clearDirty(); + m_previewRefreshScheduled = false; + refreshPreviewUri(); + if (m_layerStack.layerCount() > 0) { + ++m_layerPreviewVersion; + emit layersChanged(); } - finishStrokeAfterGpuUpload(); + + std::vector undoPre = std::move(m_strokePreSnapshot); + const int undoLayer = + m_layerStack.layerCount() > 0 ? m_layerStack.activeIndex() : 0; + const bool strokeMadeChanges = m_strokeMadeChanges; + m_strokeFromUvPreview = false; + m_strokePreSnapshot.clear(); + const uint64_t undoGen = m_strokeUndoGeneration; + QTimer::singleShot(0, this, [this, undoGen, undoLayer, strokeMadeChanges, + pre = std::move(undoPre)]() mutable { + if (undoGen != m_strokeUndoGeneration) return; + if (strokeMadeChanges) + commitStrokeUndo(std::move(pre), undoLayer); + else + m_layerStrokeBaseline = std::move(pre); + scheduleEmbeddedTextureCacheUpdate(); + }); } void TexturePaintController::updateEmbeddedTextureCache() @@ -3137,6 +3289,8 @@ bool TexturePaintController::loadPaintBuffer(const QString& path) { if (path.isEmpty()) return false; if (!m_buffer.load(path.toStdString())) return false; + m_layerStack.initFromFlatBuffer(m_buffer); + recomposeComposite(/*fullBuffer=*/true); auto* entity = activeEntity(); if (entity) { QFileInfo fi(path); @@ -3152,6 +3306,7 @@ bool TexturePaintController::loadPaintBuffer(const QString& path) // with the loaded image's pixels. refreshPreviewUri(); emit sessionChanged(); + emit layersChanged(); return true; } @@ -3330,12 +3485,42 @@ void TexturePaintController::applyPixelSnapshot(const std::vector& pixe std::memcpy(m_buffer.data().data(), pixels.data(), pixels.size()); m_buffer.markDirty(0, 0, m_buffer.width(), m_buffer.height()); flushDirtyToOgre(); - // Undo / redo replaces the buffer entirely — the cache that - // backs exports needs to follow, otherwise FBX export sees the - // pixels from BEFORE the last mutation. updateEmbeddedTextureCache(); } +void TexturePaintController::applyLayerPixelSnapshot(int layerIndex, + const std::vector& pixels) +{ + if (m_layerStack.layerCount() <= 0) { + applyPixelSnapshot(pixels); + return; + } + if (layerIndex < 0 || layerIndex >= m_layerStack.layerCount()) return; + auto& layerBuf = m_layerStack.layer(layerIndex).buffer; + if (pixels.size() != layerBuf.data().size()) return; + std::memcpy(layerBuf.data().data(), pixels.data(), pixels.size()); + layerBuf.markDirty(0, 0, layerBuf.width(), layerBuf.height()); + m_layerStrokeBaseline.clear(); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + updateEmbeddedTextureCache(); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::applyLayerStackSnapshot(const PaintLayerStack::Snapshot& snap) +{ + m_layerStack.restore(snap); + m_layerStrokeBaseline.clear(); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + updateEmbeddedTextureCache(); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + void TexturePaintController::closeSession() { if (m_strokeActive) endStroke(); @@ -3398,6 +3583,8 @@ void TexturePaintController::closeSession() } m_paintMesh.reset(); m_paintMeshEntity = nullptr; + m_layerStack = PaintLayerStack(); + m_layerPreviewVersion = 0; m_buffer = TexturePaintBuffer(); m_textureName.clear(); m_originalTexture.reset(); @@ -3410,9 +3597,15 @@ void TexturePaintController::closeSession() m_strokeGpuFlushScheduled = false; m_tiledUploadRunning = false; m_tiledUploadQueue.clear(); - m_strokeEndAfterUpload = false; - m_sessionEntity = nullptr; + m_uploadFinishingStroke = false; + m_gpuUploadGeneration = 0; + m_activeUploadGeneration = 0; + m_bufferDirtyEpoch = 0; + m_uploadEpochSnapshot = 0; + m_strokeUndoGeneration = 0; + m_layerStrokeBaseline.clear(); m_strokePreSnapshot.clear(); + m_sessionEntity = nullptr; if (!m_uvOverlayUri.isEmpty()) { m_uvOverlayUri.clear(); emit uvOverlayChanged(); @@ -3441,24 +3634,18 @@ bool TexturePaintController::beginStrokeUV(double u, double v) if (!hasActiveSession()) if (!ensurePaintableTexture(1024)) return false; GamificationManager::noteFeature(QStringLiteral("texture_paint")); + if (m_tiledUploadRunning) + cancelInFlightGpuUpload(); + ++m_gpuUploadGeneration; + ++m_strokeUndoGeneration; m_strokeActive = true; - m_strokeJustBegan = true; - m_smudgeHavePrev = false; + m_strokeFromUvPreview = true; m_strokePreSnapshot.clear(); - m_strokeLiveUploadStarted = false; - m_strokeGpuFlushPending = false; - m_strokeGpuFlushScheduled = false; - m_wandStrokeActive = false; + resetStrokePaintState(); // 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; + m_pendingStrokeWidget = nullptr; if (m_colorSource == ColorGradient) { SentryReporter::addBreadcrumb( "paint.brush.gradient", @@ -3467,6 +3654,9 @@ bool TexturePaintController::beginStrokeUV(double u, double v) .arg(m_useFgBgRamp ? QStringLiteral("FG/BG") : m_activeRampName)); } emit hoveredUVChanged(u, v); + if (m_layerStrokeBaseline.empty()) + m_layerStrokeBaseline = snapshotActiveLayerPixels(); + m_strokePreSnapshot = std::move(m_layerStrokeBaseline); m_pendingStrokeU = u; m_pendingStrokeV = v; processPendingStrokeUpdateUV(); @@ -3476,7 +3666,9 @@ bool TexturePaintController::beginStrokeUV(double u, double v) void TexturePaintController::updateStrokeUV(double u, double v) { if (!m_strokeActive || !m_paintEnabled) return; - scheduleStrokeUpdateUV(u, v); + m_pendingStrokeU = u; + m_pendingStrokeV = v; + processPendingStrokeUpdateUV(); } void TexturePaintController::endStrokeUV() @@ -3641,6 +3833,25 @@ void TexturePaintController::refreshPreviewUri() emit fullResPreviewChanged(); } +void TexturePaintController::schedulePreviewRefresh() +{ + if (m_previewRefreshScheduled) return; + m_previewRefreshScheduled = true; + const int delayMs = m_strokeActive + ? (m_strokeFromUvPreview ? 16 : 33) + : 60; + QTimer::singleShot(delayMs, this, [this]() { + m_previewRefreshScheduled = false; + refreshPreviewUri(); + // Rebuilding the layer list thumbnails is expensive — skip while + // the brush is down; endStroke emits layersChanged once on release. + if (m_layerStack.layerCount() > 0 && !m_strokeActive) { + ++m_layerPreviewVersion; + emit layersChanged(); + } + }); +} + QString TexturePaintController::fullResPreviewUrl() const { // The trailing `?v=N` invalidates QML's Image cache on each @@ -4062,20 +4273,22 @@ int applyToMaskedPixels(TexturePaintBuffer& buf, const PaintSelectionMask& mask, int TexturePaintController::fillMaskWithFG() { if (!hasActiveSession() || !hasSelectionMask()) return 0; - auto before = m_buffer.data(); + auto& layerBuf = activePaintBuffer(); + auto before = layerBuf.data(); const QColor c = texturePaintColor(); const uint8_t fr = static_cast(std::lround(c.redF() * 255.0)); const uint8_t fg = static_cast(std::lround(c.greenF() * 255.0)); const uint8_t fb = static_cast(std::lround(c.blueF() * 255.0)); const uint8_t fa = static_cast(std::lround(c.alphaF() * 255.0)); - const int affected = applyToMaskedPixels(m_buffer, m_mask, + const int affected = applyToMaskedPixels(layerBuf, m_mask, [&](uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { r = fr; g = fg; b = fb; a = fa; }); if (affected <= 0) return 0; + const int layerIdx = m_layerStack.layerCount() > 0 ? m_layerStack.activeIndex() : 0; UndoManager::getSingleton()->push(new TexturePaintMaskActionCommand( - this, std::move(before), m_buffer.data(), - m_buffer.width(), m_buffer.height(), m_textureName, + this, layerIdx, std::move(before), layerBuf.data(), + layerBuf.width(), layerBuf.height(), m_textureName, QStringLiteral("Fill selection (FG)"))); SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Smart select: filled %1 px with FG %2") @@ -4088,20 +4301,22 @@ int TexturePaintController::fillMaskWithFG() int TexturePaintController::fillMaskWithBG() { if (!hasActiveSession() || !hasSelectionMask()) return 0; - auto before = m_buffer.data(); + auto& layerBuf = activePaintBuffer(); + auto before = layerBuf.data(); const QColor c = bgPaintColor(); const uint8_t fr = static_cast(std::lround(c.redF() * 255.0)); const uint8_t fg = static_cast(std::lround(c.greenF() * 255.0)); const uint8_t fb = static_cast(std::lround(c.blueF() * 255.0)); const uint8_t fa = static_cast(std::lround(c.alphaF() * 255.0)); - const int affected = applyToMaskedPixels(m_buffer, m_mask, + const int affected = applyToMaskedPixels(layerBuf, m_mask, [&](uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { r = fr; g = fg; b = fb; a = fa; }); if (affected <= 0) return 0; + const int layerIdx = m_layerStack.layerCount() > 0 ? m_layerStack.activeIndex() : 0; UndoManager::getSingleton()->push(new TexturePaintMaskActionCommand( - this, std::move(before), m_buffer.data(), - m_buffer.width(), m_buffer.height(), m_textureName, + this, layerIdx, std::move(before), layerBuf.data(), + layerBuf.width(), layerBuf.height(), m_textureName, QStringLiteral("Fill selection (BG)"))); SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Smart select: filled %1 px with BG %2") @@ -4114,15 +4329,17 @@ int TexturePaintController::fillMaskWithBG() int TexturePaintController::deleteMaskPixels() { if (!hasActiveSession() || !hasSelectionMask()) return 0; - auto before = m_buffer.data(); - const int affected = applyToMaskedPixels(m_buffer, m_mask, + auto& layerBuf = activePaintBuffer(); + auto before = layerBuf.data(); + const int affected = applyToMaskedPixels(layerBuf, m_mask, [](uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { r = 0; g = 0; b = 0; a = 0; }); if (affected <= 0) return 0; + const int layerIdx = m_layerStack.layerCount() > 0 ? m_layerStack.activeIndex() : 0; UndoManager::getSingleton()->push(new TexturePaintMaskActionCommand( - this, std::move(before), m_buffer.data(), - m_buffer.width(), m_buffer.height(), m_textureName, + this, layerIdx, std::move(before), layerBuf.data(), + layerBuf.width(), layerBuf.height(), m_textureName, QStringLiteral("Delete selection"))); SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Smart select: deleted %1 px").arg(affected)); @@ -4460,3 +4677,451 @@ void TexturePaintController::destroyMeshMaskOverlay() m_maskOverlayTex.reset(); } } + +TexturePaintBuffer& TexturePaintController::activePaintBuffer() +{ + if (m_layerStack.layerCount() > 0) + return m_layerStack.activeLayer().buffer; + return m_buffer; +} + +const TexturePaintBuffer& TexturePaintController::activePaintBuffer() const +{ + if (m_layerStack.layerCount() > 0) + return m_layerStack.activeLayer().buffer; + return m_buffer; +} + +void TexturePaintController::recomposeComposite(bool fullBuffer) +{ + if (m_layerStack.layerCount() <= 0) return; + + const int w = m_layerStack.width(); + const int h = m_layerStack.height(); + if (w <= 0 || h <= 0) return; + if (m_buffer.width() != w || m_buffer.height() != h) + m_buffer.resize(w, h); + + TexturePaintBuffer::DirtyRect dirty = fullBuffer + ? TexturePaintBuffer::DirtyRect{0, 0, w, h} + : m_layerStack.layerDirtyUnion(); + if (dirty.empty()) return; + + if (m_layerStack.layerCount() == 1 && !fullBuffer) { + const auto& L = m_layerStack.layer(0); + const bool trivialLayer = + L.visible + && L.opacity >= 1.f - 1e-4f + && L.blendMode == PaintLayerBlend::Mode::Normal + && L.maskAlpha.empty() + && !L.locked; + if (trivialLayer) + copyBufferRect(L.buffer, m_buffer, dirty); + else + m_layerStack.compositeRegionTo(m_buffer.data().data(), + dirty.x0, dirty.y0, dirty.x1, dirty.y1); + } else if (fullBuffer) { + std::vector pixels; + m_layerStack.compositeTo(pixels); + if (pixels.empty()) return; + std::memcpy(m_buffer.data().data(), pixels.data(), pixels.size()); + } else { + m_layerStack.compositeRegionTo(m_buffer.data().data(), + dirty.x0, dirty.y0, dirty.x1, dirty.y1); + } + + m_buffer.markDirty(dirty.x0, dirty.y0, dirty.x1, dirty.y1); + ++m_bufferDirtyEpoch; + for (int i = 0; i < m_layerStack.layerCount(); ++i) + m_layerStack.layer(i).buffer.clearDirty(); +} + +std::vector TexturePaintController::snapshotActiveLayerPixels() const +{ + return activePaintBuffer().data(); +} + +void TexturePaintController::pushLayerOpUndo(const QString& label, + PaintLayerStack::Snapshot before, + PaintLayerStack::Snapshot after) +{ + UndoManager::getSingleton()->push( + new PaintLayerOpCommand(this, label, m_textureName, + std::move(before), std::move(after))); +} + +int TexturePaintController::layerCount() const +{ + return m_layerStack.layerCount(); +} + +int TexturePaintController::activeLayerIndex() const +{ + return m_layerStack.activeIndex(); +} + +void TexturePaintController::setActiveLayerIndex(int index) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return; + if (m_layerStack.activeIndex() == index) return; + m_layerStack.setActiveIndex(index); + invalidateLayerStrokeBaseline(); + ++m_layerPreviewVersion; + emit layersChanged(); +} + +QVariantList TexturePaintController::paintLayers() const +{ + QVariantList out; + for (int i = 0; i < m_layerStack.layerCount(); ++i) { + const auto& L = m_layerStack.layer(i); + QVariantMap row; + row.insert(QStringLiteral("index"), i); + row.insert(QStringLiteral("name"), L.name); + row.insert(QStringLiteral("type"), static_cast(L.type)); + row.insert(QStringLiteral("blendMode"), static_cast(L.blendMode)); + row.insert(QStringLiteral("opacity"), static_cast(L.opacity)); + row.insert(QStringLiteral("visible"), L.visible); + row.insert(QStringLiteral("locked"), L.locked); + row.insert(QStringLiteral("active"), i == m_layerStack.activeIndex()); + row.insert(QStringLiteral("solo"), m_layerStack.soloIndex() == i); + row.insert(QStringLiteral("thumbnailUrl"), layerPreviewUrl(i)); + out.append(row); + } + return out; +} + +QStringList TexturePaintController::blendModeNames() const +{ + QStringList names; + for (int m = 0; m <= static_cast(PaintLayerBlend::Mode::Hue); ++m) + names << QString::fromLatin1( + PaintLayerBlend::modeName(static_cast(m))); + return names; +} + +int TexturePaintController::addPaintLayer(const QString& name) +{ + if (!hasActiveSession()) return -1; + const auto before = m_layerStack.snapshot(); + const int idx = m_layerStack.addEmpty(name); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Add layer"), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Added layer '%1'").arg(m_layerStack.layer(idx).name)); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); + return idx; +} + +void TexturePaintController::deletePaintLayer(int index) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return; + if (m_layerStack.layerCount() <= 1) return; + const auto before = m_layerStack.snapshot(); + const QString removed = m_layerStack.layer(index).name; + m_layerStack.removeLayer(index); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Delete layer"), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Deleted layer '%1'").arg(removed)); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +int TexturePaintController::duplicatePaintLayer(int index) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return -1; + const auto before = m_layerStack.snapshot(); + const int idx = m_layerStack.duplicateLayer(index); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Duplicate layer"), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Duplicated layer '%1'").arg(m_layerStack.layer(idx).name)); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); + return idx; +} + +void TexturePaintController::movePaintLayerUp(int index) +{ + if (index <= 0 || index >= m_layerStack.layerCount()) return; + const auto before = m_layerStack.snapshot(); + m_layerStack.moveLayer(index, index - 1); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Move layer"), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Moved layer up")); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::movePaintLayerDown(int index) +{ + if (index < 0 || index >= m_layerStack.layerCount() - 1) return; + const auto before = m_layerStack.snapshot(); + m_layerStack.moveLayer(index, index + 1); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Move layer"), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Moved layer down")); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::renamePaintLayer(int index, const QString& name) +{ + if (index < 0 || index >= m_layerStack.layerCount() || name.isEmpty()) return; + const auto before = m_layerStack.snapshot(); + m_layerStack.renameLayer(index, name); + pushLayerOpUndo(QStringLiteral("Rename layer"), before, m_layerStack.snapshot()); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Renamed layer to '%1'").arg(name)); + ++m_layerPreviewVersion; + emit layersChanged(); +} + +void TexturePaintController::mergePaintLayerDown(int index) +{ + if (index <= 0 || index >= m_layerStack.layerCount()) return; + const auto before = m_layerStack.snapshot(); + m_layerStack.mergeDown(index); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Merge down"), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Merged layer down at index %1").arg(index)); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::flattenPaintLayers() +{ + if (m_layerStack.layerCount() <= 1) return; + const auto before = m_layerStack.snapshot(); + m_layerStack.flattenAll(); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Flatten layers"), before, m_layerStack.snapshot()); + invalidateLayerStrokeBaseline(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Flattened layer stack")); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::setPaintLayerVisible(int index, bool visible) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return; + if (m_layerStack.layer(index).visible == visible) return; + const auto before = m_layerStack.snapshot(); + m_layerStack.setVisible(index, visible); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Layer visibility"), before, m_layerStack.snapshot()); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + visible ? QStringLiteral("Show layer") : QStringLiteral("Hide layer")); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::setPaintLayerLocked(int index, bool locked) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return; + if (m_layerStack.layer(index).locked == locked) return; + m_layerStack.setLocked(index, locked); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + locked ? QStringLiteral("Locked layer") : QStringLiteral("Unlocked layer")); + ++m_layerPreviewVersion; + emit layersChanged(); +} + +void TexturePaintController::setPaintLayerOpacity(int index, double opacity) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return; + const float clamped = static_cast(std::clamp(opacity, 0.0, 1.0)); + if (std::abs(m_layerStack.layer(index).opacity - clamped) < 1e-5f) + return; + + PaintLayerStack::Snapshot before; + if (!m_layerOpacityDragging) + before = m_layerStack.snapshot(); + m_layerStack.setOpacity(index, clamped); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + if (m_layerOpacityDragging) { + m_layerOpacityDragChanged = true; + } else { + pushLayerOpUndo(QStringLiteral("Layer opacity"), before, m_layerStack.snapshot()); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Opacity=%1").arg(clamped, 0, 'f', 2)); + } + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::beginPaintLayerOpacityDrag() +{ + if (!hasActiveSession() || m_layerStack.layerCount() <= 0) return; + m_layerOpacityDragBefore = m_layerStack.snapshot(); + m_layerOpacityDragging = true; + m_layerOpacityDragChanged = false; +} + +void TexturePaintController::endPaintLayerOpacityDrag() +{ + if (!m_layerOpacityDragging) return; + m_layerOpacityDragging = false; + if (m_layerOpacityDragChanged) { + const auto after = m_layerStack.snapshot(); + pushLayerOpUndo(QStringLiteral("Layer opacity"), m_layerOpacityDragBefore, after); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Opacity drag")); + } + m_layerOpacityDragBefore = {}; + m_layerOpacityDragChanged = false; +} + +void TexturePaintController::setPaintLayerBlendMode(int index, int mode) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return; + const auto before = m_layerStack.snapshot(); + m_layerStack.setBlendMode(index, static_cast(mode)); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Layer blend mode"), before, m_layerStack.snapshot()); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + PaintLayerBlend::modeName(static_cast(mode))); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +void TexturePaintController::setPaintLayerSolo(int index, bool solo) +{ + if (index < 0 || index >= m_layerStack.layerCount()) return; + const auto before = m_layerStack.snapshot(); + if (solo) + m_layerStack.setSolo(index, true); + else + m_layerStack.clearSolo(); + recomposeComposite(/*fullBuffer=*/true); + flushDirtyToOgre(); + pushLayerOpUndo(QStringLiteral("Layer solo"), before, m_layerStack.snapshot()); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + solo ? QStringLiteral("Solo on") : QStringLiteral("Solo off")); + ++m_layerPreviewVersion; + emit layersChanged(); + emit fullResPreviewChanged(); +} + +QString TexturePaintController::layerPreviewUrl(int index) const +{ + if (index < 0 || index >= m_layerStack.layerCount()) return {}; + return QStringLiteral("image://paintbuffer/layer/%1?v=%2") + .arg(index) + .arg(m_layerPreviewVersion); +} + +QImage TexturePaintController::snapshotLayerImage(int index) const +{ + if (index < 0 || index >= m_layerStack.layerCount()) return {}; + const auto& buf = m_layerStack.layer(index).buffer; + if (buf.width() <= 0 || buf.height() <= 0) return {}; + QImage view(const_cast(buf.data().data()), + buf.width(), buf.height(), + buf.width() * 4, QImage::Format_RGBA8888); + return view.copy(); +} + +bool TexturePaintController::confirmFlattenLayersForExport(QWidget* parent) const +{ + if (layerCount() <= 1 || !hasActiveSession() || !m_sessionEntity) + return true; + + const auto* sel = SelectionSet::getSingleton(); + if (!sel) return true; + + auto exportsPaintSession = [this](const Ogre::Entity* entity) { + return entity && entity == m_sessionEntity; + }; + + bool includesPaintSession = false; + if (sel->hasEntities()) { + for (Ogre::Entity* entity : sel->getEntitiesSelectionList()) { + if (exportsPaintSession(entity)) { + includesPaintSession = true; + break; + } + } + } else if (sel->hasNodes()) { + auto* sceneMgr = Manager::getSingletonPtr() + ? Manager::getSingleton()->getSceneMgr() + : nullptr; + if (sceneMgr) { + for (Ogre::SceneNode* node : sel->getNodesSelectionList()) { + if (!sceneMgr->hasEntity(node->getName())) continue; + if (exportsPaintSession(sceneMgr->getEntity(node->getName()))) { + includesPaintSession = true; + break; + } + } + } + } + + if (!includesPaintSession) + return true; + + if (!parent) + return true; + + const QString msg = tr( + "This mesh has %1 texture paint layers.\n\n" + "FBX, glTF, OBJ, and other export formats store a single flat " + "texture — the merged composite of all visible layers will be " + "written. Separate layer data is not preserved in the exported file.\n\n" + "Continue export?") + .arg(layerCount()); + + const auto choice = QMessageBox::warning(parent, tr("Flatten Texture Layers?"), msg, + QMessageBox::Ok | QMessageBox::Cancel, + QMessageBox::Ok); + SentryReporter::addBreadcrumb( + QStringLiteral("ui.action"), + choice == QMessageBox::Ok + ? QStringLiteral("Export flatten layers confirmed (%1 layers)").arg(layerCount()) + : QStringLiteral("Export flatten layers cancelled (%1 layers)").arg(layerCount())); + return choice == QMessageBox::Ok; +} + +void TexturePaintController::flushPaintTextureForExport(Ogre::Entity* entity) +{ + if (!entity || entity != m_sessionEntity || !hasActiveSession()) + return; + if (m_layerStack.layerCount() > 0) + recomposeComposite(/*fullBuffer=*/true); + if (m_buffer.width() > 0 && m_buffer.height() > 0) + m_buffer.markDirty(0, 0, m_buffer.width(), m_buffer.height()); + doFlushDirtyToOgre(/*immediate=*/true); + updateEmbeddedTextureCache(); + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("Flushed %1-layer composite before export") + .arg(m_layerStack.layerCount())); +} diff --git a/src/TexturePaintController.h b/src/TexturePaintController.h index 5a37ee9f..8a9e5791 100644 --- a/src/TexturePaintController.h +++ b/src/TexturePaintController.h @@ -7,6 +7,7 @@ #include "BrushAssetLibrary.h" #include "BrushFootprint.h" #include "GradientRamp.h" +#include "PaintLayerStack.h" #include #include @@ -22,6 +23,7 @@ #include #include +#include #include class EditableMesh; @@ -292,6 +294,44 @@ class TexturePaintController : public QObject Q_INVOKABLE QString tilingThumbnailUri(const QString& name) const; /// @} + /// @name Paint v2 Slice C — layer stack (#546) + /// @{ + Q_PROPERTY(int layerCount READ layerCount NOTIFY layersChanged) + Q_PROPERTY(int activeLayerIndex READ activeLayerIndex WRITE setActiveLayerIndex NOTIFY layersChanged) + Q_PROPERTY(QVariantList paintLayers READ paintLayers NOTIFY layersChanged) + Q_PROPERTY(QStringList blendModeNames READ blendModeNames CONSTANT) + + int layerCount() const; + int activeLayerIndex() const; + void setActiveLayerIndex(int index); + QVariantList paintLayers() const; + QStringList blendModeNames() const; + + Q_INVOKABLE int addPaintLayer(const QString& name = QString()); + Q_INVOKABLE void deletePaintLayer(int index); + Q_INVOKABLE int duplicatePaintLayer(int index); + Q_INVOKABLE void movePaintLayerUp(int index); + Q_INVOKABLE void movePaintLayerDown(int index); + Q_INVOKABLE void renamePaintLayer(int index, const QString& name); + Q_INVOKABLE void mergePaintLayerDown(int index); + Q_INVOKABLE void flattenPaintLayers(); + Q_INVOKABLE void setPaintLayerVisible(int index, bool visible); + Q_INVOKABLE void setPaintLayerLocked(int index, bool locked); + Q_INVOKABLE void setPaintLayerOpacity(int index, double opacity); + Q_INVOKABLE void beginPaintLayerOpacityDrag(); + Q_INVOKABLE void endPaintLayerOpacityDrag(); + Q_INVOKABLE void setPaintLayerBlendMode(int index, int mode); + Q_INVOKABLE void setPaintLayerSolo(int index, bool solo); + Q_INVOKABLE QString layerPreviewUrl(int index) const; + /// If the current selection includes a multi-layer paint session, ask + /// whether to continue (export stores the flattened composite only). + /// Returns false when the user cancels. + bool confirmFlattenLayersForExport(QWidget* parent) const; + /// Recompose visible layers and push the composite into the live texture + /// + embedded cache so mesh export sees painted pixels. + void flushPaintTextureForExport(Ogre::Entity* entity); + /// @} + /// @name Paint target (texture or vertex colors) /// @{ int paintTarget() const { return static_cast(m_target); } @@ -315,6 +355,8 @@ class TexturePaintController : public QObject /// `paintbuffer` QQuickImageProvider to hand QML a fresh copy /// on every request (no PNG encode, no base64). QImage snapshotBufferImage() const; + /// Snapshot one layer's pixel buffer (for layer thumbnails). + QImage snapshotLayerImage(int index) const; /// PNG data URI of the UV wireframe (white triangles on transparent /// background) at the current texture resolution. Lets the QML @@ -349,8 +391,6 @@ 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(); /// @} /** @@ -496,10 +536,12 @@ class TexturePaintController : public QObject const TexturePaintBuffer& buffer() const { return m_buffer; } TexturePaintBuffer& mutableBuffer() { return m_buffer; } - /// Internal: replace the buffer pixel data and re-upload to the - /// live Ogre texture. Used by the undo command. Width/height must - /// match the current buffer. + /// Internal: replace the composite buffer and re-upload. Legacy undo path. void applyPixelSnapshot(const std::vector& pixels); + /// Undo/redo: restore one layer's pixels then recompose. + void applyLayerPixelSnapshot(int layerIndex, const std::vector& pixels); + /// Undo/redo: restore full layer stack state. + void applyLayerStackSnapshot(const PaintLayerStack::Snapshot& snap); signals: void texturePaintChanged(); @@ -515,6 +557,7 @@ class TexturePaintController : public QObject void gradientChanged(); void rampEditorChanged(); void stampChanged(); + void layersChanged(); /// 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". @@ -550,10 +593,7 @@ class TexturePaintController : public QObject /// 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 @@ -589,15 +629,25 @@ class TexturePaintController : public QObject /// 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(); + /// At most ~60 GPU blits/sec while the brush is down (CPU paint is immediate). + void scheduleThrottledLiveGpuFlush(); + /// Synchronous GPU blit of the current dirty rect during live strokes. + bool flushLiveStrokeToGpu(); + /// Rebuild composite CPU buffer from dirty layers (no GPU). + void recomposePaintBufferIfNeeded(); + /// Stop an in-progress tiled upload (e.g. prior stroke still draining). + void cancelInFlightGpuUpload(); 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(); + void commitStrokeUndo(std::vector prePixels, int layerIndex); + /// Shared per-stroke reset — must stay in sync for viewport + UV preview paths. + void resetStrokePaintState(); + void scheduleEmbeddedTextureCacheUpdate(); + void invalidateLayerStrokeBaseline(); /// 3D brush ring during strokes — uses the cached hit triangle only. bool localPointFromHitCache(const Ogre::Vector2& uv, Ogre::Vector3& outLocal, @@ -609,6 +659,8 @@ class TexturePaintController : public QObject /// Regenerate `m_previewUri` from the buffer (PNG, base64). Emits /// previewChanged when the URI actually changed. void refreshPreviewUri(); + /// Debounced Inspector / editor-window preview refresh (reads CPU buffer). + void schedulePreviewRefresh(); /// Regenerate `m_uvOverlayUri` by drawing every UV-mapped triangle /// outline at the current texture resolution into a transparent PNG. @@ -645,6 +697,17 @@ class TexturePaintController : public QObject void rebuildStampCache(float radiusUv); void refreshStampPreviewUris(); + /// CPU buffer the brush paints into (active layer). + TexturePaintBuffer& activePaintBuffer(); + const TexturePaintBuffer& activePaintBuffer() const; + /// Rebuild `m_buffer` from visible layers and mark dirty. + /// @p fullBuffer forces a full-stack composite (layer add/delete/undo). + void recomposeComposite(bool fullBuffer = false); + void pushLayerOpUndo(const QString& label, + PaintLayerStack::Snapshot before, + PaintLayerStack::Snapshot after); + std::vector snapshotActiveLayerPixels() const; + /// Draw the hover ring on the mesh at a given local position + /// normal. Shared between viewport-driven and panel-driven hover. void drawHoverRingAt(const Ogre::Vector3& localPos, @@ -657,7 +720,10 @@ class TexturePaintController : public QObject void pickColorAtUV(const Ogre::Vector2& uv); bool m_paintEnabled = false; + /// Composite display buffer uploaded to the GPU. TexturePaintBuffer m_buffer; + PaintLayerStack m_layerStack; + quint64 m_layerPreviewVersion = 0; QString m_textureName; Ogre::TexturePtr m_ogreTexture; /// The original texture we're painting into. We blit our dirty @@ -687,13 +753,18 @@ class TexturePaintController : public QObject std::vector m_tiledUploadQueue; int m_tiledUploadIndex = 0; bool m_tiledUploadRunning = false; - bool m_strokeEndAfterUpload = false; + /// Whether the current tiled upload pass is the final flush before idle. + bool m_uploadFinishingStroke = false; + /// Incremented on every new stroke and whenever pixels change — stale + /// uploads from a prior stroke must not clearDirty() or finish undo. + uint64_t m_gpuUploadGeneration = 0; + uint64_t m_activeUploadGeneration = 0; + uint64_t m_bufferDirtyEpoch = 0; + uint64_t m_uploadEpochSnapshot = 0; TexturePaintBuffer::DirtyRect m_uploadPassDirty; - /// Batches hundreds of mouse-move events into one paint/upload step. - bool m_strokeUpdateScheduled = false; + uint64_t m_strokeUndoGeneration = 0; 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. @@ -706,6 +777,14 @@ class TexturePaintController : public QObject bool m_strokeActive = false; bool m_strokeJustBegan = false; ///< Fill/picker tools fire only once per stroke. + bool m_strokeFromUvPreview = false; + bool m_strokeMadeChanges = false; + bool m_embeddedCacheUpdateScheduled = false; + bool m_layerOpacityDragging = false; + bool m_layerOpacityDragChanged = false; + PaintLayerStack::Snapshot m_layerOpacityDragBefore; + /// Layer pixels at the end of the last stroke — O(1) handoff as the next pre-image. + std::vector m_layerStrokeBaseline; std::vector m_strokePreSnapshot; // for undo BrushTool m_tool = ToolPaint; PaintTarget m_target = TargetVertex; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 298f88b4..3e6ea124 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -156,6 +156,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureChannelPacker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureAtlasPacker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PaintBufferImageProvider.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PaintLayerBlend.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PaintLayerStack.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PaintSelectionMask.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/GradientRamp.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/BrushEngine.cpp