Skip to content

fix(paint): Inspector thumbnail uses image provider — no more blink#541

Merged
fernandotonon merged 5 commits into
masterfrom
fix/inspector-thumbnail-blink
May 17, 2026
Merged

fix(paint): Inspector thumbnail uses image provider — no more blink#541
fernandotonon merged 5 commits into
masterfrom
fix/inspector-thumbnail-blink

Conversation

@fernandotonon
Copy link
Copy Markdown
Owner

@fernandotonon fernandotonon commented May 17, 2026

Summary

  • The Inspector's 2D paint thumbnail was bound to previewDataUri, a PNG-encoded + base64-wrapped data URI regenerated on every dirty flush. Each refresh produced a fresh source URL, and Qt's Image element treats a changed source as a brand-new load — visibly blinking the thumbnail during every paint stroke.
  • The detached Texture Editor window already routes through fullResPreviewUrl (image://paintbuffer/current?v=N), served by PaintBufferImageProvider — a QImage view of the live buffer, no PNG encode, no base64. This switches the Inspector to the same provider-backed channel.
  • Also removes the per-flush PNG encode + base64 work inside refreshPreviewUri() (no remaining consumer of previewDataUri). Saves ~80 ms/refresh on 2048² textures.

Changes

  • mainwindow.cpp — register the paintbuffer image provider on the Inspector's QQmlEngine (without this, the image:// URL has no resolver).
  • qml/PropertiesPanel.qml — Inspector thumbnail Image binds to TexturePaintController.fullResPreviewUrl and refreshes via onFullResPreviewChanged.
  • TexturePaintController::refreshPreviewUri() — stop encoding/populating the legacy previewDataUri; emit previewChanged only on clear-to-empty for back-compat.

Test plan

  • Manual: enter Material Mode, enable Texture paint, drag a stroke — the Inspector thumbnail should update smoothly without blinking.
  • Manual: open the detached Texture Editor window — confirm both surfaces refresh in lockstep.
  • CI: existing TexturePaintController tests still pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Inspector paint thumbnails now use a live image provider and full-resolution preview URLs instead of embedded data URIs, improving preview consistency, refresh responsiveness, and memory use during painting.
  • Tests
    • Expanded and restructured texture-paint controller test suite for broader coverage and greater stability of painting, masking, and preview behaviors.

Review Change Stack

The Inspector's 2D paint thumbnail was bound to `previewDataUri`, a
PNG-encoded + base64-wrapped data URI regenerated on every dirty
flush. Each refresh produced a fresh URL string, and Qt's Image
element treats a changed `source` as a brand-new load — visibly
blinking the thumbnail during every paint stroke.

The detached Texture Editor window already routes through
`fullResPreviewUrl` (`image://paintbuffer/current?v=N`), which is
served by `PaintBufferImageProvider` — a `QImage` view of the live
buffer, no PNG encode, no base64. The Inspector consumed the same
controller but used the slow path. This switches the Inspector to
the same provider-backed channel.

Three changes:
- Register `paintbuffer` image provider on the Inspector's
  QQmlEngine (`MainWindow`). Without this, the `image://` URL
  resolves to a missing-provider error and the thumbnail goes
  blank.
- Bind the Inspector thumbnail to `TexturePaintController.fullResPreviewUrl`
  and update via `onFullResPreviewChanged`.
- Stop populating `previewDataUri` inside `refreshPreviewUri()` —
  no consumer remains. The property + `previewChanged` signal are
  retained for binary compatibility but the per-flush PNG encode +
  base64 churn is gone. Roughly 80 ms/refresh on 2048² is saved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

📝 Walkthrough

Walkthrough

This PR migrates texture-paint preview rendering from embedded PNG/base64 data URIs to a live image provider. The QML preview now uses a provider-served full-resolution URL and listens to fullResPreviewChanged; the controller stops creating encoded thumbnails and instead bumps a full-res version to invalidate the provider. Tests are rewritten/expanded.

Changes

Paint Buffer Preview Provider Migration

Layer / File(s) Summary
Image Provider Registration Infrastructure
src/mainwindow.cpp
PaintBufferImageProvider is registered as the paintbuffer image provider on the QML engine during properties panel setup, enabling provider-based paint buffer consumption.
TexturePaintController Preview Refresh
src/TexturePaintController.cpp
refreshPreviewUri() removes the legacy PNG/base64 thumbnail encoding path, clears the old m_previewUri for invalid buffers, and bumps m_fullResVersion while emitting fullResPreviewChanged to invalidate provider caches.
QML Preview URI Binding Update
qml/PropertiesPanel.qml
Preview binding switched from previewDataUri to fullResPreviewUrl, and the update handler changed to onFullResPreviewChanged to consume the provider-served full-resolution image.
Rewritten TexturePaintController Tests
src/TexturePaintController_test.cpp
Tests reorganized into standalone and scene fixtures with shared Ogre scene/mesh helpers; expanded coverage for sessions, preview/versioning, mask operations, painting strokes, UV events, and snapshot behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • fernandotonon/QtMeshEditor#534: Continues the image-provider refactor by switching texture paint preview consumers from legacy previewDataUri/previewChanged to fullResPreviewUrl/fullResPreviewChanged and registering the provider.
  • fernandotonon/QtMeshEditor#530: Related inspector/editor UI changes that consume the paint preview; likely impacts how previewUri is used for overlays and smart-select visuals.

Poem

🐰 I hopped from PNG to provider streams,
No more base64-dreamy seams.
Full-res whispers, versioned cheer,
Previews steady, brush strokes clear. 🎨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: switching the Inspector thumbnail from a blinking data URI approach to a stable image provider-backed URL.
Description check ✅ Passed The PR description fully follows the template with comprehensive Summary and Technical Details sections, explaining the problem, solution, specific code changes, performance benefits, and a test plan.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/inspector-thumbnail-blink

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 and usage tips.

The controller had three thin tests (singleton brush-tool defaults +
one in-memory UV hit) against a 2796-line implementation. This brings
broad coverage of the public API and the QML-facing properties — the
parts that get touched by every Inspector / Material-Mode interaction.

Two suites:

* `TexturePaintControllerStandalone` (no scene needed)
  - default state on instance(), brush-tool/target/uv-overlay setters
    (sticky, no-emit on same value, signal emit on transition)
  - smart-select tolerance clamping to [0..1]
  - brush param mirror: setBrushRadius/Strength/Falloff/Color update
    EditModeController; the read-side mirrors (texturePaintRadius
    etc.) flow back through
  - no-session edge cases — fullResPreviewUrl empty, snapshotBufferImage
    null, mask actions / smartSelect / bake / loadPaintBuffer all
    no-op cleanly, ensurePaintableTexture without selection returns
    false, bakeToOriginalFile empty, beginStrokeUV refused
  - paint-target change side-effects (sessionChanged + smartSelectChanged
    also emitted, not only paintTargetChanged)
  - enable-without-selection is harmless (no crash, no session)

* `TexturePaintControllerSceneTest` (fixture: scene + 3-vertex UV
  triangle + material with a `diffuse_map` TUS)
  - enable → session created → disable tears it down
  - ensurePaintableTexture builds buffer at requested resolution and
    reuses on second call
  - closeSession resets every observable
  - refreshSlots exposes slot model with the expected keys
  - fullResPreviewUrl shape (`image://paintbuffer/...?v=N`) when
    session is active; URL version bumps after a buffer-mutating op
  - snapshotBufferImage matches buffer dimensions
  - save / load PNG roundtrip; loadPaintBuffer accepts a known PNG
    and writes the seeded pixel into the buffer
  - findMeshPointForUV hits each triangle corner, misses outside
  - texturePaintRadiusUV maps through mesh half-extent
  - smart-select adds pixels; selectAll covers W×H; invert flips
    pixel count; clearSelectionMask emits smartSelectChanged
  - fillMaskWithFG / fillMaskWithBG / deleteMaskPixels all write
    every selected pixel and zero the buffer respectively
  - applyPixelSnapshot round-trips (undo path); mismatched size
    is rejected without crash
  - paint target tex→vertex tears down the session (the "switch
    crashes app" regression guard)
  - setHoveredUV / clearHoveredUV emit hoveredUVChanged with the
    expected payload
  - beginStrokeUV/updateStrokeUV/endStrokeUV completes on a session
  - smartSelect handles unrecognised combine mode (falls to replace),
    exercises add (mode=1) and subtract (mode=2)
  - bakeVertexColorsToTexture is callable on an entity without
    crashing (return value depends on whether mesh has vertex
    colors — we just assert it's ≥ -1)
  - opening + closing the editor window flips the flag

Tests will skip cleanly on macOS (test_main FATAL on tryInitOgre is
unchanged); CI runs on Linux under Xvfb where the scene fixture works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fernandotonon fernandotonon linked an issue May 17, 2026 that may be closed by this pull request
8 tasks
fernandotonon and others added 3 commits May 16, 2026 23:27
…ler-coverage

test(paint): expand TexturePaintController coverage 3 → 65 tests
The 37 scene-fixture tests guarded on `canLoadMeshFiles()`, which
returns false in some Linux-CI permutations even when hardware
buffer creation works for the simple in-memory meshes the tests
use. The pre-existing `FindMeshPointForUVHitsCorrectTriangle` test
in the same file uses `tryInitOgre()` and runs successfully in CI;
align the new fixture with that same gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues surfaced once tests actually executed (the prior
canLoadMeshFiles gate was masking them):

1. `BeginAndEndStrokeUVCompletesWithoutCrash` — beginStrokeUV
   refuses unless m_paintEnabled is true; ensurePaintableTexture
   alone isn't enough. Enable paint mode before the stroke.

2. `SavePaintBufferProducesReadablePNG` — ensurePaintableTexture
   floors resolution at 16 when there's no existing texture
   (std::max(16, resolution)), so `(8)` produced a 16×16 buffer.
   Request 32 and assert against 32.

3+4. `FullResVersionAdvancesPerRefresh` and `RefreshPreviewUriEmitsFullResSignal`
   — both touch refreshPreviewUri, which is double-debounced:
   flushDirtyToOgre is QTimer::singleShot(16ms), then the preview
   refresh inside is QTimer::singleShot(60ms). The tests didn't
   pump events, so neither timer fired. Add `pumpEventsFor(150)`
   helper that spins QCoreApplication::processEvents until both
   timers can complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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_test.cpp`:
- Around line 683-693: The test incorrectly modifies a local copy instead of the
controller's buffer and asserts the wrong condition; change the setup to write
the sentinel value directly into the controller's buffer (e.g., assign to
ctrl->buffer().data()[0] or use ctrl->buffer()[0]) before calling
TexturePaintController::applyPixelSnapshot, and update the final assertion to
EXPECT_EQ(ctrl->buffer().data()[0], 99) so the test verifies the buffer remains
99 when a mismatched-size snapshot is ignored; references:
TexturePaintController::instance(), ensurePaintableTexture, buffer(),
applyPixelSnapshot.
🪄 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

Run ID: 840970d1-1fdd-4c9c-b356-ed55ead4ea68

📥 Commits

Reviewing files that changed from the base of the PR and between 4a8f85b and bb30cfe.

📒 Files selected for processing (1)
  • src/TexturePaintController_test.cpp

Comment on lines +683 to +693
TEST_F(TexturePaintControllerSceneTest, ApplyPixelSnapshotMismatchedSizeIgnored) {
ASSERT_TRUE(m_fix.setup(QStringLiteral("ApplyBadSize")));
auto* ctrl = TexturePaintController::instance();
ASSERT_TRUE(ctrl->ensurePaintableTexture(8));
auto good = ctrl->buffer().data();
good[0] = 99;
std::vector<uint8_t> bad(4, 0); // intentionally wrong size
ctrl->applyPixelSnapshot(bad);
// Buffer should remain unchanged (and not crash).
EXPECT_NE(ctrl->buffer().data()[0], 99) << "buffer should not have been touched";
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test logic bug: assertion always passes without verifying the intended behavior.

Line 688 modifies good (a copy of the buffer data), not the actual buffer. The assertion at line 692 checks that the buffer's first byte is not 99, but the buffer was never set to 99 — only the local copy was modified. This test will always pass regardless of whether applyPixelSnapshot correctly rejects mismatched sizes.

🐛 Proposed fix
 TEST_F(TexturePaintControllerSceneTest, ApplyPixelSnapshotMismatchedSizeIgnored) {
     ASSERT_TRUE(m_fix.setup(QStringLiteral("ApplyBadSize")));
     auto* ctrl = TexturePaintController::instance();
     ASSERT_TRUE(ctrl->ensurePaintableTexture(8));
-    auto good = ctrl->buffer().data();
-    good[0] = 99;
+    // Set buffer to a known value
+    ctrl->mutableBuffer().data()[0] = 99;
     std::vector<uint8_t> bad(4, 0);  // intentionally wrong size
     ctrl->applyPixelSnapshot(bad);
     // Buffer should remain unchanged (and not crash).
-    EXPECT_NE(ctrl->buffer().data()[0], 99) << "buffer should not have been touched";
+    EXPECT_EQ(ctrl->buffer().data()[0], 99) << "buffer should not have been touched";
 }
🤖 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_test.cpp` around lines 683 - 693, The test
incorrectly modifies a local copy instead of the controller's buffer and asserts
the wrong condition; change the setup to write the sentinel value directly into
the controller's buffer (e.g., assign to ctrl->buffer().data()[0] or use
ctrl->buffer()[0]) before calling TexturePaintController::applyPixelSnapshot,
and update the final assertion to EXPECT_EQ(ctrl->buffer().data()[0], 99) so the
test verifies the buffer remains 99 when a mismatched-size snapshot is ignored;
references: TexturePaintController::instance(), ensurePaintableTexture,
buffer(), applyPixelSnapshot.

@sonarqubecloud
Copy link
Copy Markdown

@fernandotonon fernandotonon merged commit 8717e87 into master May 17, 2026
20 checks passed
@fernandotonon fernandotonon deleted the fix/inspector-thumbnail-blink branch May 17, 2026 05:05
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.

Epic: Paint Tools

1 participant