Skip to content

Paint v2 Slice C: texture paint layers (#546) - #935

Merged
fernandotonon merged 4 commits into
masterfrom
feat/paint-v2-slice-c-layers-546
Jul 30, 2026
Merged

Paint v2 Slice C: texture paint layers (#546)#935
fernandotonon merged 4 commits into
masterfrom
feat/paint-v2-slice-c-layers-546

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a PaintLayerStack with blend modes (Normal, Multiply, Screen, Overlay, Add, Subtract, Soft Light, Hue), per-layer opacity/visibility/lock/solo, and undoable layer operations
  • Inspector layer list with thumbnails, add/duplicate/delete/reorder/merge/flatten, and export flattening prompt when saving multi-layer paint sessions
  • Fixes multi-stroke lag when painting in the UV preview: resets stamp spacing state on each stroke, uploads GPU pixels immediately for preview-panel strokes, and defers embedded-texture PNG cache updates off the paint hot path

Test plan

  • PaintLayerBlend_test unit tests
  • Paint several consecutive strokes in the UV preview — continuous painting starts immediately each stroke
  • Paint on the 3D mesh viewport — strokes remain responsive
  • Add/duplicate/reorder/merge/flatten layers; undo/redo layer ops and paint strokes
  • Export mesh with multi-layer paint — confirm flatten prompt and composite written to texture

Made with Cursor

Summary by CodeRabbit

  • New Features
    • Added texture-paint “Layers” support with layer list UI, active-layer selection, and actions for add/duplicate/move/merge/flatten/delete.
    • Added per-layer visibility, solo, lock, opacity controls, and multiple blend modes; layer previews and snapshots.
  • Bug Fixes
    • Improved layer-aware undo/redo, live painting recomposition, and export correctness (including export flatten confirmation/processing).
    • Fixed layer-specific image requests for layer previews.
  • Tests
    • Expanded blending/compositing and layer-stack operation coverage, including region compositing and solo behavior.

Introduce a PaintLayerStack with blend modes, Inspector layer UI, per-layer
undo, and export flattening. Fix multi-stroke lag in the UV preview by resetting
stamp spacing state, syncing GPU uploads immediately for preview strokes, and
deferring embedded-texture cache work off the paint hot path.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Paint v2 layered texture painting with blend modes, masks, layer-aware strokes and undo, QML layer controls, per-layer previews, generation-safe GPU refresh coordination, and export-time flattening.

Changes

Paint v2 layer stack

Layer / File(s) Summary
Blend and layer-stack core
src/PaintLayerBlend.*, src/PaintLayerStack.*, src/PaintLayerBlend_test.cpp, src/CMakeLists.txt, tests/CMakeLists.txt
Defines RGBA blend modes, full and regional compositing, ordered layer operations, masks, solo state, snapshots, flattening, and tests.
Layer-aware painting controller
src/TexturePaintController.*
Routes painting through active layers, integrates layered undo/redo, recomposition, generation-safe GPU uploads, preview refreshes, opacity-drag grouping, and QML layer operations.
Layer controls and previews
qml/PropertiesPanel.qml, src/PaintBufferImageProvider.cpp
Adds the conditional texture-paint Layers panel with layer actions, visibility, solo, lock, opacity, blend mode, and per-layer preview loading.
Flattened export integration
src/MeshImporterExporter.cpp
Confirms layer flattening before scene-node export and flushes the composite texture before formatted export.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has a summary and test plan, but it omits the required Technical Details section and PS1 runtime rip checklist. Add the missing Technical Details section, include the PS1 runtime rip checklist if relevant, and split the summary into Features/Bugfixes per the template.
Docstring Coverage ⚠️ Warning Docstring coverage is 7.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly points to the main change: texture paint layers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/paint-v2-slice-c-layers-546

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: baa5bee2f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +144 to +148
void apply(const PaintLayerStack::Snapshot& snap)
{
if (!m_controller) return;
m_controller->applyLayerStackSnapshot(snap);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind layer-operation undo to its originating session

When a layer operation is followed by switching texture slots or painting another mesh, the global undo stack retains this command and apply() unconditionally restores the old stack into the current controller session. Unlike the stroke and mask commands, there is no texture/session identity check, so Undo can replace another mesh's layers and even resize its composite buffer to the previous texture's dimensions. Store and verify the originating texture/session identity before applying the snapshot.

Useful? React with 👍 / 👎.

Comment on lines +3014 to +3017
if (m_layerStrokeBaseline.empty())
m_layerStrokeBaseline = snapshotActiveLayerPixels();
m_strokePreSnapshot = std::move(m_layerStrokeBaseline);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate the stroke baseline when switching layers

After selecting, adding, duplicating, merging, or flattening into a different active layer, m_layerStrokeBaseline can still contain pixels from the previously active layer because those operations do not invalidate it. The next stroke moves that stale buffer into its pre-image while recording the new layer index, so Undo copies the previous layer's pixels into the newly painted layer. Track the baseline's layer index or clear/rebuild it whenever the active layer changes.

Useful? React with 👍 / 👎.

Comment thread src/TexturePaintController.cpp Outdated
Comment on lines +5057 to +5059
m_buffer.markDirty(0, 0, m_buffer.width(), m_buffer.height());
flushDirtyToOgre();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Flush the layer composite synchronously before export

When export runs before a pending layer-operation upload fires, this calls flushDirtyToOgre(), which only schedules a 33 ms timer outside a stroke, and then returns to MeshImporterExporter, which immediately serializes the texture. Programmatic/headless exports, or an export immediately following a layer visibility, opacity, or ordering change, can therefore write the previous GPU texture instead of the composite just produced here. Use the synchronous upload path before returning from this export-specific method.

Useful? React with 👍 / 👎.

Comment thread src/TexturePaintController.cpp Outdated
Comment on lines +4931 to +4935
const auto before = m_layerStack.snapshot();
m_layerStack.setOpacity(index, static_cast<float>(opacity));
recomposeComposite(/*fullBuffer=*/true);
flushDirtyToOgre();
pushLayerOpUndo(QStringLiteral("Layer opacity"), before, m_layerStack.snapshot());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Coalesce opacity drags into one undo command

The QML slider calls this method on every onMoved event, and each call copies the entire layer stack twice and retains both snapshots in the unbounded undo stack. A single drag on a multi-layer 1024–4096 texture can consequently allocate hundreds of megabytes or several gigabytes, freeze the UI, and populate Undo with every intermediate slider tick. Merge successive opacity commands or create one snapshot at drag start and commit it on release.

Useful? React with 👍 / 👎.

Comment thread src/PaintLayerBlend.cpp
Comment on lines +119 to +123
src.r * a + dst.r * (1.f - a),
src.g * a + dst.g * (1.f - a),
src.b * a + dst.b * (1.f - a),
src.a * a + dst.a * (1.f - a),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute source-over alpha without counting source alpha twice

For transparent destination pixels, alpha already contains src.a, but the output alpha multiplies by src.a again, and the straight RGB channels are interpolated without normalizing by the resulting alpha. For example, compositing a 50%-alpha source over a 50%-alpha destination should produce alpha 0.75, while this returns 0.5 and incorrect straight RGB values. This leaves erased or initially transparent texture regions too transparent after overlapping layer strokes.

Useful? React with 👍 / 👎.

Comment thread src/mainwindow.cpp Outdated
Comment on lines +5559 to +5560
if (auto* tpc = TexturePaintController::instance()) {
if (!tpc->confirmFlattenLayersForExport(this)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prompt for layer flattening only once per export action

For Export Selected with a multi-layer paint session, this confirmation is immediately followed by MeshImporterExporter::exporter(node, this), which calls confirmFlattenLayersForExport() again before its file dialog. Consequently a single selected mesh prompts twice, and a multi-selection containing the painted mesh prompts once here plus once for every selected node because the confirmation checks the global selection. Keep the confirmation at only one level of the export flow.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (6)
src/TexturePaintController.h (1)

324-330: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Q_INVOKABLE on a QWidget*-taking method is unusable from QML.

confirmFlattenLayersForExport(QWidget*) can only be called from C++ (the widget parent is never available to QML), so the Q_INVOKABLE adds meta-object surface without a caller. Dropping it — like the adjacent flushPaintTextureForExport — keeps the QML API honest.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TexturePaintController.h` around lines 324 - 330, Remove the Q_INVOKABLE
annotation from confirmFlattenLayersForExport(QWidget* parent) in
TexturePaintController, leaving it as a regular C++ method like
flushPaintTextureForExport. Preserve its signature and behavior.
src/TexturePaintController.cpp (2)

5036-5047: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No breadcrumb for the flatten-export prompt or its outcome.

This is a user-facing modal decision that changes export behavior; add a ui.action breadcrumb recording the layer count and whether the user continued or cancelled. As per coding guidelines, "Track all user-facing actions and significant operations with SentryReporter::addBreadcrumb; use ui.action for UI actions".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TexturePaintController.cpp` around lines 5036 - 5047, The export
confirmation flow around layerCount() and the QMessageBox::warning call lacks
telemetry. Add a SentryReporter::addBreadcrumb entry with category “ui.action”
that records the texture layer count and whether the user selected Ok or Cancel,
while preserving the existing boolean return behavior.

Source: Coding guidelines


4769-4781: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive blendModeNames from PaintLayerBlend::modeName.

This hardcoded list must stay in lockstep with the Mode enum and modeName/modeFromName tables; a new mode added in one place silently desynchronizes the QML combo box index mapping.

♻️ Suggested refactor
 QStringList TexturePaintController::blendModeNames() const
 {
-    return {
-        QStringLiteral("Normal"),
-        QStringLiteral("Multiply"),
-        QStringLiteral("Screen"),
-        QStringLiteral("Overlay"),
-        QStringLiteral("Add"),
-        QStringLiteral("Subtract"),
-        QStringLiteral("Soft Light"),
-        QStringLiteral("Hue"),
-    };
+    QStringList names;
+    for (int m = 0; m <= static_cast<int>(PaintLayerBlend::Mode::Hue); ++m)
+        names << QString::fromLatin1(
+            PaintLayerBlend::modeName(static_cast<PaintLayerBlend::Mode>(m)));
+    return names;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TexturePaintController.cpp` around lines 4769 - 4781, Update
TexturePaintController::blendModeNames to derive its QStringList from the
complete PaintLayerBlend::Mode range using PaintLayerBlend::modeName, rather
than maintaining hardcoded names. Preserve enum ordering so the QML combo-box
indices remain aligned with modeFromName and automatically include newly added
modes.
src/PaintLayerBlend_test.cpp (1)

166-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding cases for the gaps this PR's layer ops expose.

Two behaviors are untested and both are where bugs are easy to introduce: solo/active index tracking across duplicateLayer/moveLayer/removeLayer, and composite output when every layer is hidden (full vs. region paths currently disagree). As per coding guidelines, new functionality should come with Google Test coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PaintLayerBlend_test.cpp` around lines 166 - 227, Extend
PaintLayerStackTest with Google Test cases covering solo and active-index
updates after duplicateLayer, moveLayer, and removeLayer operations, asserting
the selected layer remains correct. Add composite tests for both full and region
output when all layers are hidden, verifying both paths produce the same
expected fully hidden result.

Source: Coding guidelines

src/PaintLayerStack.cpp (1)

95-102: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

resizeAll discards existing mask data.

Any layer with a mask has it reset to fully-visible on resize, silently dropping user mask work. Consider resampling/preserving the overlapping region, or at least documenting the destructive behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PaintLayerStack.cpp` around lines 95 - 102, Update
PaintLayerStack::resizeAll so resizing a layer preserves existing maskAlpha
values, resampling them to the new dimensions or copying the overlapping region
while initializing only newly added pixels to fully visible. Do not reset every
existing mask to 255; retain the current behavior for layers without masks.
src/PaintLayerBlend.cpp (1)

196-197: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Give compositePixelAt internal linkage.

It's defined at namespace scope with external linkage but isn't declared in PaintLayerBlend.h, so it exports an undocumented symbol. Move it into the anonymous namespace above (or mark static).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PaintLayerBlend.cpp` around lines 196 - 197, Give the namespace-scope
compositePixelAt function internal linkage by moving it into the existing
anonymous namespace in PaintLayerBlend.cpp or marking it static. Keep its
signature and behavior unchanged while preventing export of the undocumented
symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@qml/PropertiesPanel.qml`:
- Around line 4825-4862: Add a per-layer lock toggle alongside the existing
visibility and solo controls in the layer-row delegate. Bind its visual state to
the layer’s locked property and invoke the controller’s corresponding
paint-layer lock setter with the row index and inverted current state,
preserving the existing styling and interaction patterns.
- Around line 4727-4871: Make the layer toolbar and layerList controls keyboard
accessible: replace or augment the toolbar rectangles, layer-selection
MouseArea, and visibility/solo controls with focusable controls that expose
accessible roles and descriptive names, show a visible focus indicator, and
invoke the existing actions on Space or Enter. Preserve the current
enabled-state behavior and layer action symbols, including modelData.action,
modelData.index, and the existing TexturePaintController calls.

In `@src/mainwindow.cpp`:
- Around line 5559-5567: Ensure flatten-layer confirmation occurs exactly once
per selected-export operation: update the selected-export flow around
TexturePaintController::confirmFlattenLayersForExport in src/mainwindow.cpp
lines 5559-5567 and the exporter confirmation in src/MeshImporterExporter.cpp
lines 3384-3390 so one layer owns the prompt and the other receives or honors an
explicit confirmed state/target; preserve confirmation for other callers that
require it and prevent per-item re-prompting.

In `@src/PaintLayerBlend.cpp`:
- Around line 264-285: Update compositeLayersRegion to match compositeLayers’
all-hidden fallback: determine whether any layer is visible for the requested
region, and fill each affected pixel opaque white when none are visible,
including solo selections that reference hidden layers. Preserve the existing
compositePixelAt path when at least one layer is visible.

In `@src/PaintLayerStack.cpp`:
- Around line 200-223: Update PaintLayerStack::mergeDown so its compositeLayers
inputs force both lower and upper layers visible during the merge, rather than
passing their display visibility flags. Preserve all other blend, opacity, mask,
and layer-removal behavior unchanged.
- Around line 159-177: Update PaintLayerStack::duplicateLayer to validate index
before accessing the source, and adjust m_soloIndex when duplicating so a solo
layer at or after the insertion point remains associated with the same original
layer. Update removeLayer to reject out-of-range indices before erasing,
preserving its existing minimum-layer behavior and valid-index solo-index
adjustments.

In `@src/TexturePaintController.cpp`:
- Around line 4721-4727: Update TexturePaintController::pushLayerOpUndo and the
related PaintLayerOpCommand flow to use a lightweight metadata-only undo command
for visibility, opacity, blend mode, solo, and rename operations, avoiding
pixel-buffer copies. Retain full PaintLayerStack::Snapshot undo entries for add,
delete, duplicate, merge, and flatten operations.
- Around line 3214-3218: Update the flow around flushLiveStrokeToGpu so
m_buffer.clearDirty() executes only after a successful GPU flush. Preserve dirty
pixels when the flush fails, and use the existing immediate doFlushDirtyToOgre
fallback if that is the established success path.
- Around line 5050-5060: Make flushPaintTextureForExport synchronously update
the bound Ogre texture before export reads it. In this export-specific path,
bypass flushDirtyToOgre()’s deferred timer by invoking the immediate
doFlushDirtyToOgre path (or otherwise synchronously waiting for completion),
while preserving the existing dirty-region and cache-update behavior.
- Around line 4698-4699: Restrict the single-layer fast path in the surrounding
buffer-composition logic to layers with default opacity, Normal blend mode, no
mask, and visibility enabled. Otherwise use the existing full-buffer compositing
path so layer properties are applied consistently.

---

Nitpick comments:
In `@src/PaintLayerBlend_test.cpp`:
- Around line 166-227: Extend PaintLayerStackTest with Google Test cases
covering solo and active-index updates after duplicateLayer, moveLayer, and
removeLayer operations, asserting the selected layer remains correct. Add
composite tests for both full and region output when all layers are hidden,
verifying both paths produce the same expected fully hidden result.

In `@src/PaintLayerBlend.cpp`:
- Around line 196-197: Give the namespace-scope compositePixelAt function
internal linkage by moving it into the existing anonymous namespace in
PaintLayerBlend.cpp or marking it static. Keep its signature and behavior
unchanged while preventing export of the undocumented symbol.

In `@src/PaintLayerStack.cpp`:
- Around line 95-102: Update PaintLayerStack::resizeAll so resizing a layer
preserves existing maskAlpha values, resampling them to the new dimensions or
copying the overlapping region while initializing only newly added pixels to
fully visible. Do not reset every existing mask to 255; retain the current
behavior for layers without masks.

In `@src/TexturePaintController.cpp`:
- Around line 5036-5047: The export confirmation flow around layerCount() and
the QMessageBox::warning call lacks telemetry. Add a
SentryReporter::addBreadcrumb entry with category “ui.action” that records the
texture layer count and whether the user selected Ok or Cancel, while preserving
the existing boolean return behavior.
- Around line 4769-4781: Update TexturePaintController::blendModeNames to derive
its QStringList from the complete PaintLayerBlend::Mode range using
PaintLayerBlend::modeName, rather than maintaining hardcoded names. Preserve
enum ordering so the QML combo-box indices remain aligned with modeFromName and
automatically include newly added modes.

In `@src/TexturePaintController.h`:
- Around line 324-330: Remove the Q_INVOKABLE annotation from
confirmFlattenLayersForExport(QWidget* parent) in TexturePaintController,
leaving it as a regular C++ method like flushPaintTextureForExport. Preserve its
signature and behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 121c076c-1e5f-4e44-a23a-b678a545da94

📥 Commits

Reviewing files that changed from the base of the PR and between 2f7a646 and baa5bee.

📒 Files selected for processing (13)
  • qml/PropertiesPanel.qml
  • src/CMakeLists.txt
  • src/MeshImporterExporter.cpp
  • src/PaintBufferImageProvider.cpp
  • src/PaintLayerBlend.cpp
  • src/PaintLayerBlend.h
  • src/PaintLayerBlend_test.cpp
  • src/PaintLayerStack.cpp
  • src/PaintLayerStack.h
  • src/TexturePaintController.cpp
  • src/TexturePaintController.h
  • src/mainwindow.cpp
  • tests/CMakeLists.txt

Comment thread qml/PropertiesPanel.qml
Comment on lines +4727 to +4871
// 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 - 108)
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)
}
}
}

MouseArea {
id: layerRowMa
anchors.fill: parent
z: -1
hoverEnabled: true
onClicked: TexturePaintController.activeLayerIndex = modelData.index
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Make the layer toolbar and list keyboard accessible.

The toolbar buttons, layer selection, visibility, and solo controls are mouse-only. Use ToolButton/CheckBox/ItemDelegate, or add tab focus, accessible roles/names, focus indicators, and Space/Enter handlers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qml/PropertiesPanel.qml` around lines 4727 - 4871, Make the layer toolbar and
layerList controls keyboard accessible: replace or augment the toolbar
rectangles, layer-selection MouseArea, and visibility/solo controls with
focusable controls that expose accessible roles and descriptive names, show a
visible focus indicator, and invoke the existing actions on Space or Enter.
Preserve the current enabled-state behavior and layer action symbols, including
modelData.action, modelData.index, and the existing TexturePaintController
calls.

Comment thread qml/PropertiesPanel.qml
Comment thread src/mainwindow.cpp Outdated
Comment on lines +5559 to +5567
if (auto* tpc = TexturePaintController::instance()) {
if (!tpc->confirmFlattenLayersForExport(this)) {
AnimationControlController::instance()->resumePollTimer();
if (wasRendering) m_pTimer->start();
SentryReporter::finishTransaction(txn);
return;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Confirm flattening exactly once per export operation.

The selected-export flow preflights confirmation and then invokes an exporter that confirms again. Because the helper evaluates the current selection, layered selections can produce duplicate prompts or one prompt per exported item.

  • src/mainwindow.cpp#L5559-L5567: retain one confirmation for the entire selected-export operation, or remove this preflight if the exporter owns confirmation.
  • src/MeshImporterExporter.cpp#L3384-L3390: avoid re-prompting after the selected-export preflight, using an explicit confirmation-state/target contract if other callers require confirmation.
📍 Affects 2 files
  • src/mainwindow.cpp#L5559-L5567 (this comment)
  • src/MeshImporterExporter.cpp#L3384-L3390
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mainwindow.cpp` around lines 5559 - 5567, Ensure flatten-layer
confirmation occurs exactly once per selected-export operation: update the
selected-export flow around
TexturePaintController::confirmFlattenLayersForExport in src/mainwindow.cpp
lines 5559-5567 and the exporter confirmation in src/MeshImporterExporter.cpp
lines 3384-3390 so one layer owns the prompt and the other receives or honors an
explicit confirmed state/target; preserve confirmation for other callers that
require it and prevent per-item re-prompting.

Comment thread src/PaintLayerBlend.cpp
Comment thread src/PaintLayerStack.cpp
Comment thread src/PaintLayerStack.cpp
Comment thread src/TexturePaintController.cpp
Comment thread src/TexturePaintController.cpp Outdated
Comment on lines +4721 to +4727
void TexturePaintController::pushLayerOpUndo(const QString& label,
PaintLayerStack::Snapshot before,
PaintLayerStack::Snapshot after)
{
UndoManager::getSingleton()->push(
new PaintLayerOpCommand(this, label, std::move(before), std::move(after)));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Every layer op pushes two full pixel-level stack snapshots onto the undo stack.

PaintLayerStack::Snapshot deep-copies every layer's TexturePaintBuffer, so a 4-layer 2048² session costs ~64 MB per snapshot and ~128 MB per undo entry — a handful of visibility/opacity toggles can exhaust memory. Attribute-only ops (visibility, opacity, blend mode, solo, rename) don't need pixel data at all; consider a lightweight metadata-only undo command for those and reserving full snapshots for add/delete/duplicate/merge/flatten.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TexturePaintController.cpp` around lines 4721 - 4727, Update
TexturePaintController::pushLayerOpUndo and the related PaintLayerOpCommand flow
to use a lightweight metadata-only undo command for visibility, opacity, blend
mode, solo, and rename operations, avoiding pixel-buffer copies. Retain full
PaintLayerStack::Snapshot undo entries for add, delete, duplicate, merge, and
flatten operations.

Comment thread src/TexturePaintController.cpp
fernandotonon and others added 2 commits July 29, 2026 16:45
Bind layer-op undo to the paint session texture, invalidate stroke baselines
on layer changes, sync export flush, coalesce opacity-slider undo, fix normal
alpha compositing, solo index on duplicate, merge visibility, and add lock UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
)

Qt 6.9 Slider has no onPressed/onReleased handlers; those broke
PropertiesPanel.qml compilation and left the Inspector blank. Use
onPressedChanged (matching the light sliders) and simplify the lock
toggle label.

Co-authored-by: Cursor <cursoragent@cursor.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/TexturePaintController.cpp (1)

4808-4823: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Non-standard Sentry breadcrumb categories for layer operations.

All new layer-operation breadcrumbs use ad-hoc categories (paint.layer.add, .delete, .duplicate, .reorder, .merge, .flatten, .opacity, .export) instead of ui.action, even though these are all user-triggered UI actions. This contradicts the convention already used in this same file for wand/vertex stroke breadcrumbs (Lines 3210-3212, 3218), which correctly use "ui.action".

🔧 Proposed fix (repeat for each site)
-    SentryReporter::addBreadcrumb(QStringLiteral("paint.layer.add"),
+    SentryReporter::addBreadcrumb(QStringLiteral("ui.action"),
         QStringLiteral("Added layer '%1'").arg(m_layerStack.layer(idx).name));

Affected sites: addPaintLayer (4817), deletePaintLayer (4836), duplicatePaintLayer (4852), movePaintLayerUp/Down (4869, 4884), mergePaintLayerDown (4911), flattenPaintLayers (4927), setPaintLayerOpacity/endPaintLayerOpacityDrag (4971, 4993), flushPaintTextureForExport (5113).

As per coding guidelines: "Track all user-facing actions and significant operations with SentryReporter::addBreadcrumb; use ui.action for UI actions, ai.tool_call for MCP calls, and file.import/file.export for I/O."

Also applies to: 4825-4841, 4843-4858, 4860-4888, 4902-4916, 4918-4931, 4960-4994, 5103-5117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TexturePaintController.cpp` around lines 4808 - 4823, Update the
SentryReporter::addBreadcrumb calls in addPaintLayer, deletePaintLayer,
duplicatePaintLayer, movePaintLayerUp, movePaintLayerDown, mergePaintLayerDown,
flattenPaintLayers, setPaintLayerOpacity, endPaintLayerOpacityDrag, and
flushPaintTextureForExport to use the standard "ui.action" category for these
user-triggered actions, while preserving their existing breadcrumb messages and
behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/TexturePaintController.cpp`:
- Around line 4960-4994: Add a lightweight drag-change flag associated with
beginPaintLayerOpacityDrag, set it only when setPaintLayerOpacity applies an
opacity change during an active drag, and guard endPaintLayerOpacityDrag so
pushLayerOpUndo runs only when that flag is set. Also ensure non-drag
setPaintLayerOpacity calls do not create undo entries when the effective opacity
is unchanged, while preserving existing snapshot and breadcrumb behavior for
real changes.

---

Outside diff comments:
In `@src/TexturePaintController.cpp`:
- Around line 4808-4823: Update the SentryReporter::addBreadcrumb calls in
addPaintLayer, deletePaintLayer, duplicatePaintLayer, movePaintLayerUp,
movePaintLayerDown, mergePaintLayerDown, flattenPaintLayers,
setPaintLayerOpacity, endPaintLayerOpacityDrag, and flushPaintTextureForExport
to use the standard "ui.action" category for these user-triggered actions, while
preserving their existing breadcrumb messages and behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9610d547-148a-48c8-8557-af5cd9fd2820

📥 Commits

Reviewing files that changed from the base of the PR and between baa5bee and 90a7730.

📒 Files selected for processing (6)
  • qml/PropertiesPanel.qml
  • src/MeshImporterExporter.cpp
  • src/PaintLayerBlend.cpp
  • src/PaintLayerStack.cpp
  • src/TexturePaintController.cpp
  • src/TexturePaintController.h
💤 Files with no reviewable changes (1)
  • src/MeshImporterExporter.cpp

Comment thread src/TexturePaintController.cpp
Use ui.action/file.export breadcrumbs, derive blend mode names from the
enum, guard opacity-drag undo against no-op slider releases, add export
flatten confirmation telemetry, preserve mask data on layer resize, give
compositePixelAt internal linkage, and extend layer stack tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/TexturePaintController.cpp (1)

4794-4801: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a sentinel before hardcoding the blend-mode enum bound.

PaintLayerBlend::Mode::Hue is the last enumerator, so the current loop still covers all defined modes. The real issue is maintainability: future enum values could be inserted after Hue and would stay out of the QML dropdown unless this bound is updated. Add a non-UI Count/Last enum value and iterate up to it instead of relying on the last UI enumerator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/TexturePaintController.cpp` around lines 4794 - 4801, Update
PaintLayerBlend::Mode to include a non-UI Count or Last sentinel after the
existing blend modes, then change TexturePaintController::blendModeNames() to
use that sentinel as the loop bound. Preserve the existing UI mode ordering and
exclude the sentinel itself from the returned names.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/TexturePaintController.cpp`:
- Around line 4794-4801: Update PaintLayerBlend::Mode to include a non-UI Count
or Last sentinel after the existing blend modes, then change
TexturePaintController::blendModeNames() to use that sentinel as the loop bound.
Preserve the existing UI mode ordering and exclude the sentinel itself from the
returned names.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 178cdb06-d103-439e-a5e7-102c710241b4

📥 Commits

Reviewing files that changed from the base of the PR and between 90a7730 and b53a173.

📒 Files selected for processing (5)
  • src/PaintLayerBlend.cpp
  • src/PaintLayerBlend_test.cpp
  • src/PaintLayerStack.cpp
  • src/TexturePaintController.cpp
  • src/TexturePaintController.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/PaintLayerBlend_test.cpp
  • src/PaintLayerBlend.cpp

@fernandotonon
fernandotonon merged commit 134d155 into master Jul 30, 2026
19 checks passed
@fernandotonon
fernandotonon deleted the feat/paint-v2-slice-c-layers-546 branch July 30, 2026 01:00
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant