#include "Document/BrushStroke.h" #include "Document/ContentFill.h" #include "SelectionFixtures.h" #include "SessionRecord.h" #include #include #include // Content-Aware Fill through the session; Subject Removal is 9.7's. namespace { const std::array red{255, 0, 0, 255}, blue{51, 153, 204, 255}; QImage picture(int width, int height, const std::function(int, int)> &colour) { QImage image = BrushRaster::context(width, height, false); for (int y = 0; y < height; ++y) { uchar *row = image.scanLine(y); for (int x = 0; x < width; ++x) std::copy_n(colour(x, y).data(), 4, row + x * 4); } return image; } std::vector pixel(const QImage &image, int x, int y) { const uchar *at = image.constScanLine(y) + x * 4; return {at[0], at[1], at[2], at[3]}; } bool within(const std::vector &pixel, const std::array &expected, int tolerance) { for (size_t i = 0; i < 4; ++i) { if (std::abs(pixel[i] - expected[i]) > tolerance) return false; } return true; } // Swift's fixture: a blue field with a red object, selected. std::unique_ptr fixture() { auto session = std::make_unique(); session->createDocument(64, 48, true); const QImage image = picture(64, 48, [](int x, int y) { return x >= 20 && x < 32 && y >= 16 && y < 26 ? red : blue; }); session->insert(ImportedImage(image, image, "Object")); session->applySelection(rectPath(QRectF(20, 16, 12, 10)), SelectionMode::replace, "Select"); return session; } bool committed(EditorSession &session) { bool done = false; session.commitFilter([&] { done = true; }); return QTest::qWaitFor([&] { return done; }, 20000); } bool prepared(EditorSession &session) { return QTest::qWaitFor([&] { return session.filterEdit().has_value() && !session.filterEdit().value().preparing; }, 20000); } QImage active(const EditorSession &session) { return session.activeLayer().value().asset.value().image(); } } class SmartEditTests : public QObject { Q_OBJECT private slots: void fillContinuesRepeatingTexture(); void fillReconstructsBackgroundInsideSelectionAndUndoes(); void cancelAndNoSourceLeaveOriginalUntouched(); void theFillGrowsTheLayerOverASelectionPastItsEdge(); void aSolidPlacedOrDisabledMaskFollowsSwift(); void growthKeepsTheLayersTurn(); void commitsThatWaitForThePreviewEachResume(); void aSoftEdgeBlendsTheFillInByItsCoverage(); void theFillRefusesWhatSwiftRefuses(); void aLayerChangedMeanwhileTakesNoResult(); void filterCallsAnnounceAndRefuseInSilence(); void theCanvasCounterMovesWhereSwiftsDoes(); }; void SmartEditTests::fillContinuesRepeatingTexture() { EditorSession session; session.createDocument(80, 64, true); // Stripes four wide, a red block over them. const QImage image = picture(80, 64, [](int x, int y) { if (x >= 32 && x < 44 && y >= 26 && y < 36) return red; const uchar value = (x / 4) % 2 == 0 ? 51 : 204; return std::array{value, value, value, 255}; }); session.insert(ImportedImage(image, image, "Stripes")); session.applySelection(rectPath(QRectF(32, 26, 12, 10)), SelectionMode::replace, "Select"); session.beginFilter(FilterKind::contentAwareFill); QVERIFY(committed(session)); const QImage result = active(session); int matching = 0; for (int y = 26; y < 36; ++y) { for (int x = 32; x < 44; ++x) matching += std::abs(pixel(result, x, y)[0] - ((x / 4) % 2 == 0 ? 51 : 204)) <= 1; } QVERIFY2(matching >= 114, qPrintable(QStringLiteral("Matched %1 of 120 texture pixels").arg(matching))); } void SmartEditTests::fillReconstructsBackgroundInsideSelectionAndUndoes() { const auto session = fixture(); const ImageIdentity original = session->activeLayer().value().asset.value().identity(); const int count = session->history.undoCount(); QVERIFY(session->canContentAwareFill()); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(session->filterEdit().has_value() && session->filterEdit().value().preparing); QVERIFY(!session->filterEdit().value().grownTransform.has_value()); QVERIFY(committed(*session)); QVERIFY(!session->filterEdit().has_value() && !session->isProjectBusy()); QCOMPARE(session->history.undoCount(), count + 1); QCOMPARE(session->history.undoName(), QString("Content-Aware Fill")); const QImage image = active(*session); QCOMPARE(image.size(), QSize(64, 48)); for (int y = 0; y < 48; ++y) { for (int x = 0; x < 64; ++x) QVERIFY2(within(pixel(image, x, y), blue, 1), qPrintable(QStringLiteral("pixel %1,%2").arg(x).arg(y))); } QCOMPARE(session->activeLayer().value().name, QString("Object")); session->undo(); QCOMPARE(session->activeLayer().value().asset.value().identity(), original); } void SmartEditTests::cancelAndNoSourceLeaveOriginalUntouched() { const auto session = fixture(); const ImageIdentity original = session->activeLayer().value().asset.value().identity(); session->beginFilter(FilterKind::contentAwareFill); session->cancelFilter(); QVERIFY(!session->filterEdit().has_value()); QCOMPARE(session->activeLayer().value().asset.value().identity(), original); // Everything selected: no pixel to copy from. session->selectAll(); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(prepared(*session)); QCOMPARE(session->filterEdit().value().previewError, std::optional(QString("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image."))); QVERIFY(!session->filterEdit().value().previewImage(session->activeLayerID().value()).has_value()); QVERIFY(committed(*session)); QVERIFY(session->filterEdit().has_value()); QCOMPARE(session->activeLayer().value().asset.value().identity(), original); session->cancelFilter(); session->deselect(); QVERIFY(!session->canContentAwareFill()); } void SmartEditTests::theFillGrowsTheLayerOverASelectionPastItsEdge() { EditorSession session; session.createDocument(60, 40, true); const QImage image = picture(20, 20, [](int, int) { return blue; }); session.insert(ImportedImage(image, image, "Blue"), QPointF(20, 20)); const QUuid id = session.activeLayerID().value(); QCOMPARE(session.activeLayer().value().transform.origin, QPointF(10, 10)); // White edges, a black block inside: the pixels must carry. QImage drawn = BrushRaster::context(20, 20, true); drawn.fill(Qt::white); for (int y = 5; y < 15; ++y) std::fill_n(drawn.scanLine(y) + 5, 10, uchar(0)); rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(drawn)); }); session.selectLayer(id); // Past the top left corner: 30 by 25 at (0,5). session.applySelection(rectPath(QRectF(-10, 5, 30, 10)), SelectionMode::replace, "Select"); session.beginFilter(FilterKind::contentAwareFill); const FilterEdit &edit = session.filterEdit().value(); QCOMPARE(edit.grownTransform, std::optional(LayerTransform{.origin = {0, 5}, .size = {30, 25}})); QCOMPARE(edit.grownImage.value().size(), QSize(30, 25)); QCOMPARE(pixel(edit.grownImage.value(), 5, 3)[3], 0); QVERIFY(within(pixel(edit.grownImage.value(), 15, 10), blue, 0)); // The preview sits on the grown grid, the mask stays. const ImageLayer layer = session.activeLayer().value(); QCOMPARE(session.displayedTransform(layer), layer.transform); QVERIFY(prepared(session)); QCOMPARE(session.displayedTransform(layer), edit.grownTransform.value()); QCOMPARE(session.displayedMaskPlacement(layer), std::optional(layer.transform)); QVERIFY(within(pixel(edit.previewImage(id).value(), 5, 3), blue, 1)); QVERIFY(!edit.previewImage(QUuid::createUuid()).has_value()); session.updateFilter(FilterSettings(), false); QCOMPARE(session.displayedTransform(layer), layer.transform); QVERIFY(!session.displayedMaskPlacement(layer).has_value()); session.updateFilter(FilterSettings(), true); QVERIFY(committed(session)); const ImageLayer grown = session.activeLayer().value(); QCOMPARE(grown.transform, (LayerTransform{.origin = {0, 5}, .size = {30, 25}})); QCOMPARE(grown.asset.value().size(), QSize(30, 25)); QVERIFY(within(pixel(grown.asset.value().image(), 5, 3), blue, 1)); QCOMPARE(pixel(grown.asset.value().image(), 5, 22)[3], 0); // The mask covers the new grid, white past the edge. const QImage mask = grown.mask.value().asset.image(); QCOMPARE(mask.size(), QSize(30, 25)); QCOMPARE(int(mask.constScanLine(3)[5]), 255); QCOMPARE(int(mask.constScanLine(13)[18]), 0); QCOMPARE(int(mask.constScanLine(7)[12]), 255); QVERIFY(!grown.mask.value().placement.has_value()); session.undo(); QCOMPARE(session.activeLayer().value().transform.size, QSizeF(20, 20)); } void SmartEditTests::aSolidPlacedOrDisabledMaskFollowsSwift() { EditorSession session; session.createDocument(60, 40, true); const QImage image = picture(20, 20, [](int, int) { return blue; }); session.insert(ImportedImage(image, image, "Blue"), QPointF(20, 20)); const QUuid id = session.activeLayerID().value(); const auto fill = [&] { session.selectLayerTarget(id, false); session.applySelection(rectPath(QRectF(25, 15, 20, 10)), SelectionMode::replace, "Select"); session.beginFilter(FilterKind::contentAwareFill); return committed(session); }; // A solid mask is left as it is. session.addMask(true); QVERIFY(fill()); QCOMPARE(session.activeLayer().value().asset.value().size(), QSize(35, 20)); QCOMPARE(session.activeLayer().value().mask.value().asset.size(), QSize(1, 1)); session.undo(); // Empty selection: nothing to fill. QImage white = BrushRaster::context(20, 20, true); white.fill(Qt::white); rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); record(snapshot, id).maskPlacement = LayerTransform{.origin = {40, 0}, .size = {20, 20}}; record(snapshot, id).maskLinked = false; }); QVERIFY(fill()); QCOMPARE(session.activeLayer().value().mask.value().asset.size(), QSize(20, 20)); QCOMPARE(session.activeLayer().value().mask.value().placement, std::optional(LayerTransform{.origin = {40, 0}, .size = {20, 20}})); session.undo(); rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); record(snapshot, id).maskPlacement = std::nullopt; record(snapshot, id).maskLinked = std::nullopt; record(snapshot, id).maskEnabled = false; }); QVERIFY(fill()); QCOMPARE(session.activeLayer().value().mask.value().asset.size(), QSize(35, 20)); QVERIFY(!session.activeLayer().value().mask.value().isEnabled); } void SmartEditTests::growthKeepsTheLayersTurn() { EditorSession session; session.createDocument(60, 40, true); // A red block at pixel (2,2) on a turned layer. const QImage image = picture(20, 20, [](int x, int y) { return x >= 2 && x < 5 && y >= 2 && y < 5 ? red : blue; }); session.insert(ImportedImage(image, image, "Turned"), QPointF(20, 20)); const QUuid id = session.activeLayerID().value(); rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 90; }); session.selectLayer(id); const LayerTransform before = session.activeLayer().value().transform; const QPointF redAt = BrushRaster::pixelToDocument(before, 20, 20).map(QPointF(3.5, 3.5)); session.applySelection(rectPath(QRectF(25, 15, 60, 10)), SelectionMode::replace, "Select"); session.beginFilter(FilterKind::contentAwareFill); const FilterEdit &edit = session.filterEdit().value(); QCOMPARE(edit.grownTransform.value().rotation, 90.0); QVERIFY(committed(session)); const ImageLayer grown = session.activeLayer().value(); QCOMPARE(grown.transform.rotation, 90.0); QVERIFY(grown.transform.size.width() > 20 || grown.transform.size.height() > 20); // The block sits where it sat on the document. const QSize size = grown.asset.value().size(); const QPointF pixel = BrushRaster::pixelToDocument(grown.transform, size.width(), size.height()).inverted().map(redAt); QVERIFY(within(::pixel(grown.asset.value().image(), int(std::floor(pixel.x())), int(std::floor(pixel.y()))), red, 0)); } void SmartEditTests::commitsThatWaitForThePreviewEachResume() { const auto session = fixture(); const int count = session->history.undoCount(); // Two wait; the first commits, the second finds it committing. session->beginFilter(FilterKind::contentAwareFill); bool first = false, second = false; session->commitFilter([&] { first = true; }); session->commitFilter([&] { second = true; }); QTRY_VERIFY(first && second); QCOMPARE(session->history.undoCount(), count + 1); QVERIFY(!session->filterEdit().has_value()); session->undo(); // A waiter whose edit was cancelled returns without applying. session->beginFilter(FilterKind::contentAwareFill); bool third = false; session->commitFilter([&] { third = true; }); session->cancelFilter(); session->beginFilter(FilterKind::contentAwareFill); QTRY_VERIFY(third); QVERIFY(session->filterEdit().has_value() && !session->filterEdit().value().committing); QCOMPARE(session->history.undoCount(), count); QVERIFY(prepared(*session)); QVERIFY(session->filterEdit().has_value()); session->cancelFilter(); // Done but undelivered: it holds the next job, and commits. session->beginFilter(FilterKind::contentAwareFill); QThreadPool::globalInstance()->waitForDone(); QVERIFY(session->filterEdit().value().preparing); session->updateFilter(FilterSettings(), true); QVERIFY(session->filterEdit().value().pending.has_value()); bool fourth = false; session->commitFilter([&] { fourth = true; }); QTRY_VERIFY(fourth); QCOMPARE(session->history.undoCount(), count + 1); QVERIFY(!session->filterEdit().has_value()); session->undo(); // Preview off while the job waits: it still runs. QThreadPool &pool = *QThreadPool::globalInstance(); const int threads = pool.maxThreadCount(); pool.setMaxThreadCount(1); QSemaphore gate; const QFuture held = QtConcurrent::run([&] { gate.acquire(); }); session->beginFilter(FilterKind::contentAwareFill); session->cancelFilter(); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(session->filterEdit().value().pending.has_value()); session->updateFilter(FilterSettings(), false); QVERIFY(session->filterEdit().value().pending.has_value() && session->filterEdit().value().preparing); gate.release(); pool.setMaxThreadCount(threads); QVERIFY(prepared(*session)); QVERIFY(!session->filterEdit().value().previewError.has_value()); QVERIFY(!session->filterEdit().value().previewImage(session->activeLayerID().value()).has_value()); bool fifth = false; session->commitFilter([&] { fifth = true; }); QTRY_VERIFY(fifth); QCOMPARE(session->history.undoCount(), count + 1); } void SmartEditTests::aSoftEdgeBlendsTheFillInByItsCoverage() { const auto session = fixture(); // Half a pixel in: the left column is half filled. session->applySelection(rectPath(QRectF(20.5, 16, 11.5, 10)), SelectionMode::replace, "Select"); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(committed(*session)); const QImage image = active(*session); QVERIFY(within(pixel(image, 25, 20), blue, 1)); QVERIFY(within(pixel(image, 20, 20), {153, 77, 102, 255}, 2)); QVERIFY(within(pixel(image, 19, 20), blue, 1)); } void SmartEditTests::theFillRefusesWhatSwiftRefuses() { const auto session = fixture(); const QImage image = active(*session); QVERIFY_THROWS_EXCEPTION(ContentFillError, ContentFill::run(FilterJob{FilterKind::contentAwareFill, image, FilterSettings(), 1, std::nullopt, QTransform()})); QCOMPARE(QString::fromUtf8(ContentFillError(ContentFillError::Kind::noSource).what()), QString("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image.")); QCOMPARE(rawValue(FilterKind::contentAwareFill), QString("Content-Aware Fill")); QVERIFY(isAutomatic(FilterKind::contentAwareFill)); // A mask target, a hidden layer, a busy project: no. const QUuid id = session->activeLayerID().value(); session->addMask(true); session->applySelection(rectPath(QRectF(20, 16, 12, 10)), SelectionMode::replace, "Select"); session->selectLayerTarget(id, true); QVERIFY(!session->canContentAwareFill()); session->selectLayerTarget(id, false); QVERIFY(session->canContentAwareFill()); // An empty selection has nothing to fill. session->applySelection(rectPath(QRectF(0, 0, 64, 48)), SelectionMode::subtract, "Subtract"); QVERIFY(session->selection().value().isEmpty() && !session->canContentAwareFill()); session->applySelection(rectPath(QRectF(20, 16, 12, 10)), SelectionMode::replace, "Select"); QVERIFY(session->canContentAwareFill()); session->toggleLayerVisibility(id); QVERIFY(!session->canContentAwareFill()); session->toggleLayerVisibility(id); session->setIsProjectBusy(true); QVERIFY(!session->canContentAwareFill()); session->setIsProjectBusy(false); // A blank layer and a folder have no pixels. session->addBlankLayer(); QVERIFY(!session->canContentAwareFill()); session->groupSelectedLayers(); QVERIFY(session->activeLayer().value().isGroup && !session->canContentAwareFill()); session->selectLayer(id); QVERIFY(session->canContentAwareFill()); // Beginning drops an open outline and commits a transform. session->selectTool(NavigationTool::lasso); session->setLassoKind(LassoKind::polygonal); session->beginLasso(QPointF(1, 1), SelectionMode::add); QVERIFY(session->lassoDraft().has_value()); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(session->filterEdit().has_value() && !session->lassoDraft().has_value()); session->cancelFilter(); session->selectTool(NavigationTool::move); session->beginTransform(); session->previewTransform(LayerTransform{.origin = {1, 0}, .size = {64, 48}}); QVERIFY(session->transformEdit().has_value()); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(session->filterEdit().has_value() && !session->transformEdit().has_value()); QCOMPARE(session->activeLayer().value().transform.origin, QPointF(1, 0)); session->cancelFilter(); // Open edit: the layers close, a second begin is refused. session->beginFilter(FilterKind::contentAwareFill); QVERIFY(session->filterEdit().has_value() && !session->canEditLayers() && !session->canContentAwareFill()); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(prepared(*session)); session->cancelFilter(); QVERIFY(session->canEditLayers()); } void SmartEditTests::aLayerChangedMeanwhileTakesNoResult() { const auto session = fixture(); const QUuid id = session->activeLayerID().value(); const ImageIdentity original = session->activeLayer().value().asset.value().identity(); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(prepared(*session)); // Moved meanwhile: the result has no layer to land on. rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.origin = QPointF(1, 1); }); QVERIFY(session->filterEdit().has_value()); QVERIFY(committed(*session)); QVERIFY(!session->filterEdit().has_value() && !session->isProjectBusy()); QCOMPARE(session->history.undoCount(), 0); QCOMPARE(session->activeLayer().value().asset.value().identity(), original); QCOMPARE(session->activeLayer().value().transform.origin, QPointF(1, 1)); // Other pixels meanwhile: the same. session->applySelection(rectPath(QRectF(21, 17, 12, 10)), SelectionMode::replace, "Select"); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(prepared(*session)); const QImage other = picture(64, 48, [](int, int) { return red; }); rewrite(*session, [&](ProjectSnapshot &snapshot) { snapshot.images.insert_or_assign(id, ImportedImage(other, other, "Red")); }); QVERIFY(committed(*session)); QVERIFY(!session->filterEdit().has_value()); QCOMPARE(session->history.undoCount(), 0); QVERIFY(within(pixel(active(*session), 25, 20), red, 0)); } void SmartEditTests::filterCallsAnnounceAndRefuseInSilence() { const auto session = fixture(); QSignalSpy changes(session.get(), &EditorSession::changed); const auto announced = [&](const std::function &change) { const qsizetype before = changes.count(); change(); return changes.count() > before; }; QVERIFY(!announced([&] { session->updateFilter(FilterSettings(), false); })); QVERIFY(!announced([&] { session->cancelFilter(); })); QVERIFY(!announced([&] { session->commitFilter(); })); QVERIFY(announced([&] { session->beginFilter(FilterKind::contentAwareFill); })); QVERIFY(!announced([&] { session->beginFilter(FilterKind::contentAwareFill); })); QVERIFY(announced([&] { session->updateFilter(FilterSettings(), false); })); QVERIFY(announced([&] { session->cancelFilter(); })); QVERIFY(!announced([&] { session->cancelFilter(); })); // The last signal of a change sees what it leaves. QStringList seen; connect(session.get(), &EditorSession::changed, session.get(), [&] { seen = described(*session); }); session->beginFilter(FilterKind::contentAwareFill); QCOMPARE(seen, described(*session)); QVERIFY(prepared(*session)); QCOMPARE(seen, described(*session)); session->updateFilter(FilterSettings(), false); QCOMPARE(seen, described(*session)); QVERIFY(committed(*session)); QCOMPARE(seen, described(*session)); QVERIFY(!session->filterEdit().has_value()); } void SmartEditTests::theCanvasCounterMovesWhereSwiftsDoes() { const auto session = fixture(); const int revision = session->brushRevision(); session->beginFilter(FilterKind::contentAwareFill); QCOMPARE(session->brushRevision(), revision); // Preview off while the fill prepares; then the delivery. session->updateFilter(FilterSettings(), false); QCOMPARE(session->brushRevision(), revision + 1); QVERIFY(prepared(*session)); QCOMPARE(session->brushRevision(), revision + 2); // A made fill shows again at once. session->updateFilter(FilterSettings(), true); QCOMPARE(session->brushRevision(), revision + 3); session->cancelFilter(); QCOMPARE(session->brushRevision(), revision + 4); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(prepared(*session)); QVERIFY(committed(*session)); QCOMPARE(session->brushRevision(), revision + 6); // A refused commit leaves it. session->selectAll(); session->beginFilter(FilterKind::contentAwareFill); QVERIFY(prepared(*session) && session->filterEdit().value().previewError.has_value()); const int failed = session->brushRevision(); QVERIFY(committed(*session)); QCOMPARE(session->brushRevision(), failed); } QTEST_GUILESS_MAIN(SmartEditTests) #include "SmartEditTests.moc"