diff --git a/src/Document/BlurTool+Session.h b/src/Document/BlurTool+Session.h index a9f0ce5..279aee3 100644 --- a/src/Document/BlurTool+Session.h +++ b/src/Document/BlurTool+Session.h @@ -1,4 +1,4 @@ // Swift's BlurTool extension: what a Blur stroke paints. public: - // The active layer, or its mask, softened at document size. - std::optional blurSample(const CanvasDocument &document, bool mask = false) const; + // The sharp layer or mask, document-sized; `render` softens parts. + std::optional blurSample(const CanvasDocument &document, bool mask = false) const; diff --git a/src/Document/BlurTool.cpp b/src/Document/BlurTool.cpp index ef95182..204bf2a 100644 --- a/src/Document/BlurTool.cpp +++ b/src/Document/BlurTool.cpp @@ -5,8 +5,42 @@ #include "Rendering/LayerRenderer.h" #include +namespace { +// `part` padded by `margin`: clear outside, or edges repeated. +QImage padded(const QImage &image, const QRect &part, int margin, bool repeat) +{ + const QRect around = part.adjusted(-margin, -margin, margin, margin); + if (!repeat) { + const QImage copy = image.copy(around); + if (copy.isNull()) + throw ExportError(ExportError::Kind::render); + return copy; + } + QImage copy = BrushRaster::context(around.width(), around.height(), true); + for (int y = 0; y < around.height(); ++y) { + const uchar *row = image.constScanLine(std::clamp(around.top() + y, 0, image.height() - 1)); + uchar *out = copy.scanLine(y); + for (int x = 0; x < around.width(); ++x) + out[x] = row[std::clamp(around.left() + x, 0, image.width() - 1)]; + } + return copy; +} + +// A piece blurred alone equals that part of the whole. +std::function softened(const QImage &sharp, double sigma, bool mask) +{ + return [sharp, sigma, mask](const QRect &part) { + const int margin = int(std::ceil(3 * sigma)); + const QImage piece = PixelAdjust::gaussianBlur(padded(sharp, part, margin, mask), sigma, mask).copy(margin, margin, part.width(), part.height()); + if (piece.isNull()) + throw ExportError(ExportError::Kind::render); + return piece; + }; +} +} + // Swift's BlurTool extension: the layer, or its mask, softened. -std::optional EditorSession::blurSample(const CanvasDocument &document, bool mask) const +std::optional EditorSession::blurSample(const CanvasDocument &document, bool mask) const { const std::optional layer = activeLayer(); if (!layer) @@ -34,7 +68,7 @@ std::optional EditorSession::blurSample(const CanvasDocument &document, painter.restore(); LayerRenderer::drawCoverage(layer->mask->asset.image(), placement, painter); painter.end(); - return PixelAdjust::gaussianBlur(context, sigma, true); + return BrushStroke::Clone{context, QSizeF(0, 0), softened(context, sigma, true)}; } if (!layer->asset) return std::nullopt; @@ -44,7 +78,7 @@ std::optional EditorSession::blurSample(const CanvasDocument &document, const LayerTransform transform = displayedTransform(*layer); LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {}); painter.end(); - return PixelAdjust::gaussianBlur(context, sigma, false); + return BrushStroke::Clone{context, QSizeF(0, 0), softened(context, sigma, false)}; } catch (const ExportError &error) { qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what(); return std::nullopt; diff --git a/src/Document/BrushStroke+Stroke.cpp b/src/Document/BrushStroke+Stroke.cpp index 27adbb4..c9e2f1a 100644 --- a/src/Document/BrushStroke+Stroke.cpp +++ b/src/Document/BrushStroke+Stroke.cpp @@ -239,7 +239,7 @@ void BrushStroke::publish(const std::set &changed) tile.context.detach(); if (tile.context.isNull()) throw ExportError(ExportError::Kind::render); - work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(tile)}); + work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(key, tile)}); const QRectF rect = pixelToDocument.mapRect(tile.rect).intersected(canvas); m_dirtyDocumentRect = m_dirtyDocumentRect ? std::optional(m_dirtyDocumentRect->united(rect)) : std::optional(rect); } @@ -250,9 +250,11 @@ void BrushStroke::publish(const std::set &changed) const double alpha = healing ? 0.45 : settings.opacity; PoolMap::blocking(work, [&](const Fill &item) { Tile &tile = *item.tile; - if (clone && (!isMask || isBlur)) - fillClone(item.sample, *item.coverage, settings.opacity, tile.context, item.clip, replacesWithClone); - else + if (m_clone && (!isMask || isBlur)) { + // Where the sample does not reach, nothing is painted. + if (!item.sample.isNull()) + fillClone(item.sample, *item.coverage, settings.opacity, tile.context, item.clip, replacesWithClone); + } else BrushRaster::fill(colour, *item.coverage, QRectF(QPointF(0, 0), tile.rect.size()), alpha, tile.context, mode, item.clip); tile.image = tile.context; }); @@ -267,17 +269,36 @@ QImage BrushStroke::selectionCoverage(const Tile &tile) const QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument); } -// The clone as this tile sees it, before the fill. -QImage BrushStroke::clonePixels(const Tile &tile) const +void BrushStroke::setClone(std::optional clone) { - if (!clone) + m_clone = std::move(clone); + m_clonePieces.clear(); +} + +// The clone as this tile sees it, before the fill. +QImage BrushStroke::clonePixels(qint64 key, const Tile &tile) +{ + if (!m_clone) return QImage(); const int width = int(tile.rect.width()), height = int(tile.rect.height()); // Tile pixels to clone pixels: grid, document, then offset. - const QTransform toClone = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument - * QTransform::fromTranslate(clone->offset.width(), clone->offset.height()); + const QTransform toWhole = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument + * QTransform::fromTranslate(m_clone->offset.width(), m_clone->offset.height()); + // Swift's clonePiece: the part under the tile, cut once. + if (!m_clonePieces.contains(key)) { + const QRect part = + toWhole.mapRect(QRectF(0, 0, width, height)).adjusted(-2, -2, 2, 2).toAlignedRect().intersected(m_clone->image.rect()); + if (part.isEmpty()) + return QImage(); + const QImage piece = m_clone->render ? m_clone->render(part) : m_clone->image.copy(part); + if (piece.isNull()) + throw ExportError(ExportError::Kind::render); + m_clonePieces.emplace(key, ClonePiece{piece, part.topLeft()}); + } + const ClonePiece &piece = m_clonePieces.at(key); + const QTransform toClone = toWhole * QTransform::fromTranslate(-piece.origin.x(), -piece.origin.y()); // Colour even for gray clones: edges carry their coverage. - const QImage &image = clone->image; + const QImage &image = piece.image; if (toClone.type() > QTransform::TxTranslate) { // Swift's medium quality: bilinear under a scale or turn. QImage sample = BrushRaster::context(width, height, false); diff --git a/src/Document/BrushStroke.h b/src/Document/BrushStroke.h index 93f05c4..269e169 100644 --- a/src/Document/BrushStroke.h +++ b/src/Document/BrushStroke.h @@ -113,8 +113,12 @@ public: struct Clone { QImage image; QSizeF offset; + // Makes a part of `image`: Blur softens piece by piece. + std::function render; }; - std::optional clone; + const std::optional &clone() const { return m_clone; } + // Swift's didSet: the pieces cut so far go. + void setClone(std::optional clone); // Blur: the clone is the layer softened, in place. bool isBlur = false; // The clone replaces what is under the tip, clearing too. @@ -179,7 +183,8 @@ private: void renderContinuous(const std::vector &settled, const std::vector &tail); void publish(const std::set &changed); QImage selectionCoverage(const Tile &tile) const; - QImage clonePixels(const Tile &tile) const; + // Null where the clone does not reach the tile. + QImage clonePixels(qint64 key, const Tile &tile); void allocateTile(qint64 key, qint64 x, qint64 y); // `draw` in document coordinates over every tile the canvas covers. void paintCanvas(bool withinSource, const std::function &draw); @@ -198,5 +203,12 @@ private: std::map m_coverage; // Selected pixels cut from the source, in layer pixels. std::optional m_lifted; + std::optional m_clone; + // Each tile's part of the clone, cut once, and where. + struct ClonePiece { + QImage image; + QPoint origin; + }; + std::map m_clonePieces; std::set m_moveTiles; }; diff --git a/src/Document/EditorSession+Brush.cpp b/src/Document/EditorSession+Brush.cpp index c90295f..0cc1727 100644 --- a/src/Document/EditorSession+Brush.cpp +++ b/src/Document/EditorSession+Brush.cpp @@ -88,14 +88,13 @@ void EditorSession::beginBrush(QPointF point) if (!image) return; m_cloneOffset = offset; - clone = BrushStroke::Clone{*image, *offset}; + clone = BrushStroke::Clone{*image, *offset, {}}; } // Blur paints a softened copy of the layer, in place. if (m_tool == NavigationTool::blur) { - const std::optional image = blurSample(m_document.value(), m_isMaskSelected); - if (!image) + clone = blurSample(m_document.value(), m_isMaskSelected); + if (!clone) return; - clone = BrushStroke::Clone{*image, QSizeF(0, 0)}; } finishOpacityEdit(); try { @@ -109,7 +108,7 @@ void EditorSession::beginBrush(QPointF point) settings.blue = settings.red; } m_brushStroke = makeRasterEdit(layer, settings, m_tool == NavigationTool::brush); - m_brushStroke->clone = clone; + m_brushStroke->setClone(clone); m_brushStroke->isBlur = m_tool == NavigationTool::blur; m_brushStroke->append(point); m_brushAnchor = point; @@ -254,7 +253,7 @@ void EditorSession::commitPaintSnapshot(const BrushStroke &stroke) beginEdit(stroke.editName.value_or(stroke.isMask ? QStringLiteral("Paint Mask") : stroke.settings.erasing ? QStringLiteral("Erase") : stroke.isBlur ? QStringLiteral("Blur") - : stroke.clone ? QStringLiteral("Clone Stamp") + : stroke.clone() ? QStringLiteral("Clone Stamp") : stroke.settings.healing ? QStringLiteral("Spot Healing") : QStringLiteral("Brush Stroke"))); ImageLayer &layer = m_document->layers[index]; diff --git a/src/Document/SmudgeLiquify.cpp b/src/Document/SmudgeLiquify.cpp index 5f6c027..9cfade3 100644 --- a/src/Document/SmudgeLiquify.cpp +++ b/src/Document/SmudgeLiquify.cpp @@ -268,7 +268,7 @@ void EditorSession::finishWarp() settings.hardness = 1; settings.opacity = 1; const std::unique_ptr stroke = makeRasterEdit(current, settings); - stroke->clone = BrushStroke::Clone{warp->image(), QSizeF(0, 0)}; + stroke->setClone(BrushStroke::Clone{warp->image(), QSizeF(0, 0), {}}); stroke->replacesWithClone = true; stroke->editName = rawValue(warp->mode); // A point every twentieth of the tip covers every dab. diff --git a/tests/BlurToolTests.cpp b/tests/BlurToolTests.cpp index 61a4356..8eadd1c 100644 --- a/tests/BlurToolTests.cpp +++ b/tests/BlurToolTests.cpp @@ -139,7 +139,9 @@ void BlurToolTests::theSampleFollowsTheRadius() settings.blurRadius = radius; settings.diameter = 3 * radius; session.setBrushSettings(settings); - QCOMPARE(session.blurSample(document).value(), PixelAdjust::gaussianBlur(sharp, sigma, false)); + const BrushStroke::Clone sample = session.blurSample(document).value(); + QCOMPARE(sample.image, sharp); + QCOMPARE(sample.render(sharp.rect()), PixelAdjust::gaussianBlur(sharp, sigma, false)); } // No layer, nothing to soften. session.selectLayer(std::nullopt); @@ -163,7 +165,8 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement() record(snapshot, id).maskPlacement = LayerTransform{.origin = {20, 10}, .size = {20, 20}}; record(snapshot, id).maskLinked = false; }); - const QImage sample = session.blurSample(session.document().value(), true).value(); + const BrushStroke::Clone clone = session.blurSample(session.document().value(), true).value(); + const QImage sample = clone.render(clone.image.rect()); QCOMPARE(sample.format(), QImage::Format_Grayscale8); QImage expected(60, 40, QImage::Format_Grayscale8); // Past its pixels the mask keeps its edge's tone: white. @@ -187,7 +190,8 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement() record(snapshot, wideID).maskLinked = false; }); turned.setBrushSettings(BrushSettings{.diameter = 5, .blurRadius = 1.5}); - const QImage soft = turned.blurSample(turned.document().value(), true).value(); + const BrushStroke::Clone sharp = turned.blurSample(turned.document().value(), true).value(); + const QImage soft = sharp.render(sharp.image.rect()); // Past the true box, inside one turned the other way. QVERIFY2(soft.constScanLine(36)[73] > 240, qPrintable(QString::number(soft.constScanLine(36)[73]))); QVERIFY2(soft.constScanLine(24)[27] > 240, qPrintable(QString::number(soft.constScanLine(24)[27]))); @@ -261,7 +265,7 @@ void BlurToolTests::aMaskBetweenPixelsTakesTheFraction() for (int y = 0; y < 20; ++y) for (int x = 0; x < 80; ++x) steps.scanLine(y)[x] = x < 21 ? 0 : 200; - paint.clone = BrushStroke::Clone{steps, QSizeF(0, 0)}; + paint.setClone(BrushStroke::Clone{steps, QSizeF(0, 0), {}}); paint.isBlur = true; paint.append(QPointF(21, 10)); const QImage tile = paint.patches().at(0).image; @@ -281,7 +285,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone() white.fill(255); layer.mask = LayerMask(LayerMask::assetFrom(white)); BrushStroke paint(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20)); - paint.clone = BrushStroke::Clone{white, QSizeF(0, 0)}; + paint.setClone(BrushStroke::Clone{white, QSizeF(0, 0), {}}); paint.isBlur = true; paint.append(QPointF(0, 10)); const QImage tile = paint.patches().at(0).image; @@ -292,7 +296,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone() black.fill(0); layer.mask = LayerMask(LayerMask::assetFrom(black)); BrushStroke over(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20)); - over.clone = BrushStroke::Clone{white, QSizeF(0, 0)}; + over.setClone(BrushStroke::Clone{white, QSizeF(0, 0), {}}); over.isBlur = true; over.append(QPointF(0, 10)); QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[0]), 128); diff --git a/tests/CloneStampTests.cpp b/tests/CloneStampTests.cpp index 1f2f074..b38556c 100644 --- a/tests/CloneStampTests.cpp +++ b/tests/CloneStampTests.cpp @@ -249,7 +249,7 @@ void CloneStampTests::aScaledLayerSamplesThroughItsGrid() ImageLayer layer(ImportedImage(clear, clear, QStringLiteral("Scaled")), QPointF(0, 0)); layer.transform.size = QSizeF(40, 20); BrushStroke paint(layer, false, brush(30, 1, 0, 0, 0), QSizeF(80, 20)); - paint.clone = BrushStroke::Clone{edged(80, 20, 31), QSizeF(10, 0)}; + paint.setClone(BrushStroke::Clone{edged(80, 20, 31), QSizeF(10, 0), {}}); paint.append(QPointF(21, 10)); const QImage tile = paint.patches().at(0).image; // Grid pixel 10 sits at document 21, sampling 31. @@ -264,7 +264,7 @@ void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction() ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20)); layer.transform.origin = QPointF(0.5, 0); BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 20)); - paint.clone = BrushStroke::Clone{edged(80, 20, 31), QSizeF(3, 0)}; + paint.setClone(BrushStroke::Clone{edged(80, 20, 31), QSizeF(3, 0), {}}); paint.append(QPointF(28, 10)); const QPointF centre = paint.pixelToDocument.map(QPointF(28.5, 10.5)); QCOMPARE(centre, QPointF(28, 10.5)); @@ -279,7 +279,7 @@ void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction() QImage rows = BrushRaster::context(40, 40, false); rows.fill(QColor(255, 0, 0)); QPainter(&rows).fillRect(QRect(0, 12, 40, 28), QColor(0, 0, 255)); - down.clone = BrushStroke::Clone{rows, QSizeF(0, 0)}; + down.setClone(BrushStroke::Clone{rows, QSizeF(0, 0), {}}); down.append(QPointF(20, 12)); const QImage lowTile = down.patches().at(0).image; const int row = int(std::floor(down.pixelToDocument.inverted().map(QPointF(20, 12)).y())); @@ -297,7 +297,7 @@ void CloneStampTests::aQuarterPixelWeighsItsNeighbours() return std::abs(pixel[0] - red) <= 2 && pixel[1] == 0 && std::abs(pixel[2] - blue) <= 2 && pixel[3] == 255; }; BrushStroke across(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40)); - across.clone = BrushStroke::Clone{edged(80, 40, 31), QSizeF(0, 0)}; + across.setClone(BrushStroke::Clone{edged(80, 40, 31), QSizeF(0, 0), {}}); across.append(QPointF(31, 12)); QCOMPARE(across.pixelToDocument.map(QPointF(31.5, 12.5)), QPointF(30.75, 12.25)); const std::vector mixed = pixel(across.patches().at(0).image, 31, 12); @@ -307,7 +307,7 @@ void CloneStampTests::aQuarterPixelWeighsItsNeighbours() rows.fill(QColor(255, 0, 0)); QPainter(&rows).fillRect(QRect(0, 12, 80, 28), QColor(0, 0, 255)); BrushStroke down(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40)); - down.clone = BrushStroke::Clone{rows, QSizeF(0, 0)}; + down.setClone(BrushStroke::Clone{rows, QSizeF(0, 0), {}}); down.append(QPointF(20, 12)); const std::vector stacked = pixel(down.patches().at(0).image, 20, 12); QVERIFY2(near(stacked, 64, 191), qPrintable(QString("%1 %2").arg(stacked[0]).arg(stacked[2]))); @@ -321,7 +321,7 @@ void CloneStampTests::opacityAndTheSelectionLimitTheClone() over.fill(QColor(255, 0, 0)); BrushStroke half(ImageLayer(ImportedImage(under, under, QStringLiteral("Blue")), QPointF(0, 0)), false, brush(10, 1, 0, 0, 0, 0.5), QSizeF(40, 20)); - half.clone = BrushStroke::Clone{over, QSizeF(0, 0)}; + half.setClone(BrushStroke::Clone{over, QSizeF(0, 0), {}}); half.append(QPointF(10, 10)); // Each byte is rounded once: 127.5 and 127.5 make 128. QCOMPARE(pixel(half.patches().at(0).image, 10, 10), (std::vector{128, 0, 128, 255})); @@ -337,7 +337,7 @@ void CloneStampTests::opacityAndTheSelectionLimitTheClone() painter.setCompositionMode(QPainter::CompositionMode_Clear); painter.fillRect(QRect(0, 0, 40, 5), Qt::transparent); } - bounded.clone = BrushStroke::Clone{sample, QSizeF(0, 0)}; + bounded.setClone(BrushStroke::Clone{sample, QSizeF(0, 0), {}}); bounded.append(QPointF(10, 8)); const QImage tile = bounded.patches().at(0).image; QCOMPARE(pixel(tile, 8, 8), redPixel); diff --git a/tests/LargeCanvasBrushTests.cpp b/tests/LargeCanvasBrushTests.cpp index 14520bd..ddb3b98 100644 --- a/tests/LargeCanvasBrushTests.cpp +++ b/tests/LargeCanvasBrushTests.cpp @@ -40,6 +40,9 @@ private slots: void largestBrushCommits_data(); void largestBrushCommits(); void aThinnedCommitLandsAllTheWarpMoved(); + void blurPiecesMatchTheWhole_data(); + void blurPiecesMatchTheWhole(); + void piecesAreCutOnceWhereTheBrushReaches(); }; void LargeCanvasBrushTests::largestBrushCommits_data() @@ -47,6 +50,7 @@ void LargeCanvasBrushTests::largestBrushCommits_data() QTest::addColumn("mode"); QTest::newRow("liquify") << BlurToolMode::liquify; QTest::newRow("smudge") << BlurToolMode::smudge; + QTest::newRow("blur") << BlurToolMode::blur; } // The commit's tip runs past Size's 2000, once refused. @@ -56,6 +60,9 @@ void LargeCanvasBrushTests::largestBrushCommits() EditorSession session; bands(session, 5000); session.setBlurMode(mode); + BrushSettings settings = session.brushSettings(); + settings.blurRadius = 20; + session.setBrushSettings(settings); const ImageIdentity before = session.activeLayer().value().asset.value().identity(); stroke(session, 5000); QCOMPARE(session.brushError(), std::optional()); @@ -97,5 +104,88 @@ void LargeCanvasBrushTests::aThinnedCommitLandsAllTheWarpMoved() QCOMPARE(apart, 0); } +void LargeCanvasBrushTests::blurPiecesMatchTheWhole_data() +{ + QTest::addColumn("mask"); + QTest::newRow("pixels") << false; + QTest::newRow("mask") << true; +} + +// Swift's test: each piece is its part of the whole. +void LargeCanvasBrushTests::blurPiecesMatchTheWhole() +{ + QFETCH(bool, mask); + EditorSession session; + bands(session, 600); + const QUuid id = session.activeLayerID().value(); + QImage stripes(600, 600, QImage::Format_Grayscale8); + for (int y = 0; y < 600; ++y) + for (int x = 0; x < 600; ++x) + stripes.scanLine(y)[x] = (x / 37 + y / 23) % 2 ? 255 : 0; + rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(stripes)); }); + BrushSettings settings = session.brushSettings(); + settings.blurRadius = 12; + session.setBrushSettings(settings); + const BrushStroke::Clone blur = session.blurSample(session.document().value(), mask).value(); + const QImage whole = blur.render(blur.image.rect()); + QCOMPARE(whole.size(), QSize(600, 600)); + for (const QRect part : {QRect(0, 0, 256, 256), QRect(250, 300, 260, 180), QRect(500, 530, 100, 70)}) { + const QImage piece = blur.render(part); + QCOMPARE(piece.size(), part.size()); + QCOMPARE(piece, whole.copy(part)); + } +} + +// Only tiles the brush reaches are blurred, each once. +void LargeCanvasBrushTests::piecesAreCutOnceWhereTheBrushReaches() +{ + QImage image = BrushRaster::context(1000, 1000, false); + image.fill(Qt::red); + const ImageLayer layer(ImportedImage(image, image, QStringLiteral("Red")), QPointF(0, 0)); + BrushStroke stroke(layer, false, brush(20, 1, 0, 0, 0), QSizeF(1000, 1000)); + std::vector parts; + stroke.setClone(BrushStroke::Clone{image, QSizeF(0, 0), [&](const QRect &part) { + parts.push_back(part); + return image.copy(part); + }}); + stroke.isBlur = true; + stroke.append(QPointF(100, 100)); + stroke.append(QPointF(120, 100)); + stroke.flush(); + QCOMPARE(int(parts.size()), 1); + QCOMPARE(parts[0], QRect(0, 0, 258, 258)); + // Over the same tile again: nothing new is cut. + stroke.append(QPointF(100, 110)); + stroke.flush(); + QCOMPARE(int(parts.size()), 1); + // Into the next tile: its piece, two pixels round. + stroke.append(QPointF(300, 110)); + stroke.flush(); + QCOMPARE(int(parts.size()), 2); + QCOMPARE(parts[1], QRect(254, 0, 260, 258)); + // A new clone drops what was cut. + stroke.setClone(stroke.clone()); + stroke.append(QPointF(310, 110)); + stroke.flush(); + QCOMPARE(int(parts.size()), 3); + QCOMPARE(parts[2], parts[1]); + // Past the clone, a tile is neither cut nor painted. + BrushStroke past(layer, false, brush(20, 1, 0, 0, 0), QSizeF(1000, 1000)); + QImage blue = BrushRaster::context(200, 200, false); + blue.fill(Qt::blue); + parts.clear(); + past.setClone(BrushStroke::Clone{blue, QSizeF(0, 0), [&](const QRect &part) { + parts.push_back(part); + return blue.copy(part); + }}); + past.isBlur = true; + past.append(QPointF(400, 400)); + past.append(QPointF(420, 400)); + past.flush(); + QVERIFY(parts.empty()); + QVERIFY(!past.patches().empty()); + QCOMPARE(pixel(preview(past, QSizeF(1000, 1000)), 410, 400), (std::vector{255, 0, 0, 255})); +} + QTEST_GUILESS_MAIN(LargeCanvasBrushTests) #include "LargeCanvasBrushTests.moc"