[graphics/krita] /: add liquify brushes "Relax" and "Affine Relax"; fixes messed up points, but preserves affine transforms (as much as possible...) normal "relax" only tries to maintain relative distortion, so may also un-shear, un-rotate etc; often you want one or the other so its two modes not one

Dmitry Kazakov <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit a85d83e532b79a2cb95cef95cabe2a5daa132d6f by Dmitry Kazakov, on behalf of Moritz Staudinger.
Committed on 07/08/2026 at 13:58.
Pushed by dkazakov into branch 'master'.

add liquify brushes "Relax" and "Affine Relax"; fixes messed up points, but preserves affine transforms (as much as possible...)   normal "relax" only tries to maintain relative distortion, so may also un-shear, un-rotate etc; often you want one or the other so its two modes not one

M  +43   -0    benchmarks/KisLiquifyTransformWorkerBenchmark.cpp
M  +6    -0    benchmarks/KisLiquifyTransformWorkerBenchmark.h
M  +4    -0    krita/pics/tool_transform/tool-transform-icons.qrc
M  +233  -0    libs/image/kis_liquify_transform_worker.cpp
M  +8    -0    libs/image/kis_liquify_transform_worker.h
M  +48   -0    libs/image/tests/kis_liquify_transform_worker_test.cpp
M  +2    -0    libs/image/tests/kis_liquify_transform_worker_test.h
M  +15   -2    plugins/tools/tool_transform2/kis_liquify_paintop.cpp
M  +41   -1    plugins/tools/tool_transform2/kis_liquify_properties.cpp
M  +5    -0    plugins/tools/tool_transform2/kis_liquify_properties.h
M  +22   -4    plugins/tools/tool_transform2/kis_tool_transform_config_widget.cpp
M  +44   -0    plugins/tools/tool_transform2/wdg_tool_transform.ui

https://invent.kde.org/graphics/krita/-/commit/a85d83e532b79a2cb95cef95cabe2a5daa132d6f

diff --git a/benchmarks/KisLiquifyTransformWorkerBenchmark.cpp b/benchmarks/KisLiquifyTransformWorkerBenchmark.cpp
index e97aaa30218..110ee45d4b5 100644
--- a/benchmarks/KisLiquifyTransformWorkerBenchmark.cpp
+++ b/benchmarks/KisLiquifyTransformWorkerBenchmark.cpp
@@ -158,6 +158,29 @@ void testPoints(KisLiquifyTransformWorkerBenchmark::Operation operation, bool us
                 worker.undoPoints(undoBases[i], flows[i%flows.length()], sigmas[i%sigmas.length()]);
             }
         }
+    } else if (operation == KisLiquifyTransformWorkerBenchmark::RelaxSimple ||
+               operation == KisLiquifyTransformWorkerBenchmark::RelaxAffine) {
+
+        for (int i = 0; i < bases.count(); i++) {
+            worker.translatePoints(bases[i], QPointF(50, 0), sigmas[i%sigmas.length()], useWashMode, flows[i%flows.length()]);
+        }
+
+        for (int i = 0; i < bases.count(); i++) {
+            worker.rotatePoints(bases[i], M_PI / 4, sigmas[i%sigmas.length()], useWashMode, flows[i%flows.length()]);
+        }
+
+        QList<QPointF> relaxBases = preparePointsData((A + D)/2, (C+B)/2, (B+D)/2, (A+C)/2, oneWayBasesCount);
+
+        QBENCHMARK {
+
+            for (int i = 0; i < relaxBases.count(); i++) {
+                if (operation == KisLiquifyTransformWorkerBenchmark::RelaxSimple) {
+                    worker.relaxPoints(relaxBases[i], flows[i%flows.length()], sigmas[i%sigmas.length()]);
+                } else {
+                    worker.affineRelaxPoints(relaxBases[i], flows[i%flows.length()], sigmas[i%sigmas.length()]);
+                }
+            }
+        }
     } else if (operation == KisLiquifyTransformWorkerBenchmark::RunOnQImage) {
         for (int i = 0; i < bases.count(); i++) {
             worker.translatePoints(bases[i], QPointF(50, 0), sigmas[i%sigmas.length()], useWashMode, flows[i%flows.length()]);
@@ -291,6 +314,26 @@ void KisLiquifyTransformWorkerBenchmark::testPointsUndoWash()
     testPoints(Undo, true);
 }
 
+void KisLiquifyTransformWorkerBenchmark::testPointsRelaxSimpleBuildUp()
+{
+    testPoints(RelaxSimple, false);
+}
+
+void KisLiquifyTransformWorkerBenchmark::testPointsRelaxSimpleWash()
+{
+    testPoints(RelaxSimple, true);
+}
+
+void KisLiquifyTransformWorkerBenchmark::testPointsRelaxAffineBuildUp()
+{
+    testPoints(RelaxAffine, false);
+}
+
+void KisLiquifyTransformWorkerBenchmark::testPointsRelaxAffineWash()
+{
+    testPoints(RelaxAffine, true);
+}
+
 void KisLiquifyTransformWorkerBenchmark::testRunOnDev()
 {
     testPoints(RunOnDev, true);
diff --git a/benchmarks/KisLiquifyTransformWorkerBenchmark.h b/benchmarks/KisLiquifyTransformWorkerBenchmark.h
index bc2cf180063..007d927b368 100644
--- a/benchmarks/KisLiquifyTransformWorkerBenchmark.h
+++ b/benchmarks/KisLiquifyTransformWorkerBenchmark.h
@@ -18,6 +18,8 @@ public:
         Scale,
         Rotate,
         Undo,
+        RelaxSimple,
+        RelaxAffine,
         RunOnQImage,
         RunOnDev,
         BenchmarkCopyConstructor,
@@ -42,6 +44,10 @@ private Q_SLOTS:
 
     void testPointsUndoBuildUp();
     void testPointsUndoWash();
+    void testPointsRelaxSimpleBuildUp();
+    void testPointsRelaxSimpleWash();
+    void testPointsRelaxAffineBuildUp();
+    void testPointsRelaxAffineWash();
 
     void testRunOnDev();
     void testRunOnQImage();
diff --git a/krita/pics/tool_transform/tool-transform-icons.qrc b/krita/pics/tool_transform/tool-transform-icons.qrc
index c512e37db99..065ca0f9f05 100644
--- a/krita/pics/tool_transform/tool-transform-icons.qrc
+++ b/krita/pics/tool_transform/tool-transform-icons.qrc
@@ -9,6 +9,8 @@
         <file>dark_transform_icons_liquify_main.svg</file>
         <file>dark_transform_icons_liquify_move.svg</file>
         <file>dark_transform_icons_liquify_offset.svg</file>
+        <file>dark_transform_icons_liquify_relax_affine.svg</file>
+        <file>dark_transform_icons_liquify_relax.svg</file>
         <file>dark_transform_icons_liquify_resize.svg</file>
         <file>dark_transform_icons_liquify_rotate.svg</file>
         <file>dark_transform_icons_liquify_rotateCCW.svg</file>
@@ -27,6 +29,8 @@
         <file>light_transform_icons_liquify_main.svg</file>
         <file>light_transform_icons_liquify_move.svg</file>
         <file>light_transform_icons_liquify_offset.svg</file>
+        <file>light_transform_icons_liquify_relax_affine.svg</file>
+        <file>light_transform_icons_liquify_relax.svg</file>
         <file>light_transform_icons_liquify_resize.svg</file>
         <file>light_transform_icons_liquify_rotate.svg</file>
         <file>light_transform_icons_liquify_rotateCCW.svg</file>
diff --git a/libs/image/kis_liquify_transform_worker.cpp b/libs/image/kis_liquify_transform_worker.cpp
index fec30b43a4c..4ee32aa3d4d 100644
--- a/libs/image/kis_liquify_transform_worker.cpp
+++ b/libs/image/kis_liquify_transform_worker.cpp
@@ -227,6 +227,239 @@ void KisLiquifyTransformWorker::undoPoints(const QPointF &base,
     }
 }
 
+namespace {
+
+struct PointUpdate
+{
+    int index = -1;
+    QPointF oldPosition;
+    QPointF newPosition;
+};
+
+bool solveLinear3x3(const qreal matrix[3][3], const qreal rhs[3], qreal result[3])
+{
+    const qreal determinant =
+        matrix[0][0] * (matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1]) -
+        matrix[0][1] * (matrix[1][0] * matrix[2][2] - matrix[1][2] * matrix[2][0]) +
+        matrix[0][2] * (matrix[1][0] * matrix[2][1] - matrix[1][1] * matrix[2][0]);
+
+    if (qAbs(determinant) < 1e-12) return false;
+
+    const qreal invDeterminant = 1.0 / determinant;
+
+    qreal inverse[3][3];
+    inverse[0][0] =  (matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1]) * invDeterminant;
+    inverse[0][1] = -(matrix[0][1] * matrix[2][2] - matrix[0][2] * matrix[2][1]) * invDeterminant;
+    inverse[0][2] =  (matrix[0][1] * matrix[1][2] - matrix[0][2] * matrix[1][1]) * invDeterminant;
+    inverse[1][0] = -(matrix[1][0] * matrix[2][2] - matrix[1][2] * matrix[2][0]) * invDeterminant;
+    inverse[1][1] =  (matrix[0][0] * matrix[2][2] - matrix[0][2] * matrix[2][0]) * invDeterminant;
+    inverse[1][2] = -(matrix[0][0] * matrix[1][2] - matrix[0][2] * matrix[1][0]) * invDeterminant;
+    inverse[2][0] =  (matrix[1][0] * matrix[2][1] - matrix[1][1] * matrix[2][0]) * invDeterminant;
+    inverse[2][1] = -(matrix[0][0] * matrix[2][1] - matrix[0][1] * matrix[2][0]) * invDeterminant;
+    inverse[2][2] =  (matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0]) * invDeterminant;
+
+    for (int i = 0; i < 3; i++) {
+        result[i] = inverse[i][0] * rhs[0] + inverse[i][1] * rhs[1] + inverse[i][2] * rhs[2];
+    }
+
+    return true;
+}
+
+}
+
+void KisLiquifyTransformWorker::relaxPoints(const QPointF &base,
+                                            qreal amount,
+                                            qreal sigma)
+{
+    const qreal maxDistCoeff = 3.0;
+    const qreal maxDist = maxDistCoeff * sigma;
+
+    KIS_ASSERT_RECOVER_RETURN(m_d->originalPoints.size() ==
+                              m_d->transformedPoints.size());
+
+    QRectF clipRect(base.x() - maxDist, base.y() - maxDist,
+                    2 * maxDist, 2 * maxDist);
+    m_d->accumulatedBrushStrokes |= kisGrowRect(clipRect, m_d->pixelPrecision);
+
+    QVector<int> indexes;
+    m_d->transformedPointsContainer.findAllInRange(indexes, base, maxDist);
+
+    QVector<PointUpdate> updates;
+    updates.reserve(indexes.count());
+
+    /*
+     * Blur the displacement field, not the transformed point positions.
+     * Repeatedly blurring positions would pull all coordinates in the dab
+     * toward their centroid, effectively compressing the grid like a black
+     * hole. Blurring displacements keeps the original grid structure and
+     * only reduces local disagreement in how neighboring points moved.
+     *
+     * Store all updates first so every point is relaxed against the same
+     * pre-dab state; otherwise the result would depend on iteration order.
+     */
+    for (int i = 0; i < indexes.count(); i++) {
+        const int index = indexes[i];
+
+        QPointF diff = m_d->transformedPoints[index] - base;
+        qreal dist = KisAlgebra2D::norm(diff);
+        if (dist > maxDist) continue;
+
+        qreal lambda = exp(-0.5 * pow2(dist / sigma));
+        lambda *= amount;
+
+        const int col = index % m_d->gridSize.width();
+        const int row = index / m_d->gridSize.width();
+
+        QPointF displacementSum;
+        int displacementCount = 0;
+
+        for (int neighborRow = qMax(0, row - 1);
+             neighborRow <= qMin(m_d->gridSize.height() - 1, row + 1);
+             neighborRow++) {
+            for (int neighborCol = qMax(0, col - 1);
+                 neighborCol <= qMin(m_d->gridSize.width() - 1, col + 1);
+                 neighborCol++) {
+
+                if (neighborCol == col && neighborRow == row) continue;
+
+                const int neighborIndex =
+                    GridIterationTools::pointToIndex(QPoint(neighborCol, neighborRow),
+                                                     m_d->gridSize);
+
+                displacementSum += m_d->transformedPoints[neighborIndex] -
+                    m_d->originalPoints[neighborIndex];
+                displacementCount++;
+            }
+        }
+
+        if (!displacementCount) continue;
+
+        const QPointF oldDisplacement =
+            m_d->transformedPoints[index] - m_d->originalPoints[index];
+        const QPointF relaxedDisplacement = displacementSum / displacementCount;
+
+        const QPointF newPosition =
+            m_d->originalPoints[index] +
+            oldDisplacement * (1.0 - lambda) +
+            relaxedDisplacement * lambda;
+
+        updates << PointUpdate{index, m_d->transformedPoints[index], newPosition};
+    }
+
+    for (int i = 0; i < updates.count(); i++) {
+        const PointUpdate &update = updates[i];
+        m_d->transformedPoints[update.index] = update.newPosition;
+        m_d->transformedPointsContainer.movePoint(update.index, update.oldPosition, update.newPosition);
+    }
+}
+
+void KisLiquifyTransformWorker::affineRelaxPoints(const QPointF &base,
+                                                  qreal amount,
+                                                  qreal sigma)
+{
+    const qreal maxDistCoeff = 3.0;
+    const qreal maxDist = maxDistCoeff * sigma;
+    const int neighborhoodRadius = 2;
+
+    KIS_ASSERT_RECOVER_RETURN(m_d->originalPoints.size() ==
+                              m_d->transformedPoints.size());
+
+    QRectF clipRect(base.x() - maxDist, base.y() - maxDist,
+                    2 * maxDist, 2 * maxDist);
+    m_d->accumulatedBrushStrokes |= kisGrowRect(clipRect, m_d->pixelPrecision);
+
+    QVector<int> indexes;
+    m_d->transformedPointsContainer.findAllInRange(indexes, base, maxDist);
+
+    QVector<PointUpdate> updates;
+    updates.reserve(indexes.count());
+
+    /*
+     * Fit a local affine transform from neighboring original points to their
+     * transformed positions. This preserves broad local translation, rotation,
+     * scale and shear. A pure displacement blur would slowly damp rotations
+     * because a rotated area has different displacement vectors at different
+     * points, even when the rotation itself is perfectly smooth.
+     *
+     * Like simple relax, collect updates first so each fit samples the same
+     * pre-dab deformation grid.
+     */
+    for (int i = 0; i < indexes.count(); i++) {
+        const int index = indexes[i];
+
+        QPointF diff = m_d->transformedPoints[index] - base;
+        qreal dist = KisAlgebra2D::norm(diff);
+        if (dist > maxDist) continue;
+
+        qreal lambda = exp(-0.5 * pow2(dist / sigma));
+        lambda *= amount;
+
+        const int col = index % m_d->gridSize.width();
+        const int row = index / m_d->gridSize.width();
+        const QPointF originalCenter = m_d->originalPoints[index];
+
+        qreal matrix[3][3] = {};
+        qreal rhsX[3] = {};
+        qreal rhsY[3] = {};
+        int sampleCount = 0;
+
+        for (int neighborRow = qMax(0, row - neighborhoodRadius);
+             neighborRow <= qMin(m_d->gridSize.height() - 1, row + neighborhoodRadius);
+             neighborRow++) {
+            for (int neighborCol = qMax(0, col - neighborhoodRadius);
+                 neighborCol <= qMin(m_d->gridSize.width() - 1, col + neighborhoodRadius);
+                 neighborCol++) {
+
+                if (neighborCol == col && neighborRow == row) continue;
+
+                const int neighborIndex =
+                    GridIterationTools::pointToIndex(QPoint(neighborCol, neighborRow),
+                                                     m_d->gridSize);
+                const QPointF originalOffset =
+                    m_d->originalPoints[neighborIndex] - originalCenter;
+                const qreal fitPoint[3] = {originalOffset.x(), originalOffset.y(), 1.0};
+
+                const qreal gridDistance = KisAlgebra2D::norm(originalOffset);
+                const qreal fitSigma = qMax<qreal>(m_d->pixelPrecision, 1.0);
+                const qreal weight = exp(-0.5 * pow2(gridDistance / (neighborhoodRadius * fitSigma)));
+
+                for (int r = 0; r < 3; r++) {
+                    for (int c = 0; c < 3; c++) {
+                        matrix[r][c] += weight * fitPoint[r] * fitPoint[c];
+                    }
+                    rhsX[r] += weight * fitPoint[r] * m_d->transformedPoints[neighborIndex].x();
+                    rhsY[r] += weight * fitPoint[r] * m_d->transformedPoints[neighborIndex].y();
+                }
+
+                sampleCount++;
+            }
+        }
+
+        qreal coeffX[3];
+        qreal coeffY[3];
+        if (sampleCount < 3 ||
+            !solveLinear3x3(matrix, rhsX, coeffX) ||
+            !solveLinear3x3(matrix, rhsY, coeffY)) {
+
+            continue;
+        }
+
+        const QPointF predictedPosition(coeffX[2], coeffY[2]);
+
+        const QPointF newPosition =
+            m_d->transformedPoints[index] * (1.0 - lambda) +
+            predictedPosition * lambda;
+
+        updates << PointUpdate{index, m_d->transformedPoints[index], newPosition};
+    }
+
+    for (int i = 0; i < updates.count(); i++) {
+        const PointUpdate &update = updates[i];
+        m_d->transformedPoints[update.index] = update.newPosition;
+        m_d->transformedPointsContainer.movePoint(update.index, update.oldPosition, update.newPosition);
+    }
+}
+
 template <class ProcessOp>
 void KisLiquifyTransformWorker::Private::
 processTransformedPixelsBuildUp(ProcessOp op,
diff --git a/libs/image/kis_liquify_transform_worker.h b/libs/image/kis_liquify_transform_worker.h
index cb2471cd884..b52657f1395 100644
--- a/libs/image/kis_liquify_transform_worker.h
+++ b/libs/image/kis_liquify_transform_worker.h
@@ -60,6 +60,14 @@ public:
                     qreal amount,
                     qreal sigma);
 
+    void relaxPoints(const QPointF &base,
+                     qreal amount,
+                     qreal sigma);
+
+    void affineRelaxPoints(const QPointF &base,
+                           qreal amount,
+                           qreal sigma);
+
     const QVector<QPointF>& originalPoints() const;
     QVector<QPointF>& transformedPoints();
 
diff --git a/libs/image/tests/kis_liquify_transform_worker_test.cpp b/libs/image/tests/kis_liquify_transform_worker_test.cpp
index b5beea7d176..049782f9369 100644
--- a/libs/image/tests/kis_liquify_transform_worker_test.cpp
+++ b/libs/image/tests/kis_liquify_transform_worker_test.cpp
@@ -130,6 +130,54 @@ void KisLiquifyTransformWorkerTest::testPoints()
     TestUtil::checkQImage(result, "liquify_transform_test", "liquify_dev", "unity");
 }
 
+void KisLiquifyTransformWorkerTest::testRelaxPoints()
+{
+    const QRect bounds(0, 0, 64, 64);
+    const int pixelPrecision = 8;
+
+    KisLiquifyTransformWorker worker(bounds, 0, pixelPrecision);
+
+    const QPoint centerCell(4, 4);
+    const int centerIndex = worker.pointToIndex(centerCell);
+    const QPointF centerPoint = worker.originalPoints()[centerIndex];
+
+    worker.translatePoints(centerPoint, QPointF(30.0, 0.0), 8.0, false, 1.0);
+
+    const QPointF oldCenterDisplacement =
+        worker.transformedPoints()[centerIndex] - worker.originalPoints()[centerIndex];
+    worker.relaxPoints(worker.transformedPoints()[centerIndex], 1.0, 8.0);
+
+    const QPointF centerDisplacement =
+        worker.transformedPoints()[centerIndex] - worker.originalPoints()[centerIndex];
+
+    QVERIFY(centerDisplacement.x() < oldCenterDisplacement.x());
+    QVERIFY(centerDisplacement.x() > 0.0);
+    QVERIFY(qAbs(centerDisplacement.y()) < 1e-6);
+}
+
+void KisLiquifyTransformWorkerTest::testAffineRelaxPoints()
+{
+    const QRect bounds(0, 0, 64, 64);
+    const int pixelPrecision = 8;
+
+    KisLiquifyTransformWorker worker(bounds, 0, pixelPrecision);
+
+    const QPoint centerCell(4, 4);
+    const int centerIndex = worker.pointToIndex(centerCell);
+    const QPointF centerPoint = worker.originalPoints()[centerIndex];
+
+    worker.rotatePoints(centerPoint, M_PI / 2.0, 100.0, false, 1.0);
+    const QPointF affinePosition = worker.transformedPoints()[centerIndex];
+
+    worker.translatePoints(affinePosition, QPointF(25.0, 0.0), 8.0, false, 1.0);
+    const qreal corruptedDistance = kisDistance(worker.transformedPoints()[centerIndex], affinePosition);
+
+    worker.affineRelaxPoints(worker.transformedPoints()[centerIndex], 1.0, 8.0);
+    const qreal relaxedDistance = kisDistance(worker.transformedPoints()[centerIndex], affinePosition);
+
+    QVERIFY(relaxedDistance < corruptedDistance);
+}
+
 void KisLiquifyTransformWorkerTest::testPointsQImage()
 {
     TestUtil::TestProgressBar bar;
diff --git a/libs/image/tests/kis_liquify_transform_worker_test.h b/libs/image/tests/kis_liquify_transform_worker_test.h
index 22543d74632..64392d6c60c 100644
--- a/libs/image/tests/kis_liquify_transform_worker_test.h
+++ b/libs/image/tests/kis_liquify_transform_worker_test.h
@@ -14,6 +14,8 @@ class KisLiquifyTransformWorkerTest : public QObject
     Q_OBJECT
 private Q_SLOTS:
     void testPoints();
+    void testRelaxPoints();
+    void testAffineRelaxPoints();
     void testPointsQImage();
     void testIdentityTransform();
 
diff --git a/plugins/tools/tool_transform2/kis_liquify_paintop.cpp b/plugins/tools/tool_transform2/kis_liquify_paintop.cpp
index 3d2fcad239c..fcd906901c2 100644
--- a/plugins/tools/tool_transform2/kis_liquify_paintop.cpp
+++ b/plugins/tools/tool_transform2/kis_liquify_paintop.cpp
@@ -49,6 +49,8 @@ QPainterPath KisLiquifyPaintop::brushOutline(const KisLiquifyProperties &props,
     switch (props.mode()) {
     case KisLiquifyProperties::MOVE:
     case KisLiquifyProperties::SCALE:
+    case KisLiquifyProperties::RELAX_SIMPLE:
+    case KisLiquifyProperties::RELAX_AFFINE:
         break;
     case KisLiquifyProperties::ROTATE: {
         QPainterPath p;
@@ -132,8 +134,7 @@ KisSpacingInformation KisLiquifyPaintop::paintAt(const KisPaintInformation &pi)
     const qreal spacing = m_d->props.spacing() * size;
 
     const qreal reverseCoeff =
-        m_d->props.mode() !=
-        KisLiquifyProperties::UNDO &&
+        KisLiquifyProperties::supportsReverseDirection(m_d->props.mode()) &&
         m_d->props.reverseDirection() ? -1.0 : 1.0;
     const qreal amount = m_d->props.amountHasPressure() ?
         pi.pressure() * reverseCoeff * m_d->props.amount():
@@ -173,6 +174,18 @@ KisSpacingInformation KisLiquifyPaintop::paintAt(const KisPaintInformation &pi)
                                 amount,
                                 size);
 
+        break;
+    case KisLiquifyProperties::RELAX_SIMPLE:
+        m_d->worker->relaxPoints(pi.pos(),
+                                 amount,
+                                 size);
+
+        break;
+    case KisLiquifyProperties::RELAX_AFFINE:
+        m_d->worker->affineRelaxPoints(pi.pos(),
+                                       amount,
+                                       size);
+
         break;
     case KisLiquifyProperties::N_MODES:
         qFatal("Not supported mode");
diff --git a/plugins/tools/tool_transform2/kis_liquify_properties.cpp b/plugins/tools/tool_transform2/kis_liquify_properties.cpp
index 5a118cf6ae6..c0f98575b1b 100644
--- a/plugins/tools/tool_transform2/kis_liquify_properties.cpp
+++ b/plugins/tools/tool_transform2/kis_liquify_properties.cpp
@@ -57,6 +57,20 @@ bool KisLiquifyProperties::operator==(const KisLiquifyProperties &other) const
         m_flow == other.m_flow;
 }
 
+bool KisLiquifyProperties::supportsReverseDirection(LiquifyMode mode)
+{
+    return mode != UNDO &&
+        mode != RELAX_SIMPLE &&
+        mode != RELAX_AFFINE;
+}
+
+bool KisLiquifyProperties::supportsWashMode(LiquifyMode mode)
+{
+    return mode != UNDO &&
+        mode != RELAX_SIMPLE &&
+        mode != RELAX_AFFINE;
+}
+
 QString liquifyModeString(KisLiquifyProperties::LiquifyMode mode)
 {
     QString result;
@@ -77,6 +91,12 @@ QString liquifyModeString(KisLiquifyProperties::LiquifyMode mode)
     case KisLiquifyProperties::UNDO:
         result = "Undo";
         break;
+    case KisLiquifyProperties::RELAX_SIMPLE:
+        result = "RelaxSimple";
+        break;
+    case KisLiquifyProperties::RELAX_AFFINE:
+        result = "RelaxAffine";
+        break;
     case KisLiquifyProperties::N_MODES:
         qFatal("Unsupported mode");
     }
@@ -84,6 +104,26 @@ QString liquifyModeString(KisLiquifyProperties::LiquifyMode mode)
     return QString("LiquifyTool/%1").arg(result);
 }
 
+qreal defaultAmountForMode(KisLiquifyProperties::LiquifyMode mode)
+{
+    switch (mode) {
+    case KisLiquifyProperties::RELAX_SIMPLE:
+        return 0.3;
+    case KisLiquifyProperties::RELAX_AFFINE:
+        return 1.0;
+    case KisLiquifyProperties::MOVE:
+    case KisLiquifyProperties::SCALE:
+    case KisLiquifyProperties::ROTATE:
+    case KisLiquifyProperties::OFFSET:
+    case KisLiquifyProperties::UNDO:
+        return 0.05;
+    case KisLiquifyProperties::N_MODES:
+        qFatal("Unsupported mode");
+    }
+
+    return 0.05;
+}
+
 void KisLiquifyProperties::saveMode() const
 {
     KConfigGroup cfg =
@@ -108,7 +148,7 @@ void KisLiquifyProperties::loadMode()
          KSharedConfig::openConfig()->group(liquifyModeString(m_mode));
 
     m_size = cfg.readEntry("size", m_size);
-    m_amount = cfg.readEntry("amount", m_amount);
+    m_amount = cfg.readEntry("amount", defaultAmountForMode(m_mode));
     m_spacing = cfg.readEntry("spacing", m_spacing);
     m_sizeHasPressure = cfg.readEntry("sizeHasPressure", m_sizeHasPressure);
     m_amountHasPressure = cfg.readEntry("amountHasPressure", m_amountHasPressure);
diff --git a/plugins/tools/tool_transform2/kis_liquify_properties.h b/plugins/tools/tool_transform2/kis_liquify_properties.h
index 5e3a2b15b83..d14c98bb74a 100644
--- a/plugins/tools/tool_transform2/kis_liquify_properties.h
+++ b/plugins/tools/tool_transform2/kis_liquify_properties.h
@@ -24,6 +24,8 @@ public:
         ROTATE,
         OFFSET,
         UNDO,
+        RELAX_SIMPLE,
+        RELAX_AFFINE,
 
         N_MODES
     };
@@ -55,6 +57,9 @@ public:
         m_mode = value;
     }
 
+    static bool supportsReverseDirection(LiquifyMode mode);
+    static bool supportsWashMode(LiquifyMode mode);
+
     qreal size() const {
         return m_size;
     }
diff --git a/plugins/tools/tool_transform2/kis_tool_transform_config_widget.cpp b/plugins/tools/tool_transform2/kis_tool_transform_config_widget.cpp
index 9a1183f5c39..a70dbb801d3 100644
--- a/plugins/tools/tool_transform2/kis_tool_transform_config_widget.cpp
+++ b/plugins/tools/tool_transform2/kis_tool_transform_config_widget.cpp
@@ -265,11 +265,15 @@ KisToolTransformConfigWidget::KisToolTransformConfigWidget(TransformTransactionP
     connect(liquifyRotate, SIGNAL(toggled(bool)), liquifyModeMapper, SLOT(map()));
     connect(liquifyOffset, SIGNAL(toggled(bool)), liquifyModeMapper, SLOT(map()));
     connect(liquifyUndo, SIGNAL(toggled(bool)), liquifyModeMapper, SLOT(map()));
+    connect(liquifyRelaxSimple, SIGNAL(toggled(bool)), liquifyModeMapper, SLOT(map()));
+    connect(liquifyRelaxAffine, SIGNAL(toggled(bool)), liquifyModeMapper, SLOT(map()));
     liquifyModeMapper->setMapping(liquifyMove, (int)KisLiquifyProperties::MOVE);
     liquifyModeMapper->setMapping(liquifyScale, (int)KisLiquifyProperties::SCALE);
     liquifyModeMapper->setMapping(liquifyRotate, (int)KisLiquifyProperties::ROTATE);
     liquifyModeMapper->setMapping(liquifyOffset, (int)KisLiquifyProperties::OFFSET);
     liquifyModeMapper->setMapping(liquifyUndo, (int)KisLiquifyProperties::UNDO);
+    liquifyModeMapper->setMapping(liquifyRelaxSimple, (int)KisLiquifyProperties::RELAX_SIMPLE);
+    liquifyModeMapper->setMapping(liquifyRelaxAffine, (int)KisLiquifyProperties::RELAX_AFFINE);
     connect(liquifyModeMapper, SIGNAL(mapped(int)), SLOT(slotLiquifyModeChanged(int)));
     connect(liquifyModeMapper, SIGNAL(mapped(int)), SLOT(notifyEditingFinished()));
 
@@ -278,6 +282,14 @@ KisToolTransformConfigWidget::KisToolTransformConfigWidget(TransformTransactionP
     liquifyRotate->setToolTip(i18nc("@info:tooltip", "Rotate: twirl image under cursor"));
     liquifyOffset->setToolTip(i18nc("@info:tooltip", "Offset: shift the image to the right of the stroke direction"));
     liquifyUndo->setToolTip(i18nc("@info:tooltip", "Undo: erase actions of other tools"));
+    liquifyRelaxSimple->setToolTip(i18nc("@info:tooltip",
+                                          "Relax: smooth liquify distortions by averaging neighboring movements\n"
+                                          "Preserves: local average movement\n"
+                                          "Removes: local movement differences, bumps and jitter"));
+    liquifyRelaxAffine->setToolTip(i18nc("@info:tooltip",
+                                          "Shape-preserving Relax: smooth liquify distortions while preserving local rotation, scale and shear\n"
+                                          "Preserves: local translation, rotation, scale and shear\n"
+                                          "Removes: local non-affine distortions, pinches and swirls"));
 
     // Connect all edit boxes to the Editing Finished signal
     connect(densityBox, SIGNAL(editingFinished()), this, SLOT(notifyEditingFinished()));
@@ -350,6 +362,8 @@ void KisToolTransformConfigWidget::slotUpdateIcons()
     liquifyRotate->setIcon(KisIconUtils::loadIcon("transform_icons_liquify_rotate"));
     liquifyOffset->setIcon(KisIconUtils::loadIcon("transform_icons_liquify_offset"));
     liquifyUndo->setIcon(KisIconUtils::loadIcon("transform_icons_liquify_erase"));
+    liquifyRelaxSimple->setIcon(KisIconUtils::loadIcon("transform_icons_liquify_relax"));
+    liquifyRelaxAffine->setIcon(KisIconUtils::loadIcon("transform_icons_liquify_relax_affine"));
 
 
 
@@ -394,10 +408,8 @@ void KisToolTransformConfigWidget::updateLiquifyControls()
     KisLiquifyProperties::LiquifyMode mode =
         static_cast<KisLiquifyProperties::LiquifyMode>(props->mode());
 
-    bool canInverseDirection =
-        mode != KisLiquifyProperties::UNDO;
-
-    bool canUseWashMode = mode != KisLiquifyProperties::UNDO;
+    bool canInverseDirection = KisLiquifyProperties::supportsReverseDirection(mode);
+    bool canUseWashMode = KisLiquifyProperties::supportsWashMode(mode);
 
     liquifyReverseDirectionChk->setEnabled(canInverseDirection);
     liquifyFlowSlider->setEnabled(canUseWashMode && useWashMode);
@@ -657,6 +669,12 @@ void KisToolTransformConfigWidget::updateConfig(const ToolTransformArgs &config)
         case KisLiquifyProperties::UNDO:
             liquifyUndo->setChecked(true);
             break;
+        case KisLiquifyProperties::RELAX_SIMPLE:
+            liquifyRelaxSimple->setChecked(true);
+            break;
+        case KisLiquifyProperties::RELAX_AFFINE:
+            liquifyRelaxAffine->setChecked(true);
+            break;
         case KisLiquifyProperties::N_MODES:
             qFatal("Unsupported mode");
         }
diff --git a/plugins/tools/tool_transform2/wdg_tool_transform.ui b/plugins/tools/tool_transform2/wdg_tool_transform.ui
index 4dbce43450a..4247aafcbea 100644
--- a/plugins/tools/tool_transform2/wdg_tool_transform.ui
+++ b/plugins/tools/tool_transform2/wdg_tool_transform.ui
@@ -1940,6 +1940,50 @@
               </property>
              </widget>
             </item>
+            <item>
+             <widget class="QToolButton" name="liquifyRelaxSimple">
+              <property name="sizePolicy">
+               <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+                <horstretch>0</horstretch>
+                <verstretch>0</verstretch>
+               </sizepolicy>
+              </property>
+              <property name="text">
+               <string>Relax</string>
+              </property>
+              <property name="checkable">
+               <bool>true</bool>
+              </property>
+              <property name="autoExclusive">
+               <bool>true</bool>
+              </property>
+              <property name="autoRaise">
+               <bool>true</bool>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QToolButton" name="liquifyRelaxAffine">
+              <property name="sizePolicy">
+               <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+                <horstretch>0</horstretch>
+                <verstretch>0</verstretch>
+               </sizepolicy>
+              </property>
+              <property name="text">
+               <string>Affine Relax</string>
+              </property>
+              <property name="checkable">
+               <bool>true</bool>
+              </property>
+              <property name="autoExclusive">
+               <bool>true</bool>
+              </property>
+              <property name="autoRaise">
+               <bool>true</bool>
+              </property>
+             </widget>
+            </item>
            </layout>
           </widget>
          </item>
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.