From cbd8612014568f633536321071f6856d675c80c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:48:08 -0400 Subject: [PATCH] Liquify keeps pixels sharp, Smudge without ghost copies, Blur's own Radius, the largest Smear commits (13.16, part one) --- src/Document/BlurTool.cpp | 4 +- src/Document/BrushStroke+Stroke.cpp | 2 +- src/Document/BrushStroke.h | 4 ++ src/Document/SmudgeLiquify.cpp | 74 +++++++++++++++----- src/Document/SmudgeLiquify.h | 6 +- src/UI/BrushControls.cpp | 58 +++++++++++++++- src/UI/BrushControls.h | 6 ++ tests/BlurBrushTests.cpp | 55 +++++++++++++++ tests/BlurToolTests.cpp | 19 +++--- tests/BrushControlsTests.cpp | 82 ++++++++++++++++++++++ tests/BrushTests.cpp | 4 +- tests/LargeCanvasBrushTests.cpp | 101 ++++++++++++++++++++++++++++ tests/SmudgeLiquifyTests.cpp | 78 +++++++++++++++------ tests/Tests.cmake | 3 + tests/WarpStrokeTests.cpp | 98 +++++++++++++++++++++++++++ 15 files changed, 543 insertions(+), 51 deletions(-) create mode 100644 tests/BlurBrushTests.cpp create mode 100644 tests/LargeCanvasBrushTests.cpp create mode 100644 tests/WarpStrokeTests.cpp diff --git a/src/Document/BlurTool.cpp b/src/Document/BlurTool.cpp index ea62db3..ef95182 100644 --- a/src/Document/BlurTool.cpp +++ b/src/Document/BlurTool.cpp @@ -11,8 +11,8 @@ std::optional EditorSession::blurSample(const CanvasDocument &document, const std::optional layer = activeLayer(); if (!layer) return std::nullopt; - // The softening follows the brush's size. - const double sigma = std::min(30.0, std::max(1.5, m_brushSettings.diameter / 10)); + // The bar's Radius, whatever the brush's size. + const double sigma = std::min(50.0, std::max(0.5, m_brushSettings.blurRadius)); try { if (mask) { if (!layer->mask) diff --git a/src/Document/BrushStroke+Stroke.cpp b/src/Document/BrushStroke+Stroke.cpp index 5a2f4a6..27adbb4 100644 --- a/src/Document/BrushStroke+Stroke.cpp +++ b/src/Document/BrushStroke+Stroke.cpp @@ -92,7 +92,7 @@ BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const Br grid.paintTransform.size = {grid.width * base.size.width() / originalWidth, grid.height * base.size.height() / originalHeight}; const QPointF center = originalMapping.map(extent.center()); grid.paintTransform.origin = {center.x() - grid.paintTransform.size.width() / 2, center.y() - grid.paintTransform.size.height() / 2}; - if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > 2000 + if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > maxDiameter || !std::isfinite(settings.hardness) || settings.hardness < 0 || settings.hardness > 1 || !std::isfinite(settings.opacity) || settings.opacity < 0.01 || settings.opacity > 1) throw ProjectError(ProjectError::Kind::tooLarge); diff --git a/src/Document/BrushStroke.h b/src/Document/BrushStroke.h index 8c82c01..93f05c4 100644 --- a/src/Document/BrushStroke.h +++ b/src/Document/BrushStroke.h @@ -34,6 +34,8 @@ struct BrushSettings { double opacity = 1; // 0–100: the brush trails the pointer on this string. double smoothing = 0; + // Blur: how far it softens, in canvas pixels. + double blurRadius = 5; // Erase clears the layer's pixels instead of painting on them. bool erasing = false; // Spot healing takes nearby pixels instead of the colour. @@ -120,6 +122,8 @@ public: // The undo name when the stroke's kind says none. std::optional editName; static constexpr int tileSize = 256; + // Size stops at 2000; the app's own commits run wider. + static constexpr double maxDiameter = 2100; // Soft-tip deposition rate, shared with the continuous integral. static double spacingFraction(double hardness); diff --git a/src/Document/SmudgeLiquify.cpp b/src/Document/SmudgeLiquify.cpp index 73f534e..5f6c027 100644 --- a/src/Document/SmudgeLiquify.cpp +++ b/src/Document/SmudgeLiquify.cpp @@ -39,6 +39,18 @@ WarpStroke::WarpStroke(const ImageLayer &layer, const QImage &image, const Layer { QPainter painter(&m_context); LayerRenderer::draw(image, transform, transform.center(), painter, {}); + painter.end(); + if (mode != BlurToolMode::liquify) + return; + // Resampled at every dab, pixels soften; offsets keep them sharp. + m_original = m_context.copy(); + if (m_original.isNull()) + throw ExportError(ExportError::Kind::render); + try { + m_offsets.assign(size_t(width) * size_t(height) * 2, 0); + } catch (const std::bad_alloc &) { + throw ExportError(ExportError::Kind::render); + } } int WarpStroke::radius() const @@ -68,7 +80,8 @@ void WarpStroke::append(QPointF point) } const QPointF from = *m_last; const double distance = std::hypot(point.x() - from.x(), point.y() - from.y()); - const double spacing = std::max(1.0, diameter * (mode == BlurToolMode::smudge ? 0.08 : 0.025)); + // Smudge dabs a pixel apart smear without echoes. + const double spacing = std::max(1.0, diameter * (mode == BlurToolMode::smudge ? 0.005 : 0.025)); if (distance < spacing) return; const int steps = int(std::ceil(distance / spacing)); @@ -131,10 +144,11 @@ void WarpStroke::smudge(QPointF center) float *c = m_carried.data() + (size_t(dy + r) * size_t(side) + size_t(dx + r)) * 4; for (int k = 0; k < 4; ++k) { const float under = float(p[k]); - const float painted = under + (c[k] - under) * w; + // What was under the brush last dab, at the strength. + const float painted = under + (c[k] - under) * w * keep; p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round(painted)))); - // The brush picks up what it left; weaker, more so. - c[k] = painted + (c[k] - painted) * keep; + // It carries what it just left, nothing older: no ghosts. + c[k] = painted; } } } @@ -152,14 +166,16 @@ void WarpStroke::push(QPointF a, QPointF b) if (x0 > x1 || y0 > y1) return; const int cw = x1 - x0 + 1, ch = y1 - y0 + 1; - if (m_scratch.size() < size_t(cw) * size_t(ch) * 4) - m_scratch.assign(size_t(cw) * size_t(ch) * 4, 0); + if (m_scratch.size() < size_t(cw) * size_t(ch) * 2) + m_scratch.assign(size_t(cw) * size_t(ch) * 2, 0); + for (int y = 0; y < ch; ++y) { + const auto row = m_offsets.begin() + std::ptrdiff_t((size_t(y + y0) * size_t(width) + size_t(x0)) * 2); + std::copy(row, row + std::ptrdiff_t(size_t(cw) * 2), m_scratch.begin() + std::ptrdiff_t(size_t(y) * size_t(cw) * 2)); + } uchar *pixels = m_context.bits(); const qsizetype stride = m_context.bytesPerLine(); - for (int y = 0; y < ch; ++y) { - const uchar *row = pixels + (y + y0) * stride + x0 * 4; - std::copy(row, row + size_t(cw) * 4, m_scratch.begin() + std::ptrdiff_t(size_t(y) * size_t(cw) * 4)); - } + const uchar *original = m_original.constBits(); + const qsizetype originalStride = m_original.bytesPerLine(); const float invR = 1 / float(diameter / 2); const float *s = m_scratch.data(); for (int dy = -r; dy <= r; ++dy) { @@ -173,19 +189,36 @@ void WarpStroke::push(QPointF a, QPointF b) const float w = weight(std::sqrt(float(dx * dx + dy * dy)) * invR); if (!(w > 0)) continue; - // Bilinear from the old pixels, behind the brush's travel. + // Bilinear from the old offsets, behind the brush's travel. const float sx = std::min(float(cw - 1), std::max(0.0f, float(x - x0) - moveX * w)); const float sy = std::min(float(ch - 1), std::max(0.0f, float(y - y0) - moveY * w)); const int ix = std::min(cw - 2, int(sx)), iy = std::min(ch - 2, int(sy)); if (ix < 0 || iy < 0) continue; const float fx = sx - float(ix), fy = sy - float(iy); - uchar *p = pixels + y * stride + x * 4; - const size_t s00 = (size_t(iy) * size_t(cw) + size_t(ix)) * 4, s10 = s00 + 4, s01 = s00 + size_t(cw) * 4, s11 = s01 + 4; - for (int k = 0; k < 4; ++k) { + const size_t s00 = (size_t(iy) * size_t(cw) + size_t(ix)) * 2, s10 = s00 + 2, s01 = s00 + size_t(cw) * 2, s11 = s01 + 2; + float moved[2]; + for (int k = 0; k < 2; ++k) { const float top = s[s00 + k] + (s[s10 + k] - s[s00 + k]) * fx; const float bottom = s[s01 + k] + (s[s11 + k] - s[s01 + k]) * fx; - p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round(top + (bottom - top) * fy)))); + moved[k] = top + (bottom - top) * fy - (k == 0 ? moveX : moveY) * w; + } + float *offset = m_offsets.data() + (size_t(y) * size_t(width) + size_t(x)) * 2; + offset[0] = moved[0]; + offset[1] = moved[1]; + // The untouched layer there, held to its edges, sampled once. + const float sourceX = std::clamp(float(x) + moved[0], 0.0f, float(width - 1)); + const float sourceY = std::clamp(float(y) + moved[1], 0.0f, float(height - 1)); + const int jx = std::max(0, std::min(int(sourceX), width - 2)), jy = std::max(0, std::min(int(sourceY), height - 2)); + const float gx = sourceX - float(jx), gy = sourceY - float(jy); + const int kx = std::min(jx + 1, width - 1), ky = std::min(jy + 1, height - 1); + const uchar *top0 = original + jy * originalStride, *bottom0 = original + ky * originalStride; + const uchar *c00 = top0 + jx * 4, *c10 = top0 + kx * 4, *c01 = bottom0 + jx * 4, *c11 = bottom0 + kx * 4; + uchar *p = pixels + y * stride + x * 4; + for (int k = 0; k < 4; ++k) { + const float a = float(c00[k]) / 255, b = float(c10[k]) / 255, c = float(c01[k]) / 255, d = float(c11[k]) / 255; + const float top = a + (b - a) * gx, bottom = c + (d - c) * gx; + p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round((top + (bottom - top) * gy) * 255)))); } } } @@ -238,8 +271,17 @@ void EditorSession::finishWarp() stroke->clone = BrushStroke::Clone{warp->image(), QSizeF(0, 0)}; stroke->replacesWithClone = true; stroke->editName = rawValue(warp->mode); - for (const QPointF step : warp->points()) + // A point every twentieth of the tip covers every dab. + const double spacing = std::max(1.0, warp->diameter * 0.05); + const std::vector &points = warp->points(); + std::optional kept; + for (size_t index = 0; index < points.size(); ++index) { + const QPointF step = points[index]; + if (kept && index + 1 < points.size() && std::hypot(step.x() - kept->x(), step.y() - kept->y()) < spacing) + continue; stroke->append(step); + kept = step; + } stroke->flush(); if (!stroke->patches().empty()) commitPaintSnapshot(*stroke); diff --git a/src/Document/SmudgeLiquify.h b/src/Document/SmudgeLiquify.h index b1b6b92..fe9c162 100644 --- a/src/Document/SmudgeLiquify.h +++ b/src/Document/SmudgeLiquify.h @@ -41,7 +41,7 @@ private: float weight(float u) const; void pickUp(QPointF center); void smudge(QPointF center); - // Forward warp: pixels follow the brush, fading to the rim. + // Forward warp on offsets; pixels drawn afresh from the original. void push(QPointF from, QPointF to); QImage m_context; @@ -49,5 +49,9 @@ private: std::optional m_last; // Smudge: the colour the brush carries, a (2r+1)² RGBA square. std::vector m_carried; + // Liquify: the layer as found, and each pixel's source offset. + QImage m_original; + std::vector m_offsets; + // Liquify: the dab's offsets as they were before it. std::vector m_scratch; }; diff --git a/src/UI/BrushControls.cpp b/src/UI/BrushControls.cpp index d9f5abb..8c96ee9 100644 --- a/src/UI/BrushControls.cpp +++ b/src/UI/BrushControls.cpp @@ -1,5 +1,6 @@ #include "UI/BrushControls.h" #include "Document/EditorSession.h" +#include "UI/ColorPickerSheet.h" #include "UI/ColorPaletteControls.h" #include "UI/LassoControls.h" #include "UI/NumericScrub.h" @@ -25,6 +26,22 @@ const QString modeTip = QStringLiteral("Paint with the foreground color (B), or const QString blurTip = QStringLiteral("Liquify pushes pixels · Blur softens · Smudge drags color along"); const QString sampleTip = QStringLiteral("Copy from the active layer only, or from every visible layer as shown"); +// Swift's fractionLength(0...1): one place at most, none trailing. +QString oneDecimal(double value, QLocale locale) +{ + locale.setNumberOptions(QLocale::OmitGroupSeparator); + QString text = locale.toString(value, 'f', 1); + const QString zero = locale.decimalPoint() + locale.zeroDigit(); + if (text.endsWith(zero)) + text.chop(zero.size()); + return text; +} + +double radius(double value) +{ + return std::min(50.0, std::max(0.5, value)); +} + QLabel *label(const QString &text, QWidget *parent) { auto *made = new QLabel(text, parent); @@ -53,6 +70,22 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent) [this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.opacity = value / 1000.0; }); })), m_opacity(new SelectionAmountField(session, 1, 100, [this] { return m_session.brushSettings().opacity * 100; }, [this](double percent) { changeBrush(m_session, [percent](BrushSettings &brush) { brush.opacity = percent / 100; }); }, this)), + m_radiusLabel(label(QStringLiteral("Radius"), this)), + m_radiusSlider(slider(QStringLiteral("blurRadiusSlider"), 50, 2000, + [this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.blurRadius = value / 100.0; }); })), + m_radius(new PickerField([this] { + bool number = false; + const double typed = m_radius->locale().toDouble(m_radius->text(), &number); + if (m_radius->isModified() && number) + changeBrush(m_session, [typed](BrushSettings &brush) { brush.blurRadius = std::isfinite(typed) ? radius(typed) : 5; }); + m_radius->setModified(false); + m_radius->setText(oneDecimal(m_session.brushSettings().blurRadius, m_radius->locale())); + }, [this](int step) { + changeBrush(m_session, [step](BrushSettings &brush) { brush.blurRadius = radius(brush.blurRadius + step); }); + m_radius->setModified(false); + m_radius->setText(oneDecimal(m_session.brushSettings().blurRadius, m_radius->locale())); + }, this)), + m_radiusUnit(label(QStringLiteral("px"), this)), m_smoothingLabel(label(QStringLiteral("Smoothing"), this)), m_smoothingSlider(slider(QStringLiteral("brushSmoothingSlider"), 0, 1000, [this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.smoothing = value / 10.0; }); })), @@ -79,6 +112,19 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent) m_opacity->setObjectName(QStringLiteral("brushOpacity")); m_opacity->setFixedWidth(42); m_opacity->setToolTip(QStringLiteral("Press 1–9 for 10–90%, 0 for 100%")); + m_radius->setObjectName(QStringLiteral("blurRadius")); + m_radius->setFixedWidth(42); + m_radius->setFont(ToolHeaderStyle::controlFont()); + m_radius->setAccessibleName(QStringLiteral("Radius")); + m_radius->setPlaceholderText(QStringLiteral("Radius")); + m_radius->setToolTip(QStringLiteral("How far the blur softens, in pixels")); + // The slider covers everyday radii; typing or scrubbing reaches 50. + new NumericScrub(m_radiusLabel, {.sensitivity = 0.1, .low = 0.5, .high = 50, .step = std::nullopt, + .value = [this] { return m_session.brushSettings().blurRadius; }, .set = [this](double value) { + changeBrush(m_session, [value](BrushSettings &brush) { brush.blurRadius = value; }); + m_radius->setModified(false); + m_radius->setText(oneDecimal(value, m_radius->locale())); + }}); m_smoothing->setObjectName(QStringLiteral("brushSmoothing")); m_smoothing->setFixedWidth(42); m_smoothing->setToolTip(QStringLiteral("The brush trails the pointer on a string this long, so a shaky hand still draws a smooth line")); @@ -118,7 +164,7 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent) widgets.insert(widgets.end(), {m_aligned, m_thisLayer, m_allLayers}); widgets.insert(widgets.end(), {size, m_size, label(QStringLiteral("px"), this), hardness, m_hardnessSlider, m_hardness, label(QStringLiteral("%"), this), m_opacityLabel, m_opacitySlider, m_opacity, - label(QStringLiteral("%"), this), m_smoothingLabel, m_smoothingSlider, m_smoothing, m_paintLabel, m_maskPaint, m_colourLabel, m_colour}); + label(QStringLiteral("%"), this), m_radiusLabel, m_radiusSlider, m_radius, m_radiusUnit, m_smoothingLabel, m_smoothingSlider, m_smoothing, m_paintLabel, m_maskPaint, m_colourLabel, m_colour}); for (QWidget *widget : widgets) row->insertWidget(row->count() - 1, widget); row->addWidget(m_cloneNote); @@ -179,11 +225,19 @@ void BrushControls::synchronize() m_size->sync(int(std::lround(brush.diameter))); m_hardness->sync(int(std::lround(brush.hardness * 100))); m_opacity->sync(int(std::lround(brush.opacity * 100))); + const bool blurring = tool == NavigationTool::blur && m_session.blurMode() == BlurToolMode::blur; + for (QWidget *widget : std::initializer_list{m_radiusLabel, m_radiusSlider, m_radius, m_radiusUnit}) + widget->setVisible(blurring); + // A field being typed in keeps its typing. + if (!m_radius->isModified()) + m_radius->setText(oneDecimal(brush.blurRadius, m_radius->locale())); for (QWidget *widget : std::initializer_list{m_smoothingLabel, m_smoothingSlider, m_smoothing}) widget->setVisible(tool == NavigationTool::brush); m_smoothing->sync(int(std::lround(brush.smoothing))); // The sliders show the session's numbers without writing back. - const QSignalBlocker hardness(m_hardnessSlider), opacity(m_opacitySlider), smoothing(m_smoothingSlider); + const QSignalBlocker hardness(m_hardnessSlider), opacity(m_opacitySlider), smoothing(m_smoothingSlider), reach(m_radiusSlider); + // Past 20 the slider rests at its end. + m_radiusSlider->setValue(int(std::lround(brush.blurRadius * 100))); m_hardnessSlider->setValue(int(std::lround(brush.hardness * 1000))); m_opacitySlider->setValue(int(std::lround(brush.opacity * 1000))); m_smoothingSlider->setValue(int(std::lround(brush.smoothing * 10))); diff --git a/src/UI/BrushControls.h b/src/UI/BrushControls.h index 33295e1..6d5b071 100644 --- a/src/UI/BrushControls.h +++ b/src/UI/BrushControls.h @@ -9,6 +9,7 @@ class QComboBox; class QLabel; class QSlider; class QToolButton; +class PickerField; class SelectionAmountField; class SwatchButton; @@ -44,6 +45,11 @@ private: QLabel *const m_opacityLabel; QSlider *const m_opacitySlider; SelectionAmountField *const m_opacity; + // Blur's own Radius, apart from Strength. + QLabel *const m_radiusLabel; + QSlider *const m_radiusSlider; + PickerField *const m_radius; + QLabel *const m_radiusUnit; // Paint and Erase only; other tools have their own feel. QLabel *const m_smoothingLabel; QSlider *const m_smoothingSlider; diff --git a/tests/BlurBrushTests.cpp b/tests/BlurBrushTests.cpp new file mode 100644 index 0000000..ca40e31 --- /dev/null +++ b/tests/BlurBrushTests.cpp @@ -0,0 +1,55 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" + +// Swift's BlurBrushTests: Radius sets the reach, whatever the size. +namespace { +// A hard edge blurred along: row 50's gray columns. +int spread(double radius, double diameter = 60) +{ + EditorSession session; + session.createDocument(200, 100); + QImage image = BrushRaster::context(200, 100, false); + image.fill(Qt::black); + QPainter(&image).fillRect(QRect(100, 0, 100, 100), Qt::white); + session.insert(ImportedImage(image, image, QStringLiteral("Edge"))); + session.selectTool(NavigationTool::blur); + session.setBlurMode(BlurToolMode::blur); + BrushSettings settings = session.brushSettings(); + settings.diameter = diameter; + settings.hardness = 1; + settings.blurRadius = radius; + session.setBrushSettings(settings); + session.beginBrush(QPointF(100, 20)); + session.continueBrush(QPointF(100, 80)); + session.finishBrushImmediately(); + const QImage result = session.activeLayer().value().asset.value().image().convertToFormat(QImage::Format_RGBA8888_Premultiplied); + int gray = 0; + for (int x = 0; x < 200; ++x) { + const int value = result.constScanLine(50)[x * 4]; + gray += value > 10 && value < 245; + } + return gray; +} +} + +class BlurBrushTests : public QObject { + Q_OBJECT +private slots: + void radiusSetsHowFarItSoftens(); +}; + +void BlurBrushTests::radiusSetsHowFarItSoftens() +{ + const int narrow = spread(2), wide = spread(12); + QVERIFY2(wide > narrow * 2, qPrintable(QString("%1 against %2").arg(narrow).arg(wide))); + QCOMPARE(narrow, 6); + QCOMPARE(wide, 42); + // The brush size leaves the reach alone. + QCOMPARE(spread(12, 80), wide); + // Past its ends, Radius holds to 0.5 and 50. + QCOMPARE(spread(0.1), spread(0.5)); + QCOMPARE(spread(80), spread(50)); +} + +QTEST_GUILESS_MAIN(BlurBrushTests) +#include "BlurBrushTests.moc" diff --git a/tests/BlurToolTests.cpp b/tests/BlurToolTests.cpp index 7119ad0..61a4356 100644 --- a/tests/BlurToolTests.cpp +++ b/tests/BlurToolTests.cpp @@ -54,7 +54,7 @@ private slots: void aPointSpreadsAlongTheBellCurve(); void clearEdgesFadeAndClampedEdgesHold(); void grayBlursAndBadInputIsRefused(); - void theSampleFollowsTheBrushSize(); + void theSampleFollowsTheRadius(); void aMaskSampleKeepsItsEdgeToneAndPlacement(); void aBlurStrokeSoftensUnderTheBrushAsOneStep(); void aBlurStrokeSoftensAMask(); @@ -127,16 +127,17 @@ void BlurToolTests::grayBlursAndBadInputIsRefused() QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step.convertToFormat(QImage::Format_RGB32), 2, true)); } -void BlurToolTests::theSampleFollowsTheBrushSize() +void BlurToolTests::theSampleFollowsTheRadius() { EditorSession session; blurring(session, BrushSettings{.diameter = 60}); const CanvasDocument document = session.document().value(); const QImage sharp = edge(60, 40); - // A tenth of the size, within 1.5 and 30. - for (const auto &[diameter, sigma] : {std::pair(60.0, 6.0), std::pair(5.0, 1.5), std::pair(900.0, 30.0)}) { + // The bar's Radius, within 0.5 and 50, whatever the size. + for (const auto &[radius, sigma] : {std::pair(6.0, 6.0), std::pair(0.1, 0.5), std::pair(90.0, 50.0)}) { BrushSettings settings = session.brushSettings(); - settings.diameter = diameter; + settings.blurRadius = radius; + settings.diameter = 3 * radius; session.setBrushSettings(settings); QCOMPARE(session.blurSample(document).value(), PixelAdjust::gaussianBlur(sharp, sigma, false)); } @@ -149,7 +150,7 @@ void BlurToolTests::theSampleFollowsTheBrushSize() void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement() { EditorSession session; - blurring(session, BrushSettings{.diameter = 20}); + blurring(session, BrushSettings{.diameter = 20, .blurRadius = 2}); const QUuid id = session.activeLayerID().value(); // A white mask with a black bar, placed mid-layer. QImage mask(20, 20, QImage::Format_Grayscale8); @@ -185,7 +186,7 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement() record(snapshot, wideID).maskPlacement = LayerTransform{.origin = {30, 10}, .size = {40, 40}, .rotation = 30}; record(snapshot, wideID).maskLinked = false; }); - turned.setBrushSettings(BrushSettings{.diameter = 5}); + turned.setBrushSettings(BrushSettings{.diameter = 5, .blurRadius = 1.5}); const QImage soft = turned.blurSample(turned.document().value(), true).value(); // Past the true box, inside one turned the other way. QVERIFY2(soft.constScanLine(36)[73] > 240, qPrintable(QString::number(soft.constScanLine(36)[73]))); @@ -195,7 +196,7 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement() void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep() { EditorSession session; - blurring(session, BrushSettings{.diameter = 20, .hardness = 1}); + blurring(session, BrushSettings{.diameter = 20, .hardness = 1, .blurRadius = 2}); const int steps = session.history.undoCount(); session.beginBrush(QPointF(30, 12)); QVERIFY(session.brushStroke() && session.brushStroke()->isBlur && !session.warpStroke()); @@ -225,7 +226,7 @@ void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep() void BlurToolTests::aBlurStrokeSoftensAMask() { EditorSession session; - blurring(session, BrushSettings{.diameter = 20, .hardness = 1}); + blurring(session, BrushSettings{.diameter = 20, .hardness = 1, .blurRadius = 2}); const QUuid id = session.activeLayerID().value(); QImage mask(60, 40, QImage::Format_Grayscale8); for (int y = 0; y < 40; ++y) diff --git a/tests/BrushControlsTests.cpp b/tests/BrushControlsTests.cpp index 836216c..cfadf66 100644 --- a/tests/BrushControlsTests.cpp +++ b/tests/BrushControlsTests.cpp @@ -39,6 +39,7 @@ private slots: void cloneStampShowsAlignedSampleAndTheSourceNote(); void theSmearShowsItsThreeModes(); void smoothingShowsWithTheBrushAlone(); + void blurHasItsOwnRadius(); }; void BrushControlsTests::theTitleAndModesFollowTheSession() @@ -306,5 +307,86 @@ void BrushControlsTests::smoothingShowsWithTheBrushAlone() QVERIFY(field.isVisible()); } +// Swift's Radius: Blur alone, apart from Strength. +void BrushControlsTests::blurHasItsOwnRadius() +{ + EditorSession session; + session.createDocument(8, 8, true); + session.selectTool(NavigationTool::blur); + BrushControls bar(session); + bar.show(); + auto &field = find(bar, "blurRadius"); + auto &slider = find(bar, "blurRadiusSlider"); + QVERIFY(!field.isVisible() && !slider.isVisible()); + session.setBlurMode(BlurToolMode::blur); + QVERIFY(field.isVisible() && slider.isVisible()); + QCOMPARE(session.brushSettings().blurRadius, 5.0); + QCOMPARE(field.text(), QString("5")); + QCOMPARE(field.toolTip(), QString("How far the blur softens, in pixels")); + QCOMPARE(field.accessibleName(), QString("Radius")); + QVERIFY(slider.minimum() == 50 && slider.maximum() == 2000 && slider.value() == 500 && slider.width() == 100 && field.width() == 42); + slider.setValue(1250); + QCOMPARE(session.brushSettings().blurRadius, 12.5); + QCOMPARE(field.text(), QString("12.5")); + // Typed, it applies on Return, clamped, past the slider's end. + type(field, "33.33"); + QCOMPARE(session.brushSettings().blurRadius, 12.5); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(session.brushSettings().blurRadius, 33.33); + QCOMPARE(field.text(), QString("33.3")); + QCOMPARE(slider.value(), 2000); + // Return on what it shows rounds nothing. + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(session.brushSettings().blurRadius, 33.33); + type(field, "90"); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(session.brushSettings().blurRadius, 50.0); + type(field, "0"); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(session.brushSettings().blurRadius, 0.5); + QCOMPARE(field.text(), QString("0.5")); + type(field, "abc"); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(session.brushSettings().blurRadius, 0.5); + QCOMPARE(field.text(), QString("0.5")); + // Arrows step a pixel, Shift ten, within bounds. + QTest::keyClick(&field, Qt::Key_Up); + QCOMPARE(session.brushSettings().blurRadius, 1.5); + QCOMPARE(field.text(), QString("1.5")); + QTest::keyClick(&field, Qt::Key_Up, Qt::ShiftModifier); + QCOMPARE(session.brushSettings().blurRadius, 11.5); + QTest::keyClick(&field, Qt::Key_Down, Qt::ShiftModifier); + QTest::keyClick(&field, Qt::Key_Down, Qt::ShiftModifier); + QCOMPARE(session.brushSettings().blurRadius, 0.5); + // While typed in, other changes leave the typing. + type(field, "7"); + BrushSettings settings = session.brushSettings(); + settings.blurRadius = 3; + session.setBrushSettings(settings); + QCOMPARE(field.text(), QString("7")); + QTest::keyClick(&field, Qt::Key_Return); + QCOMPARE(session.brushSettings().blurRadius, 7.0); + // The label scrubs a tenth a point. + QLabel *radius = nullptr; + for (QLabel *label : bar.findChildren()) + if (label->text() == QLatin1String("Radius")) + radius = label; + QVERIFY(radius && radius->isVisible()); + QMouseEvent press(QEvent::MouseButtonPress, QPointF(2, 2), radius->mapToGlobal(QPointF(2, 2)), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(radius, &press); + QMouseEvent move(QEvent::MouseMove, QPointF(22, 2), radius->mapToGlobal(QPointF(22, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(radius, &move); + QCOMPARE(session.brushSettings().blurRadius, 9.0); + QCOMPARE(field.text(), QString("9")); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(22, 2), radius->mapToGlobal(QPointF(22, 2)), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(radius, &release); + // Smudge, Liquify and the other tools have none. + session.setBlurMode(BlurToolMode::smudge); + QVERIFY(!field.isVisible()); + session.setBlurMode(BlurToolMode::blur); + session.selectTool(NavigationTool::brush); + QVERIFY(!field.isVisible() && !slider.isVisible()); +} + QTEST_MAIN(BrushControlsTests) #include "BrushControlsTests.moc" diff --git a/tests/BrushTests.cpp b/tests/BrushTests.cpp index 60f9c9d..d80cd77 100644 --- a/tests/BrushTests.cpp +++ b/tests/BrushTests.cpp @@ -305,7 +305,9 @@ void BrushTests::masksPaintInGrayAndCommitWhole() void BrushTests::wildSettingsAndPointsAreRefused() { - for (const BrushSettings &settings : {red(0), red(2001), red(NAN), red(20, 1.1), red(20, -0.1), brush(20, 1, 1, 0, 0, 0.001), brush(20, 1, 1, 0, 0, INFINITY)}) + // Past the bar's 2000, the app's own commits reach 2100. + QCOMPARE(BrushStroke(blankLayer(600, 80), false, red(2100), QSizeF(600, 80)).settings.diameter, 2100.0); + for (const BrushSettings &settings : {red(0), red(2100.5), red(NAN), red(20, 1.1), red(20, -0.1), brush(20, 1, 1, 0, 0, 0.001), brush(20, 1, 1, 0, 0, INFINITY)}) QVERIFY_THROWS_EXCEPTION(ProjectError, BrushStroke(blankLayer(600, 80), false, settings, QSizeF(600, 80))); // A billion-pixel grid is refused while still a double. QImage strip = BrushRaster::context(30000, 1, false); diff --git a/tests/LargeCanvasBrushTests.cpp b/tests/LargeCanvasBrushTests.cpp new file mode 100644 index 0000000..14520bd --- /dev/null +++ b/tests/LargeCanvasBrushTests.cpp @@ -0,0 +1,101 @@ +#include "BrushFixtures.h" +#include "Document/EditorSession.h" +#include "IO/ImageExporter.h" +#include "SessionFixtures.h" +#include + +// Swift's LargeCanvasBrushTests: the largest Smear on a big canvas. +namespace { +// Eight coloured bands across a square canvas. +void bands(EditorSession &session, int side) +{ + session.createDocument(side, side); + QImage image = BrushRaster::context(side, side, false); + { + QPainter painter(&image); + for (int band = 0; band < 8; ++band) + painter.fillRect(QRect(band * side / 8, 0, side / 8 + 1, side), QColor::fromRgbF(band / 7.0, 0.4, 1 - band / 7.0)); + } + session.insert(ImportedImage(image, image, QStringLiteral("Bands"))); + session.selectTool(NavigationTool::blur); + BrushSettings settings = session.brushSettings(); + settings.diameter = 2000; + settings.hardness = 0.5; + session.setBrushSettings(settings); +} + +void stroke(EditorSession &session, int side) +{ + const double y = side / 2.0; + session.beginBrush(QPointF(1000, y)); + for (int step = 1; step <= 20; ++step) + session.continueBrush(QPointF(1000 + step * 100.0, y + (step % 3) * 40.0)); + session.finishBrushImmediately(); +} +} + +class LargeCanvasBrushTests : public QObject { + Q_OBJECT +private slots: + void largestBrushCommits_data(); + void largestBrushCommits(); + void aThinnedCommitLandsAllTheWarpMoved(); +}; + +void LargeCanvasBrushTests::largestBrushCommits_data() +{ + QTest::addColumn("mode"); + QTest::newRow("liquify") << BlurToolMode::liquify; + QTest::newRow("smudge") << BlurToolMode::smudge; +} + +// The commit's tip runs past Size's 2000, once refused. +void LargeCanvasBrushTests::largestBrushCommits() +{ + QFETCH(BlurToolMode, mode); + EditorSession session; + bands(session, 5000); + session.setBlurMode(mode); + const ImageIdentity before = session.activeLayer().value().asset.value().identity(); + stroke(session, 5000); + QCOMPARE(session.brushError(), std::optional()); + QVERIFY(session.activeLayer().value().asset.value().identity() != before); + QCOMPARE(session.history.undoName(), rawValue(mode)); +} + +// A point every twentieth of the tip covers the warp. +void LargeCanvasBrushTests::aThinnedCommitLandsAllTheWarpMoved() +{ + EditorSession session; + bands(session, 400); + session.setBlurMode(BlurToolMode::liquify); + BrushSettings settings = session.brushSettings(); + settings.diameter = 160; + settings.hardness = 1; + session.setBrushSettings(settings); + // Hard to its rim, ending a rim before a band. + session.beginBrush(QPointF(60, 200)); + session.continueBrush(QPointF(273, 213)); + const QImage live = session.warpStroke()->image().copy(); + QCOMPARE(int(session.warpStroke()->points().size()), 54); + session.finishBrush(); + const QImage landed = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + int apart = 0; + for (int y = 0; y < 400; ++y) + apart += std::memcmp(landed.constScanLine(y), live.constScanLine(y), 400 * 4) != 0; + QCOMPARE(apart, 0); + QCOMPARE(session.history.undoName(), QString("Liquify")); + // A tight circle: chords between kept points stay close. + session.beginBrush(QPointF(260, 200)); + for (int step = 1; step <= 72; ++step) + session.continueBrush(QPointF(200 + 60 * std::cos(step * M_PI / 36), 200 + 60 * std::sin(step * M_PI / 36))); + const QImage circled = session.warpStroke()->image().copy(); + session.finishBrush(); + const QImage turned = ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + for (int y = 0; y < 400; ++y) + apart += std::memcmp(turned.constScanLine(y), circled.constScanLine(y), 400 * 4) != 0; + QCOMPARE(apart, 0); +} + +QTEST_GUILESS_MAIN(LargeCanvasBrushTests) +#include "LargeCanvasBrushTests.moc" diff --git a/tests/SmudgeLiquifyTests.cpp b/tests/SmudgeLiquifyTests.cpp index 2bae9c8..ebe3c56 100644 --- a/tests/SmudgeLiquifyTests.cpp +++ b/tests/SmudgeLiquifyTests.cpp @@ -5,7 +5,7 @@ #include "SessionFixtures.h" #include -// The Smear's warps against Swift's own arithmetic, replayed here. +// The Smear against Swift's arithmetic and shader, replayed here. namespace { // Swift's WarpStroke, written again from SmudgeLiquify.swift. struct Oracle { @@ -15,6 +15,9 @@ struct Oracle { bool smudging; std::vector carried, scratch; std::optional last; + // Liquify, as MetalWarp's shader: the untouched pixels and offsets. + std::vector original; + std::vector offsets; int radius() const { return int(std::ceil(diameter / 2)); } float weight(float u) const @@ -57,9 +60,9 @@ struct Oracle { for (int k = 0; k < 4; ++k) { uchar &p = pixels[size_t((y * width + x) * 4 + k)]; float &carry = carried[size_t(((dy + r) * side + dx + r) * 4 + k)]; - const float under = float(p), painted = under + (carry - under) * w; + const float under = float(p), painted = under + (carry - under) * w * keep; p = uchar(std::max(0.0f, std::min(255.0f, std::round(painted)))); - carry = painted + (carry - painted) * keep; + carry = painted; } } } @@ -74,12 +77,17 @@ struct Oracle { if (x0 > x1 || y0 > y1) return; const int cw = x1 - x0 + 1, ch = y1 - y0 + 1; - scratch.assign(size_t(cw * ch * 4), 0); + if (original.empty()) { + original = pixels; + offsets.assign(size_t(width * height * 2), 0); + } + scratch.assign(size_t(cw * ch * 2), 0); for (int y = 0; y < ch; ++y) for (int x = 0; x < cw; ++x) - for (int k = 0; k < 4; ++k) - scratch[size_t((y * cw + x) * 4 + k)] = float(pixels[size_t(((y + y0) * width + x + x0) * 4 + k)]); + for (int k = 0; k < 2; ++k) + scratch[size_t((y * cw + x) * 2 + k)] = offsets[size_t(((y + y0) * width + x + x0) * 2 + k)]; const float invR = 1 / float(diameter / 2); + const auto mix = [](float a, float b, float t) { return a + (b - a) * t; }; for (int dy = -r; dy <= r; ++dy) for (int dx = -r; dx <= r; ++dx) { const int y = cy + dy, x = cx + dx; @@ -94,11 +102,19 @@ struct Oracle { if (ix < 0 || iy < 0) continue; const float fx = sx - float(ix), fy = sy - float(iy); - const auto at = [&](int ox, int oy, int k) { return scratch[size_t(((iy + oy) * cw + ix + ox) * 4 + k)]; }; + const auto at = [&](int ox, int oy, int k) { return scratch[size_t(((iy + oy) * cw + ix + ox) * 2 + k)]; }; + float moved[2]; + for (int k = 0; k < 2; ++k) + moved[k] = mix(mix(at(0, 0, k), at(1, 0, k), fx), mix(at(0, 1, k), at(1, 1, k), fx), fy) - (k == 0 ? mx : my) * w; + offsets[size_t((y * width + x) * 2)] = moved[0]; + offsets[size_t((y * width + x) * 2 + 1)] = moved[1]; + const float px = std::clamp(float(x) + moved[0], 0.0f, float(width - 1)), py = std::clamp(float(y) + moved[1], 0.0f, float(height - 1)); + const int jx = std::min(int(px), width - 2), jy = std::min(int(py), height - 2); + const float gx = px - float(jx), gy = py - float(jy); + const auto c = [&](int ox, int oy, int k) { return float(original[size_t(((jy + oy) * width + jx + ox) * 4 + k)]) / 255; }; for (int k = 0; k < 4; ++k) { - const float top = at(0, 0, k) + (at(1, 0, k) - at(0, 0, k)) * fx; - const float bottom = at(0, 1, k) + (at(1, 1, k) - at(0, 1, k)) * fx; - pixels[size_t((y * width + x) * 4 + k)] = uchar(std::max(0.0f, std::min(255.0f, std::round(top + (bottom - top) * fy)))); + const float colour = mix(mix(c(0, 0, k), c(1, 0, k), gx), mix(c(0, 1, k), c(1, 1, k), gx), gy); + pixels[size_t((y * width + x) * 4 + k)] = uchar(std::clamp(std::round(colour * 255), 0.0f, 255.0f)); } } } @@ -112,7 +128,7 @@ struct Oracle { } const QPointF from = *last; const double distance = std::hypot(point.x() - from.x(), point.y() - from.y()); - const double spacing = std::max(1.0, diameter * (smudging ? 0.08 : 0.025)); + const double spacing = std::max(1.0, diameter * (smudging ? 0.005 : 0.025)); if (distance < spacing) return; const int steps = int(std::ceil(distance / spacing)); @@ -147,7 +163,7 @@ Oracle oracle(const QImage &image, const BrushSettings &settings, bool smudging) for (int y = 0; y < image.height(); ++y) std::memcpy(bytes.data() + size_t(y * image.width() * 4), image.constScanLine(y), size_t(image.width() * 4)); return Oracle{bytes, image.width(), image.height(), std::max(2.0, settings.diameter), std::min(0.98, std::max(0.0, settings.hardness)), - std::min(1.0, std::max(0.01, settings.opacity)), smudging, {}, {}, std::nullopt}; + std::min(1.0, std::max(0.01, settings.opacity)), smudging, {}, {}, std::nullopt, {}, {}}; } // Every byte the stroke made, against the oracle's. @@ -181,6 +197,7 @@ class SmudgeLiquifyTests : public QObject { private slots: void liquifyAndSmudgeFollowSwiftsArithmetic_data(); void liquifyAndSmudgeFollowSwiftsArithmetic(); + void liquifyHoldsItsSourcesToTheEdges(); void theSettingsAreClampedAsSwiftClampsThem(); void liquifyCommitsAlongThePathAsOneStep(); void theCommitLandsAllTheWarpMoved(); @@ -217,9 +234,9 @@ void SmudgeLiquifyTests::liquifyAndSmudgeFollowSwiftsArithmetic() ImageLayer(ImportedImage(image, image, QStringLiteral("Scene")), QPointF(0, 0)).transform, QSizeF(80, 40), smudging ? BlurToolMode::smudge : BlurToolMode::liquify, settings); Oracle expected = oracle(image, settings, smudging); - // Along, back across, off the canvas and in again. + // Along, back, off the canvas, then in from edges. for (const QPointF point : {QPointF(30.6, 20.2), QPointF(47.7, 23.1), QPointF(47.9, 23.3), QPointF(48.4, 23.3), QPointF(20, 31.5), QPointF(-6, 37), - QPointF(12, 9.5)}) { + QPointF(12, 9.5), QPointF(79.4, 38.6), QPointF(64, 30.2), QPointF(70, 0.3), QPointF(66.5, 12)}) { stroke.append(point); expected.append(point); QCOMPARE(differences(stroke, expected), 0); @@ -228,6 +245,26 @@ void SmudgeLiquifyTests::liquifyAndSmudgeFollowSwiftsArithmetic() QVERIFY(stroke.points().size() > 10); } +// Pushed in from each edge, sources clamp at the ramps. +void SmudgeLiquifyTests::liquifyHoldsItsSourcesToTheEdges() +{ + QImage ramps = BrushRaster::context(80, 40, false); + for (int y = 0; y < 40; ++y) + for (int x = 0; x < 80; ++x) + ramps.setPixel(x, y, qRgba(x * 3, y * 6, 90, 255)); + const ImageLayer layer(ImportedImage(ramps, ramps, QStringLiteral("Ramps")), QPointF(0, 0)); + const BrushSettings settings = brush(30, 0.8, 0, 0, 0, 1); + WarpStroke stroke(layer, ramps, layer.transform, QSizeF(80, 40), BlurToolMode::liquify, settings); + Oracle expected = oracle(ramps, settings, false); + for (const QPointF point : {QPointF(79.5, 20), QPointF(62, 20), QPointF(40, 39.5), QPointF(40, 24), QPointF(0.5, 20), QPointF(16, 20), + QPointF(40, 0.5), QPointF(40, 15)}) { + stroke.append(point); + expected.append(point); + QCOMPARE(differences(stroke, expected), 0); + } + QVERIFY(stroke.image() != ramps); +} + void SmudgeLiquifyTests::theSettingsAreClampedAsSwiftClampsThem() { const QImage image = scene(20, 20); @@ -282,7 +319,7 @@ void SmudgeLiquifyTests::theCommitLandsAllTheWarpMoved() for (int y = 0; y < 40; ++y) apart += std::memcmp(landed.constScanLine(y), live.constScanLine(y), 80 * 4) != 0; QCOMPARE(apart, 0); - QVERIFY(pixel(live, 41, 19) != pixel(scene(80, 40), 41, 19)); + QVERIFY(pixel(live, 41, 15) != pixel(scene(80, 40), 41, 15)); // Shift's line starts where the warp ended, on the pixels. QCOMPARE(session.shiftLineStart().value(), to); } @@ -296,10 +333,13 @@ void SmudgeLiquifyTests::smudgeCarriesColourAndNamesItsStep() session.continueBrush(QPointF(50, 12)); session.finishBrush(); QCOMPARE(session.history.undoName(), QString("Smudge")); - // The core carries red all the way; its flanks fade. - QCOMPARE(exported(session, 49, 12), (std::vector{255, 0, 0, 255})); - const std::vector near = exported(session, 42, 15), far = exported(session, 49, 15); - QVERIFY2(near[0] > far[0] + 20 && far[0] > 0 && far[2] > 0, qPrintable(QString("%1 %2 %3").arg(near[0]).arg(far[0]).arg(far[2]))); + // Red drags into the blue, fading along the way. + QCOMPARE(exported(session, 41, 12), (std::vector{233, 0, 22, 255})); + QCOMPARE(exported(session, 45, 12), (std::vector{94, 0, 161, 255})); + QCOMPARE(exported(session, 49, 12), (std::vector{9, 0, 246, 255})); + // Its flanks less so; the rim not at all. + QCOMPARE(exported(session, 42, 15), (std::vector{61, 0, 194, 255})); + QCOMPARE(exported(session, 49, 15), (std::vector{0, 0, 255, 255})); QCOMPARE(exported(session, 75, 35), (std::vector{0, 0, 255, 255})); } diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 4c9b1f1..f1a6a29 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -41,6 +41,9 @@ compositor_test(SpotHealingTests) compositor_test(CloneStampTests) compositor_test(CloneStampCanvasTests) compositor_test(SmudgeLiquifyTests) +compositor_test(WarpStrokeTests) +compositor_test(LargeCanvasBrushTests) +compositor_test(BlurBrushTests) compositor_test(BlurToolTests) compositor_test(SmearCanvasTests) compositor_test(GradientTests) diff --git a/tests/WarpStrokeTests.cpp b/tests/WarpStrokeTests.cpp new file mode 100644 index 0000000..082a5bb --- /dev/null +++ b/tests/WarpStrokeTests.cpp @@ -0,0 +1,98 @@ +#include "BrushFixtures.h" +#include "Document/SmudgeLiquify.h" +#include + +// Swift's MetalWarpTests: Liquify stays sharp, Smudge leaves no ghosts. +namespace { +WarpStroke warp(const QImage &image, BlurToolMode mode, const BrushSettings &settings) +{ + const ImageLayer layer(ImportedImage(image, image, QStringLiteral("Picture")), QPointF(0, 0)); + return WarpStroke(layer, image, layer.transform, QSizeF(image.size()), mode, settings); +} +} + +class WarpStrokeTests : public QObject { + Q_OBJECT +private slots: + void liquifyStaysSharp(); + void liquifyDrawsFromTheUntouchedLayer(); + void smudgeLeavesOneFadingTrail(); +}; + +// Pushed across and back, the layer ends nearly unchanged. +void WarpStrokeTests::liquifyStaysSharp() +{ + // Swift's photo: two ramps, a checkerboard, a half-clear strip. + QImage image = BrushRaster::context(300, 200, false); + for (int y = 0; y < 200; ++y) { + uchar *row = image.scanLine(y); + for (int x = 0; x < 300; ++x) { + const int a = x < 250 ? 255 : 128; + const int check = (x / 12 + y / 12) % 2 == 0 ? 220 : 40; + row[x * 4] = uchar(x * 255 / 300 * a / 255); + row[x * 4 + 1] = uchar(y * 255 / 200 * a / 255); + row[x * 4 + 2] = uchar(check * a / 255); + row[x * 4 + 3] = uchar(a); + } + } + WarpStroke stroke = warp(image, BlurToolMode::liquify, brush(60, 0.3, 0, 0, 0, 0.7)); + for (double x = 60; x <= 200; x += 4) + stroke.append(QPointF(x, 100)); + for (double x = 200; x >= 60; x -= 4) + stroke.append(QPointF(x, 100)); + double change = 0; + for (int y = 0; y < 200; ++y) + for (int x = 0; x < 300 * 4; ++x) + change += std::abs(int(stroke.image().constScanLine(y)[x]) - int(image.constScanLine(y)[x])); + change /= 300.0 * 200 * 4; + // Swift measured 0.49 sampled once, 2.34 resampled per dab. + QVERIFY2(change < 1, qPrintable(QString::number(change))); + QVERIFY(change > 0); +} + +// Half-pixel pushes add up to one: stripes stay crisp. +void WarpStrokeTests::liquifyDrawsFromTheUntouchedLayer() +{ + QImage image = BrushRaster::context(60, 20, false); + for (int x = 0; x < 60; ++x) + for (int y = 0; y < 20; ++y) + image.setPixel(x, y, qRgba(x % 2 ? 200 : 0, 0, 0, 255)); + WarpStroke stroke = warp(image, BlurToolMode::liquify, brush(10, 0.98, 0, 0, 0, 0.5)); + stroke.append(QPointF(20, 10)); + stroke.append(QPointF(21, 10)); + stroke.append(QPointF(22, 10)); + // Resampled per dab these read 100; now each, one stripe. + QCOMPARE(pixel(stroke.image(), 22, 10), (std::vector{200, 0, 0, 255})); + QCOMPARE(pixel(stroke.image(), 21, 10), (std::vector{0, 0, 0, 255})); + QCOMPARE(pixel(stroke.image(), 23, 10), (std::vector{0, 0, 0, 255})); + QCOMPARE(pixel(stroke.image(), 40, 10), (std::vector{0, 0, 0, 255})); + QCOMPARE(pixel(stroke.image(), 41, 10), (std::vector{200, 0, 0, 255})); +} + +void WarpStrokeTests::smudgeLeavesOneFadingTrail() +{ + QImage image = BrushRaster::context(300, 100, false); + image.fill(QColor::fromRgbF(0.1, 0.1, 0.1)); + { + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + painter.setBrush(Qt::white); + painter.drawEllipse(QRectF(52, 42, 16, 16)); + } + WarpStroke stroke = warp(image, BlurToolMode::smudge, brush(40, 0.5, 0, 0, 0, 0.6)); + for (double x = 60; x <= 240; x += 3) + stroke.append(QPointF(x, 50)); + std::vector row; + for (int x = 70; x < 240; ++x) + row.push_back(stroke.image().constScanLine(50)[x * 4]); + // A ghost is a bump: brighter than two either side. + int peaks = 0; + for (size_t i = 2; i + 2 < row.size(); ++i) + peaks += row[i] > row[i - 2] + 2 && row[i] > row[i + 2] + 2; + QCOMPARE(peaks, 0); + QVERIFY2(row.front() > row.back() + 20, qPrintable(QString("%1 to %2").arg(row.front()).arg(row.back()))); +} + +QTEST_GUILESS_MAIN(WarpStrokeTests) +#include "WarpStrokeTests.moc"