Blur softens only the pieces the brush reaches; a clone is cut once per tile (13.16, part two)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 18:18:57 -04:00
1 parent cbd8612014
commit de8f23acbe
9 files changed
+197 -37

No files matched your search

+2 -2
View File
@@ -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<QImage> blurSample(const CanvasDocument &document, bool mask = false) const;
// The sharp layer or mask, document-sized; `render` softens parts.
std::optional<BrushStroke::Clone> blurSample(const CanvasDocument &document, bool mask = false) const;
+37 -3
View File
@@ -5,8 +5,42 @@
#include "Rendering/LayerRenderer.h"
#include <cmath>
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<QImage(const QRect &)> 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<QImage> EditorSession::blurSample(const CanvasDocument &document, bool mask) const
std::optional<BrushStroke::Clone> EditorSession::blurSample(const CanvasDocument &document, bool mask) const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!layer)
@@ -34,7 +68,7 @@ std::optional<QImage> 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<QImage> 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;
+31 -10
View File
@@ -239,7 +239,7 @@ void BrushStroke::publish(const std::set<qint64> &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<qint64> &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> 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);
+14 -2
View File
@@ -113,8 +113,12 @@ public:
struct Clone {
QImage image;
QSizeF offset;
// Makes a part of `image`: Blur softens piece by piece.
std::function<QImage(const QRect &)> render;
};
std::optional<Clone> clone;
const std::optional<Clone> &clone() const { return m_clone; }
// Swift's didSet: the pieces cut so far go.
void setClone(std::optional<Clone> 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<BrushSegment> &settled, const std::vector<BrushSegment> &tail);
void publish(const std::set<qint64> &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<void(QPainter &)> &draw);
@@ -198,5 +203,12 @@ private:
std::map<qint64, QImage> m_coverage;
// Selected pixels cut from the source, in layer pixels.
std::optional<Lifted> m_lifted;
std::optional<Clone> m_clone;
// Each tile's part of the clone, cut once, and where.
struct ClonePiece {
QImage image;
QPoint origin;
};
std::map<qint64, ClonePiece> m_clonePieces;
std::set<qint64> m_moveTiles;
};
+5 -6
View File
@@ -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<QImage> 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];
+1 -1
View File
@@ -268,7 +268,7 @@ void EditorSession::finishWarp()
settings.hardness = 1;
settings.opacity = 1;
const std::unique_ptr<BrushStroke> 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.