diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 36644340..6df24856 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1089,7 +1089,11 @@ Rectangle { property int activeSlot: TexturePaintController.activeSlotIndex property int brushTool: TexturePaintController.brushTool property int paintTarget: TexturePaintController.paintTarget - property string previewUri: TexturePaintController.previewDataUri + // image://paintbuffer/current?v=N — served by PaintBufferImageProvider. + // Switching from PNG-encoded data URIs eliminates the per-stroke + // blink (each new base64 string was a fresh load) and drops the + // PNG encode + base64 churn off the main thread. + property string previewUri: TexturePaintController.fullResPreviewUrl property string maskOverlayUri: TexturePaintController.maskOverlayDataUri property bool hasMask: TexturePaintController.hasSelectionMask property int maskCount: TexturePaintController.selectedPixelCount @@ -1111,8 +1115,8 @@ Rectangle { texPaintCol.slots = TexturePaintController.textureSlots texPaintCol.activeSlot = TexturePaintController.activeSlotIndex } - function onPreviewChanged() { - texPaintCol.previewUri = TexturePaintController.previewDataUri + function onFullResPreviewChanged() { + texPaintCol.previewUri = TexturePaintController.fullResPreviewUrl } function onBrushToolChanged() { texPaintCol.brushTool = TexturePaintController.brushTool diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index 3017bef7..f8530fd9 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1958,41 +1958,19 @@ void TexturePaintController::refreshUvOverlay() void TexturePaintController::refreshPreviewUri() { + // Both the Inspector thumbnail and the detached editor window now + // consume `fullResPreviewUrl`, which is served by + // PaintBufferImageProvider (a QImage view of the buffer — no PNG + // encode, no base64). The legacy `previewDataUri` PNG path is + // retained as a property for binary compatibility but no longer + // populated; emitting `previewChanged` keeps any external observer + // notified without paying the encode cost on the main thread. if (m_buffer.width() <= 0 || m_buffer.height() <= 0) { if (!m_previewUri.isEmpty()) { m_previewUri.clear(); emit previewChanged(); } - // Bump the full-res URL anyway so any bound Image clears. - ++m_fullResVersion; - emit fullResPreviewChanged(); - return; - } - // Scale down to a thumbnail before PNG-encoding. The preview - // panel is 256×256; encoding a 1024² or 2048² PNG every refresh - // burns most of a frame on the main thread. Qt::FastTransformation - // is the cheapest scaler. - QImage src(const_cast(m_buffer.data().data()), - m_buffer.width(), m_buffer.height(), - m_buffer.width() * 4, QImage::Format_RGBA8888); - constexpr int kPreviewMax = 256; - QImage thumb = (m_buffer.width() > kPreviewMax || m_buffer.height() > kPreviewMax) - ? src.scaled(kPreviewMax, kPreviewMax, - Qt::KeepAspectRatio, Qt::FastTransformation) - : src.copy(); // own the pixels — src points at m_buffer - QByteArray bytes; - QBuffer qbuf(&bytes); - qbuf.open(QIODevice::WriteOnly); - thumb.save(&qbuf, "PNG"); - const QString next = QStringLiteral("data:image/png;base64,") - + QString::fromLatin1(bytes.toBase64()); - if (next != m_previewUri) { - m_previewUri = next; - emit previewChanged(); } - // The full-res preview channel doesn't need PNG encoding — - // QQuickImageProvider serves the live buffer directly on demand. - // Just bump the version so QML re-fetches. ++m_fullResVersion; emit fullResPreviewChanged(); } diff --git a/src/TexturePaintController_test.cpp b/src/TexturePaintController_test.cpp index c91bb730..ae361f1d 100644 --- a/src/TexturePaintController_test.cpp +++ b/src/TexturePaintController_test.cpp @@ -1,82 +1,885 @@ #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include "EditModeController.h" #include "Manager.h" #include "SelectionSet.h" #include "TestHelpers.h" #include "TexturePaintBuffer.h" #include "TexturePaintController.h" +#include "UndoManager.h" #include +#include +#include #include +#include #include #include +#include +#include +#include -// Reverse UV→3D lookup: ask for UV (0,0) and verify we get the -// position of vertex 0 (which carries UV (0,0)) on the test triangle. -TEST(TexturePaintControllerTest, FindMeshPointForUVHitsCorrectTriangle) +namespace { + +// Build an entity in the scene with the canonical 3-vertex UV triangle +// and a material that already has a TUS named "diffuse_map". The TUS is +// what `findOrCreateActiveTextureUnit` will pick up by default, so any +// test that flows through `ensurePaintableTexture` can rely on it. +struct ScenePaintFixture { - ASSERT_TRUE(tryInitOgre()); - auto* mgr = Manager::getSingleton(); - ASSERT_NE(mgr, nullptr); - auto* scene = mgr->getSceneMgr(); - ASSERT_NE(scene, nullptr); - auto mesh = createInMemoryTriangleMesh("TPC_FindMeshPointForUV"); - auto* entity = scene->createEntity("TPC_TestEntity", mesh->getName()); - auto* node = scene->getRootSceneNode()->createChildSceneNode(); - node->attachObject(entity); + Ogre::SceneManager* scene = nullptr; + Ogre::MeshPtr mesh; + Ogre::Entity* entity = nullptr; + Ogre::SceneNode* node = nullptr; + Ogre::MaterialPtr mat; - SelectionSet::getSingleton()->clear(); - SelectionSet::getSingleton()->append(entity); + bool setup(const QString& tag) + { + if (!tryInitOgre()) return false; + auto* mgr = Manager::getSingleton(); + if (!mgr) return false; + scene = mgr->getSceneMgr(); + if (!scene) return false; - auto* ctrl = TexturePaintController::instance(); - ctrl->refreshSlots(); - // Need a paint buffer so `m_paintMesh` is built. - ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + const std::string meshName = ("TPC_Mesh_" + tag).toStdString(); + const std::string entName = ("TPC_Entity_" + tag).toStdString(); + const std::string matName = ("TPC_Mat_" + tag).toStdString(); - Ogre::Vector3 pos, normal; - EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 0.0f), pos, normal)); - EXPECT_NEAR(pos.x, 0.0f, 1e-4); - EXPECT_NEAR(pos.y, 0.0f, 1e-4); + mesh = createInMemoryTriangleMesh(meshName); + if (!mesh) return false; + entity = scene->createEntity(entName, mesh->getName()); + if (!entity) return false; + node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); - EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(1.0f, 0.0f), pos, normal)); - EXPECT_NEAR(pos.x, 1.0f, 1e-4); - EXPECT_NEAR(pos.y, 0.0f, 1e-4); + auto& mm = Ogre::MaterialManager::getSingleton(); + mat = mm.create(matName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* pass = mat->getTechnique(0)->getPass(0); + auto* tus = pass->createTextureUnitState(); + tus->setName("diffuse_map"); + entity->getSubEntity(0)->setMaterial(mat); - EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 1.0f), pos, normal)); - EXPECT_NEAR(pos.x, 0.0f, 1e-4); - EXPECT_NEAR(pos.y, 1.0f, 1e-4); + SelectionSet::getSingleton()->clear(); + SelectionSet::getSingleton()->append(entity); + return true; + } - // Outside the triangle in UV space → no hit. - EXPECT_FALSE(ctrl->findMeshPointForUV(Ogre::Vector2(0.9f, 0.9f), pos, normal)); + void teardown() + { + auto* ctrl = TexturePaintController::instance(); + if (ctrl) ctrl->closeSession(); + if (SelectionSet::getSingleton()) + SelectionSet::getSingleton()->clear(); + if (scene) { + if (node) { + scene->getRootSceneNode()->removeAndDestroyChild(node); + node = nullptr; + } + if (entity) { + scene->destroyEntity(entity); + entity = nullptr; + } + } + if (mesh) { + Ogre::MeshManager::getSingleton().remove(mesh); + mesh.reset(); + } + if (mat) { + Ogre::MaterialManager::getSingleton().remove(mat); + mat.reset(); + } + } +}; - ctrl->closeSession(); - SelectionSet::getSingleton()->clear(); - scene->getRootSceneNode()->removeAndDestroyChild(node); - scene->destroyEntity(entity); - Ogre::MeshManager::getSingleton().remove(mesh); +// Spin the Qt event loop for `ms` real milliseconds so debounced +// QTimer::singleShot callbacks fire. Used for tests that need to +// observe the result of flushDirtyToOgre (16 ms) → refreshPreviewUri +// (60 ms inside). Default 120 ms covers both with margin. +void pumpEventsFor(int ms = 120) +{ + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < ms) { + QCoreApplication::processEvents(QEventLoop::AllEvents, ms); + QCoreApplication::sendPostedEvents(); + } } -TEST(TexturePaintControllerTest, BrushToolDefaultIsPaint) +// Drop the controller's session + reset selection so every test starts +// from a clean state regardless of run order. +void hardResetController() { auto* ctrl = TexturePaintController::instance(); - // Reset to a known value via the public path so test order doesn't - // matter. + if (!ctrl) return; + if (ctrl->texturePaintEnabled()) ctrl->setTexturePaintEnabled(false); + ctrl->clearSelectionMask(); + ctrl->closeSession(); ctrl->setBrushTool(TexturePaintController::ToolPaint); + ctrl->setPaintTarget(TexturePaintController::TargetTexture); + ctrl->setUvOverlayVisible(false); + if (auto* sel = SelectionSet::getSingleton()) sel->clear(); +} + +} // namespace + +// =========================================================================== +// Standalone tests — pure-state, no Ogre needed +// =========================================================================== + +TEST(TexturePaintControllerStandalone, InstanceIsStable) { + auto* a = TexturePaintController::instance(); + auto* b = TexturePaintController::instance(); + EXPECT_EQ(a, b) << "instance() should be a singleton"; + EXPECT_NE(a, nullptr); +} + +TEST(TexturePaintControllerStandalone, DefaultsAreSane) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_FALSE(ctrl->texturePaintEnabled()); + EXPECT_FALSE(ctrl->hasActiveSession()); EXPECT_EQ(ctrl->brushTool(), static_cast(TexturePaintController::ToolPaint)); + EXPECT_EQ(ctrl->paintTarget(), static_cast(TexturePaintController::TargetTexture)); + EXPECT_FALSE(ctrl->hasSelectionMask()); + EXPECT_EQ(ctrl->selectedPixelCount(), 0); + EXPECT_FALSE(ctrl->uvOverlayVisible()); + EXPECT_FALSE(ctrl->editorWindowOpen()); + EXPECT_TRUE(ctrl->currentTextureName().isEmpty()); + EXPECT_EQ(ctrl->textureResolution(), 0); } -TEST(TexturePaintControllerTest, SetBrushToolEmitsOnceAndSticks) -{ +TEST(TexturePaintControllerStandalone, SetBrushToolSticksAcrossAllValues) { + auto* ctrl = TexturePaintController::instance(); + for (int t : {static_cast(TexturePaintController::ToolErase), + static_cast(TexturePaintController::ToolFill), + static_cast(TexturePaintController::ToolColorPicker), + static_cast(TexturePaintController::ToolSmudge), + static_cast(TexturePaintController::ToolSmartSelect), + static_cast(TexturePaintController::ToolPaint)}) { + ctrl->setBrushTool(t); + EXPECT_EQ(ctrl->brushTool(), t); + } +} + +TEST(TexturePaintControllerStandalone, SetBrushToolNoSignalOnSameValue) { auto* ctrl = TexturePaintController::instance(); ctrl->setBrushTool(TexturePaintController::ToolPaint); QSignalSpy spy(ctrl, &TexturePaintController::brushToolChanged); - ctrl->setBrushTool(TexturePaintController::ToolErase); - EXPECT_EQ(ctrl->brushTool(), static_cast(TexturePaintController::ToolErase)); + ctrl->setBrushTool(TexturePaintController::ToolPaint); + EXPECT_EQ(spy.count(), 0); +} + +TEST(TexturePaintControllerStandalone, SetPaintTargetEmitsOnceAndSticks) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::paintTargetChanged); + ctrl->setPaintTarget(TexturePaintController::TargetVertex); + EXPECT_EQ(ctrl->paintTarget(), static_cast(TexturePaintController::TargetVertex)); EXPECT_EQ(spy.count(), 1); - // Same value should not re-emit. - ctrl->setBrushTool(TexturePaintController::ToolErase); + ctrl->setPaintTarget(TexturePaintController::TargetVertex); + EXPECT_EQ(spy.count(), 1) << "same target should not re-emit"; + ctrl->setPaintTarget(TexturePaintController::TargetTexture); + EXPECT_EQ(spy.count(), 2); +} + +TEST(TexturePaintControllerStandalone, SetPaintTargetAlsoFiresSessionAndSmartSelectSignals) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy session(ctrl, &TexturePaintController::sessionChanged); + QSignalSpy smart (ctrl, &TexturePaintController::smartSelectChanged); + ctrl->setPaintTarget(TexturePaintController::TargetVertex); + EXPECT_GE(session.count(), 1); + EXPECT_GE(smart.count(), 1); +} + +TEST(TexturePaintControllerStandalone, SmartSelectToleranceClampedAndEmits) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::smartSelectChanged); + ctrl->setSmartSelectTolerance(0.42); + EXPECT_DOUBLE_EQ(ctrl->smartSelectTolerance(), 0.42); EXPECT_EQ(spy.count(), 1); - ctrl->setBrushTool(TexturePaintController::ToolPaint); + ctrl->setSmartSelectTolerance(0.42); + EXPECT_EQ(spy.count(), 1) << "no re-emit on no-op"; + ctrl->setSmartSelectTolerance(-1.0); + EXPECT_DOUBLE_EQ(ctrl->smartSelectTolerance(), 0.0); + ctrl->setSmartSelectTolerance(99.0); + EXPECT_DOUBLE_EQ(ctrl->smartSelectTolerance(), 1.0); +} + +TEST(TexturePaintControllerStandalone, SetUvOverlayVisibleTogglesAndEmits) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::uvOverlayChanged); + ctrl->setUvOverlayVisible(true); + EXPECT_TRUE(ctrl->uvOverlayVisible()); + EXPECT_EQ(spy.count(), 1); + ctrl->setUvOverlayVisible(true); + EXPECT_EQ(spy.count(), 1); + ctrl->setUvOverlayVisible(false); + EXPECT_FALSE(ctrl->uvOverlayVisible()); + EXPECT_EQ(spy.count(), 2); +} + +TEST(TexturePaintControllerStandalone, BrushSettersMirrorIntoEditModeController) { + auto* ctrl = TexturePaintController::instance(); + auto* em = EditModeController::instance(); + ctrl->setBrushRadius(0.123); + EXPECT_DOUBLE_EQ(em->vertexPaintRadius(), 0.123); + ctrl->setBrushStrength(0.456); + EXPECT_DOUBLE_EQ(em->vertexPaintStrength(), 0.456); + ctrl->setBrushFalloff(0.789); + EXPECT_DOUBLE_EQ(em->vertexPaintFalloff(), 0.789); + QColor c(12, 34, 56); + ctrl->setBrushColor(c); + EXPECT_EQ(em->vertexPaintColor(), c); + // And reading-side mirrors agree. + EXPECT_DOUBLE_EQ(ctrl->texturePaintRadius(), 0.123); + EXPECT_DOUBLE_EQ(ctrl->texturePaintStrength(), 0.456); + EXPECT_DOUBLE_EQ(ctrl->texturePaintFalloff(), 0.789); + EXPECT_EQ(ctrl->texturePaintColor(), c); +} + +TEST(TexturePaintControllerStandalone, BrushShapeMirrorsEditMode) { + auto* ctrl = TexturePaintController::instance(); + auto* em = EditModeController::instance(); + em->setVertexPaintShape(EditModeController::ShapeSquare); + EXPECT_EQ(ctrl->brushShape(), static_cast(EditModeController::ShapeSquare)); + em->setVertexPaintShape(EditModeController::ShapeRound); + EXPECT_EQ(ctrl->brushShape(), static_cast(EditModeController::ShapeRound)); +} + +TEST(TexturePaintControllerStandalone, BgPaintColorMirrorsEditMode) { + auto* ctrl = TexturePaintController::instance(); + auto* em = EditModeController::instance(); + em->setVertexPaintBackgroundColor(QColor(7, 8, 9)); + EXPECT_EQ(ctrl->bgPaintColor(), QColor(7, 8, 9)); +} + +TEST(TexturePaintControllerStandalone, SetActiveSlotIndexOutOfRangeIsNoOp) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + const int before = ctrl->activeSlotIndex(); + ctrl->setActiveSlotIndex(-1); + ctrl->setActiveSlotIndex(9999); + EXPECT_EQ(ctrl->activeSlotIndex(), before); +} + +TEST(TexturePaintControllerStandalone, EnableWithNoSelectionIsHarmless) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::texturePaintChanged); + ctrl->setTexturePaintEnabled(true); + EXPECT_TRUE(ctrl->texturePaintEnabled()); + EXPECT_FALSE(ctrl->hasActiveSession()) << "no selection → no session"; + EXPECT_EQ(spy.count(), 1); + ctrl->setTexturePaintEnabled(true); + EXPECT_EQ(spy.count(), 1) << "no re-emit on no-op"; + ctrl->setTexturePaintEnabled(false); + EXPECT_FALSE(ctrl->texturePaintEnabled()); + EXPECT_EQ(spy.count(), 2); +} + +TEST(TexturePaintControllerStandalone, FullResPreviewUrlEmptyWithoutSession) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_TRUE(ctrl->fullResPreviewUrl().isEmpty()) + << "with no buffer, the URL must be empty so QML clears the Image source"; +} + +TEST(TexturePaintControllerStandalone, SnapshotBufferImageEmptyWithoutSession) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + QImage img = ctrl->snapshotBufferImage(); + EXPECT_TRUE(img.isNull()) << "no buffer → null QImage for the image provider"; +} + +TEST(TexturePaintControllerStandalone, MaskActionsNoOpWithoutSession) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_EQ(ctrl->fillMaskWithFG(), 0); + EXPECT_EQ(ctrl->fillMaskWithBG(), 0); + EXPECT_EQ(ctrl->deleteMaskPixels(), 0); +} + +TEST(TexturePaintControllerStandalone, SmartSelectMaskOpsNoOpWithoutSession) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_EQ(ctrl->smartSelectAtUV(0.5, 0.5, 0), 0); + ctrl->selectAllMask(); + EXPECT_FALSE(ctrl->hasSelectionMask()); + ctrl->invertSelectionMask(); + EXPECT_FALSE(ctrl->hasSelectionMask()); +} + +TEST(TexturePaintControllerStandalone, ClearSelectionMaskNoOpWhenEmpty) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::smartSelectChanged); + ctrl->clearSelectionMask(); + EXPECT_EQ(spy.count(), 0) << "no emit when there's nothing to clear"; +} + +TEST(TexturePaintControllerStandalone, BakeVertexColorsNoOpWithoutSelection) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_EQ(ctrl->bakeVertexColorsToTexture(64, 1, QString()), -1); +} + +TEST(TexturePaintControllerStandalone, SavePaintBufferFailsWithoutSession) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_FALSE(ctrl->savePaintBuffer(QStringLiteral("/tmp/should_not_be_written.png"))); + EXPECT_FALSE(ctrl->savePaintBuffer(QString())); +} + +TEST(TexturePaintControllerStandalone, LoadPaintBufferRejectsEmptyAndMissingPath) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_FALSE(ctrl->loadPaintBuffer(QString())); + EXPECT_FALSE(ctrl->loadPaintBuffer(QStringLiteral("/nonexistent/path/should_fail.png"))); +} + +TEST(TexturePaintControllerStandalone, EnsurePaintableTextureNoOpWithoutSelection) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_FALSE(ctrl->ensurePaintableTexture(256)); + EXPECT_FALSE(ctrl->hasActiveSession()); +} + +TEST(TexturePaintControllerStandalone, BeginStrokeUVRejectedWithoutSession) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_FALSE(ctrl->beginStrokeUV(0.5, 0.5)); +} + +TEST(TexturePaintControllerStandalone, RefreshSlotsClearsListWithoutSelection) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + ctrl->refreshSlots(); + EXPECT_TRUE(ctrl->textureSlots().isEmpty()); +} + +TEST(TexturePaintControllerStandalone, CloseSessionWithoutSessionIsHarmless) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + ctrl->closeSession(); + EXPECT_FALSE(ctrl->hasActiveSession()); +} + +TEST(TexturePaintControllerStandalone, SetUvOverlayVisibleNoSessionStillToggles) { + // Visibility flag is purely controller state — independent of whether + // a session exists. + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + ctrl->setUvOverlayVisible(true); + EXPECT_TRUE(ctrl->uvOverlayVisible()); + EXPECT_TRUE(ctrl->uvOverlayDataUri().isEmpty()) << "no session ⇒ no overlay PNG"; + ctrl->setUvOverlayVisible(false); +} + +TEST(TexturePaintControllerStandalone, BakeToOriginalFileEmptyWithoutSession) { + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + EXPECT_TRUE(ctrl->bakeToOriginalFile().isEmpty()); +} + +TEST(TexturePaintControllerStandalone, SnapshotBufferImageProviderShape) { + // The PaintBufferImageProvider asks for image://paintbuffer/current — + // confirm the URL it returns is well-formed (image://paintbuffer/...?v=N). + // After a paint refresh the version bumps; spot-check the prefix here. + hardResetController(); + auto* ctrl = TexturePaintController::instance(); + const QString url = ctrl->fullResPreviewUrl(); + EXPECT_TRUE(url.isEmpty()) << "no session ⇒ empty URL"; +} + +// =========================================================================== +// Stateful fixture tests — need a scene + entity +// =========================================================================== + +class TexturePaintControllerSceneTest : public ::testing::Test +{ +protected: + void SetUp() override + { + // Use tryInitOgre() (not canLoadMeshFiles()) to match the existing + // FindMeshPointForUVHitsCorrectTriangle test in this file — + // canLoadMeshFiles() returns false in some Linux-CI permutations + // even though hardware buffer creation works for the simple + // in-memory meshes these tests use. + ASSERT_TRUE(tryInitOgre()) << "Ogre init / render window required"; + hardResetController(); + } + + void TearDown() override + { + m_fix.teardown(); + hardResetController(); + } + + ScenePaintFixture m_fix; +}; + +TEST_F(TexturePaintControllerSceneTest, EnableWithSelectionCreatesSession) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Enable"))); + auto* ctrl = TexturePaintController::instance(); + ctrl->setPaintTarget(TexturePaintController::TargetTexture); + ctrl->setTexturePaintEnabled(true); + EXPECT_TRUE(ctrl->texturePaintEnabled()); + EXPECT_TRUE(ctrl->hasActiveSession()); + EXPECT_EQ(ctrl->textureResolution(), 1024); + EXPECT_FALSE(ctrl->currentTextureName().isEmpty()); + // Disabling tears the session back down. + ctrl->setTexturePaintEnabled(false); + EXPECT_FALSE(ctrl->hasActiveSession()); +} + +TEST_F(TexturePaintControllerSceneTest, EnsurePaintableTextureBuildsBuffer) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Ensure"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(128)); + EXPECT_TRUE(ctrl->hasActiveSession()); + EXPECT_EQ(ctrl->textureResolution(), 128); + EXPECT_EQ(ctrl->buffer().width(), 128); + EXPECT_EQ(ctrl->buffer().height(), 128); +} + +TEST_F(TexturePaintControllerSceneTest, EnsurePaintableTextureReusesExistingSession) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Reuse"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + const QString firstName = ctrl->currentTextureName(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)) << "second call should reuse the session"; + EXPECT_EQ(ctrl->currentTextureName(), firstName); +} + +TEST_F(TexturePaintControllerSceneTest, CloseSessionResetsAllState) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Close"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(32)); + ASSERT_TRUE(ctrl->hasActiveSession()); + ctrl->closeSession(); + EXPECT_FALSE(ctrl->hasActiveSession()); + EXPECT_TRUE(ctrl->currentTextureName().isEmpty()); + EXPECT_EQ(ctrl->textureResolution(), 0); +} + +TEST_F(TexturePaintControllerSceneTest, RefreshSlotsExposesEntitySlots) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Slots"))); + auto* ctrl = TexturePaintController::instance(); + ctrl->refreshSlots(); + QVariantList slotList = ctrl->textureSlots(); + EXPECT_GE(slotList.size(), 1); + if (!slotList.isEmpty()) { + auto entry = slotList.first().toMap(); + EXPECT_TRUE(entry.contains("slot")); + EXPECT_TRUE(entry.contains("submesh")); + } +} + +TEST_F(TexturePaintControllerSceneTest, FullResPreviewUrlActiveSession) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("PreviewUrl"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + const QString url = ctrl->fullResPreviewUrl(); + EXPECT_TRUE(url.startsWith(QStringLiteral("image://paintbuffer/"))); + EXPECT_TRUE(url.contains(QStringLiteral("?v="))); +} + +TEST_F(TexturePaintControllerSceneTest, SnapshotBufferImageMatchesBuffer) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Snap"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(48)); + QImage img = ctrl->snapshotBufferImage(); + EXPECT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), ctrl->buffer().width()); + EXPECT_EQ(img.height(), ctrl->buffer().height()); +} + +TEST_F(TexturePaintControllerSceneTest, SaveAndLoadPaintBufferRoundTrip) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Roundtrip"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString out = tmp.path() + "/buf.png"; + EXPECT_TRUE(ctrl->savePaintBuffer(out)); + EXPECT_TRUE(QFile::exists(out)); + // Round-tripping the saved file back in should keep the session alive. + EXPECT_TRUE(ctrl->loadPaintBuffer(out)); + EXPECT_TRUE(ctrl->hasActiveSession()); +} + +TEST_F(TexturePaintControllerSceneTest, FindMeshPointForUVHitsAllThreeVertices) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Find"))); + auto* ctrl = TexturePaintController::instance(); + ctrl->refreshSlots(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + Ogre::Vector3 pos, n; + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 0.0f), pos, n)); + EXPECT_NEAR(pos.x, 0.0f, 1e-4); + EXPECT_NEAR(pos.y, 0.0f, 1e-4); + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(1.0f, 0.0f), pos, n)); + EXPECT_NEAR(pos.x, 1.0f, 1e-4); + EXPECT_TRUE(ctrl->findMeshPointForUV(Ogre::Vector2(0.0f, 1.0f), pos, n)); + EXPECT_NEAR(pos.y, 1.0f, 1e-4); +} + +TEST_F(TexturePaintControllerSceneTest, FindMeshPointForUVMissesOutsideTriangle) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("FindMiss"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(32)); + Ogre::Vector3 pos, n; + EXPECT_FALSE(ctrl->findMeshPointForUV(Ogre::Vector2(0.99f, 0.99f), pos, n)); +} + +TEST_F(TexturePaintControllerSceneTest, TexturePaintRadiusUVMapsThroughMeshExtent) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("RadUV"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(32)); + ctrl->setBrushRadius(0.5); + const double uv = ctrl->texturePaintRadiusUV(); + EXPECT_GT(uv, 0.005); + EXPECT_LE(uv, 1.0); +} + +TEST_F(TexturePaintControllerSceneTest, SmartSelectInsideTriangleAddsPixels) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Smart"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(32)); + // White buffer + low tolerance: pick the center pixel → mass select. + ctrl->setSmartSelectTolerance(0.5); + const int affected = ctrl->smartSelectAtUV(0.2, 0.2, 0); + EXPECT_GT(affected, 0); + EXPECT_TRUE(ctrl->hasSelectionMask()); + EXPECT_EQ(ctrl->selectedPixelCount(), affected); +} + +TEST_F(TexturePaintControllerSceneTest, SelectAllMaskCoversEveryPixel) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("All"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->selectAllMask(); + EXPECT_TRUE(ctrl->hasSelectionMask()); + EXPECT_EQ(ctrl->selectedPixelCount(), 16 * 16); +} + +TEST_F(TexturePaintControllerSceneTest, InvertSelectionMaskFlipsCount) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Invert"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->selectAllMask(); + const int allCount = ctrl->selectedPixelCount(); + EXPECT_EQ(allCount, 16 * 16); + ctrl->invertSelectionMask(); + EXPECT_EQ(ctrl->selectedPixelCount(), 0); + ctrl->invertSelectionMask(); + EXPECT_EQ(ctrl->selectedPixelCount(), allCount); +} + +TEST_F(TexturePaintControllerSceneTest, ClearSelectionMaskFiresSmartSignal) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("ClearMask"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->selectAllMask(); + QSignalSpy spy(ctrl, &TexturePaintController::smartSelectChanged); + ctrl->clearSelectionMask(); + EXPECT_EQ(spy.count(), 1); + EXPECT_FALSE(ctrl->hasSelectionMask()); +} + +TEST_F(TexturePaintControllerSceneTest, FillMaskWithFGAffectsAllSelectedPixels) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("FillFG"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->selectAllMask(); + ctrl->setBrushColor(QColor(255, 0, 0)); + const int affected = ctrl->fillMaskWithFG(); + EXPECT_EQ(affected, 16 * 16); + // First pixel should now be red. + const auto& px = ctrl->buffer().data(); + EXPECT_EQ(px[0], 255); + EXPECT_EQ(px[1], 0); + EXPECT_EQ(px[2], 0); +} + +TEST_F(TexturePaintControllerSceneTest, FillMaskWithBGAffectsAllSelectedPixels) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("FillBG"))); + auto* ctrl = TexturePaintController::instance(); + auto* em = EditModeController::instance(); + em->setVertexPaintBackgroundColor(QColor(0, 255, 0)); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->selectAllMask(); + const int affected = ctrl->fillMaskWithBG(); + EXPECT_EQ(affected, 16 * 16); + const auto& px = ctrl->buffer().data(); + EXPECT_EQ(px[1], 255); +} + +TEST_F(TexturePaintControllerSceneTest, DeleteMaskPixelsZeroesAll) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("DelMask"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->selectAllMask(); + const int affected = ctrl->deleteMaskPixels(); + EXPECT_EQ(affected, 16 * 16); + const auto& px = ctrl->buffer().data(); + EXPECT_EQ(px[0], 0); + EXPECT_EQ(px[3], 0) << "alpha must be zeroed too"; +} + +TEST_F(TexturePaintControllerSceneTest, MaskActionsNoOpWhenSelectionEmpty) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("MaskNoOp"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ASSERT_FALSE(ctrl->hasSelectionMask()); + EXPECT_EQ(ctrl->fillMaskWithFG(), 0); + EXPECT_EQ(ctrl->fillMaskWithBG(), 0); + EXPECT_EQ(ctrl->deleteMaskPixels(), 0); +} + +TEST_F(TexturePaintControllerSceneTest, SetActiveSlotIndexZeroIsIdempotent) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("SlotIdx"))); + auto* ctrl = TexturePaintController::instance(); + ctrl->refreshSlots(); + const int before = ctrl->activeSlotIndex(); + ctrl->setActiveSlotIndex(before); // same value + EXPECT_EQ(ctrl->activeSlotIndex(), before); +} + +TEST_F(TexturePaintControllerSceneTest, ApplyPixelSnapshotRoundTripsBuffer) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("ApplySnap"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(8)); + // Capture the initial buffer, mutate, then restore via applyPixelSnapshot + // — this is the same code path the undo stack uses. + auto before = ctrl->buffer().data(); + ctrl->mutableBuffer().data()[0] = 1; + ctrl->mutableBuffer().data()[1] = 2; + ctrl->mutableBuffer().data()[2] = 3; + ctrl->mutableBuffer().data()[3] = 4; + EXPECT_NE(ctrl->buffer().data()[0], before[0]); + ctrl->applyPixelSnapshot(before); + EXPECT_EQ(ctrl->buffer().data()[0], before[0]); + EXPECT_EQ(ctrl->buffer().data()[1], before[1]); + EXPECT_EQ(ctrl->buffer().data()[2], before[2]); + EXPECT_EQ(ctrl->buffer().data()[3], before[3]); +} + +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 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"; +} + +TEST_F(TexturePaintControllerSceneTest, SwitchPaintTargetTexToVertexTearsDownSession) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("SwitchTarget"))); + auto* ctrl = TexturePaintController::instance(); + ctrl->setPaintTarget(TexturePaintController::TargetTexture); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ASSERT_TRUE(ctrl->hasActiveSession()); + ctrl->setPaintTarget(TexturePaintController::TargetVertex); + EXPECT_FALSE(ctrl->hasActiveSession()) + << "moving to vertex target must release the texture-paint session"; +} + +TEST_F(TexturePaintControllerSceneTest, SetHoveredUVEmitsSignal) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Hover"))); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::hoveredUVChanged); + ctrl->setHoveredUV(0.3, 0.7); + EXPECT_EQ(spy.count(), 1); + if (spy.count() > 0) { + const auto args = spy.first(); + EXPECT_NEAR(args.at(0).toDouble(), 0.3, 1e-6); + EXPECT_NEAR(args.at(1).toDouble(), 0.7, 1e-6); + } +} + +TEST_F(TexturePaintControllerSceneTest, ClearHoveredUVEmitsMinusOne) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("HoverClear"))); + auto* ctrl = TexturePaintController::instance(); + ctrl->setHoveredUV(0.5, 0.5); + QSignalSpy spy(ctrl, &TexturePaintController::hoveredUVChanged); + ctrl->clearHoveredUV(); + EXPECT_EQ(spy.count(), 1); + if (spy.count() > 0) { + const auto args = spy.first(); + EXPECT_LT(args.at(0).toDouble(), 0.0); + EXPECT_LT(args.at(1).toDouble(), 0.0); + } +} + +TEST_F(TexturePaintControllerSceneTest, BeginAndEndStrokeUVCompletesWithoutCrash) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("StrokeUV"))); + auto* ctrl = TexturePaintController::instance(); + // beginStrokeUV requires paint mode on — setTexturePaintEnabled + // also calls ensurePaintableTexture internally. + ctrl->setTexturePaintEnabled(true); + ASSERT_TRUE(ctrl->hasActiveSession()); + EXPECT_TRUE(ctrl->beginStrokeUV(0.5, 0.5)); + ctrl->updateStrokeUV(0.6, 0.5); + ctrl->endStrokeUV(); + SUCCEED(); +} + +TEST_F(TexturePaintControllerSceneTest, SmartSelectInvalidModeFallsToReplace) { + // mode 0 = replace, 1 = add, 2 = sub; anything else falls through to + // the replace branch. Exercise that branch. + ASSERT_TRUE(m_fix.setup(QStringLiteral("SmartBadMode"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->setSmartSelectTolerance(0.5); + const int affected = ctrl->smartSelectAtUV(0.5, 0.5, 99); + EXPECT_GE(affected, 0); // valid call, doesn't crash on unrecognised mode +} + +TEST_F(TexturePaintControllerSceneTest, SmartSelectAddAndSubtractCombineModes) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("SmartCombine"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + ctrl->setSmartSelectTolerance(0.5); + const int initial = ctrl->smartSelectAtUV(0.5, 0.5, 0); + EXPECT_GT(initial, 0); + const int afterAdd = ctrl->smartSelectAtUV(0.5, 0.5, 1); + (void)afterAdd; // depending on impl may be 0 (already selected) or more + // Sub on the same seed should remove pixels. + const int afterSub = ctrl->smartSelectAtUV(0.5, 0.5, 2); + EXPECT_GE(afterSub, 0); +} + +TEST_F(TexturePaintControllerSceneTest, EnsurePaintableTextureFreshSessionEmitsSessionChanged) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("EnsureEmit"))); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::sessionChanged); + ASSERT_TRUE(ctrl->ensurePaintableTexture(32)); + EXPECT_GE(spy.count(), 1); +} + +TEST_F(TexturePaintControllerSceneTest, LoadPaintBufferReplacesBufferWithFileBytes) { + // Write a known image to disk, load it via the controller, verify the + // first pixel matches. + ASSERT_TRUE(m_fix.setup(QStringLiteral("Load"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(8)); + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString path = tmp.path() + "/known.png"; + QImage seed(8, 8, QImage::Format_RGBA8888); + seed.fill(QColor(11, 22, 33, 255)); + ASSERT_TRUE(seed.save(path)); + ASSERT_TRUE(ctrl->loadPaintBuffer(path)); + const auto& px = ctrl->buffer().data(); + EXPECT_EQ(px[0], 11); + EXPECT_EQ(px[1], 22); + EXPECT_EQ(px[2], 33); +} + +TEST_F(TexturePaintControllerSceneTest, SavePaintBufferProducesReadablePNG) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("SavePNG"))); + auto* ctrl = TexturePaintController::instance(); + // ensurePaintableTexture floors the resolution at 16 when no existing + // texture is bound (so request 32 to get a non-trivial known size). + ASSERT_TRUE(ctrl->ensurePaintableTexture(32)); + ASSERT_EQ(ctrl->textureResolution(), 32); + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString out = tmp.path() + "/save.png"; + EXPECT_TRUE(ctrl->savePaintBuffer(out)); + QImage round(out); + EXPECT_FALSE(round.isNull()); + EXPECT_EQ(round.width(), 32); + EXPECT_EQ(round.height(), 32); +} + +TEST_F(TexturePaintControllerSceneTest, OpenAndCloseEditorWindowFlipFlag) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("EditorWin"))); + auto* ctrl = TexturePaintController::instance(); + QSignalSpy spy(ctrl, &TexturePaintController::editorWindowChanged); + ctrl->openEditorWindow(); + // openEditorWindow has a fast path when no session — exercise without + // requiring it to succeed in a headless test (the QML may fail to + // load). Just make sure closing is harmless. + ctrl->closeEditorWindow(); + EXPECT_FALSE(ctrl->editorWindowOpen()); +} + +TEST_F(TexturePaintControllerSceneTest, FullResVersionAdvancesPerRefresh) { + // Bumping pixels + flushing should change the URL's `?v=N` segment so + // QML invalidates Image cache. flushDirtyToOgre is debounced (~16 ms) + // and the preview refresh inside is further debounced (~60 ms), so + // we pump the event loop to let both timers fire. + ASSERT_TRUE(m_fix.setup(QStringLiteral("Bump"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + const QString url1 = ctrl->fullResPreviewUrl(); + ctrl->selectAllMask(); + ctrl->setBrushColor(QColor(123, 45, 67)); + ctrl->fillMaskWithFG(); + pumpEventsFor(150); + const QString url2 = ctrl->fullResPreviewUrl(); + EXPECT_NE(url1, url2) << "refresh must bump the version counter"; +} + +TEST_F(TexturePaintControllerSceneTest, BakeVertexColorsToTextureBakesPixels) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("Bake"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(32)); + // bakeVertexColorsToTexture returns the rasterized pixel count. Some + // mesh configurations have no vertex colors → returns 0 or -1; we + // assert it doesn't crash and yields a non-negative-or--1 value. + const int n = ctrl->bakeVertexColorsToTexture(32, 1, QString()); + EXPECT_GE(n, -1); +} + +TEST_F(TexturePaintControllerSceneTest, RefreshPreviewUriEmitsFullResSignal) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("RefreshSig"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + QSignalSpy spy(ctrl, &TexturePaintController::fullResPreviewChanged); + // Mask write → flushDirtyToOgre (16 ms debounce) → refreshPreviewUri + // (60 ms debounce inside). Pump events to let both timers fire. + ctrl->selectAllMask(); + ctrl->setBrushColor(QColor(99, 99, 99)); + ctrl->fillMaskWithFG(); + pumpEventsFor(150); + EXPECT_GE(spy.count(), 1); +} + +TEST_F(TexturePaintControllerSceneTest, SelectAllMaskHonorsBufferDimensions) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("MaskRes"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(64)); + ctrl->selectAllMask(); + EXPECT_EQ(ctrl->selectedPixelCount(), 64 * 64); +} + +TEST_F(TexturePaintControllerSceneTest, ChangingResolutionAfterCloseProducesNewBuffer) { + ASSERT_TRUE(m_fix.setup(QStringLiteral("ResChange"))); + auto* ctrl = TexturePaintController::instance(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(16)); + EXPECT_EQ(ctrl->textureResolution(), 16); + ctrl->closeSession(); + ASSERT_TRUE(ctrl->ensurePaintableTexture(48)); + EXPECT_EQ(ctrl->textureResolution(), 48); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e968e522..c4fff7a7 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -73,6 +73,7 @@ #include "AssetBrowserController.h" #include "EditModeController.h" #include "TexturePaintController.h" +#include "PaintBufferImageProvider.h" #include "EditorModeController.h" #include #include @@ -542,6 +543,14 @@ void MainWindow::initToolBar() return TexturePaintController::qmlInstance(engine, nullptr); }); + // Same image provider the detached editor window uses — serves the + // live paint buffer as a QImage view (no PNG encode, no base64). + // Without this the Inspector thumbnail had to PNG-encode + reload + // a brand-new data URI per refresh, which visibly blinked the + // thumbnail during a paint stroke. + m_propertiesPanel->engine()->addImageProvider( + QStringLiteral("paintbuffer"), new PaintBufferImageProvider()); + m_propertiesPanel->setSource(QUrl("qrc:/PropertiesPanel/PropertiesPanel.qml")); if (auto* root = m_propertiesPanel->rootObject()) { root->setProperty("bottomToolHost",