Liquify keeps pixels sharp, Smudge without ghost copies, Blur's own Radius, the largest Smear commits (13.16, part one)
This commit is contained in:
1 parent
b50657bcdb
commit
cbd8612014
15 files changed
+543
-51
No files matched your search
@@ -11,8 +11,8 @@ std::optional<QImage> EditorSession::blurSample(const CanvasDocument &document,
|
||||
const std::optional<ImageLayer> 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<QString> 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);
|
||||
|
||||
|
||||
@@ -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<QPointF> &points = warp->points();
|
||||
std::optional<QPointF> 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);
|
||||
|
||||
@@ -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<QPointF> m_last;
|
||||
// Smudge: the colour the brush carries, a (2r+1)² RGBA square.
|
||||
std::vector<float> m_carried;
|
||||
// Liquify: the layer as found, and each pixel's source offset.
|
||||
QImage m_original;
|
||||
std::vector<float> m_offsets;
|
||||
// Liquify: the dab's offsets as they were before it.
|
||||
std::vector<float> m_scratch;
|
||||
};
|
||||
@@ -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<QWidget *>{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<QWidget *>{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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
+10
-9
@@ -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)
|
||||
|
||||
@@ -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<QLineEdit>(bar, "blurRadius");
|
||||
auto &slider = find<QSlider>(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<QLabel *>())
|
||||
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"
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "SessionFixtures.h"
|
||||
#include <cmath>
|
||||
|
||||
// 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<BlurToolMode>("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<QString>());
|
||||
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"
|
||||
@@ -5,7 +5,7 @@
|
||||
#include "SessionFixtures.h"
|
||||
#include <cmath>
|
||||
|
||||
// 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<float> carried, scratch;
|
||||
std::optional<QPointF> last;
|
||||
// Liquify, as MetalWarp's shader: the untouched pixels and offsets.
|
||||
std::vector<uchar> original;
|
||||
std::vector<float> 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<int>{255, 0, 0, 255}));
|
||||
const std::vector<int> 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<int>{233, 0, 22, 255}));
|
||||
QCOMPARE(exported(session, 45, 12), (std::vector<int>{94, 0, 161, 255}));
|
||||
QCOMPARE(exported(session, 49, 12), (std::vector<int>{9, 0, 246, 255}));
|
||||
// Its flanks less so; the rim not at all.
|
||||
QCOMPARE(exported(session, 42, 15), (std::vector<int>{61, 0, 194, 255}));
|
||||
QCOMPARE(exported(session, 49, 15), (std::vector<int>{0, 0, 255, 255}));
|
||||
QCOMPARE(exported(session, 75, 35), (std::vector<int>{0, 0, 255, 255}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "Document/SmudgeLiquify.h"
|
||||
#include <cmath>
|
||||
|
||||
// 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<int>{200, 0, 0, 255}));
|
||||
QCOMPARE(pixel(stroke.image(), 21, 10), (std::vector<int>{0, 0, 0, 255}));
|
||||
QCOMPARE(pixel(stroke.image(), 23, 10), (std::vector<int>{0, 0, 0, 255}));
|
||||
QCOMPARE(pixel(stroke.image(), 40, 10), (std::vector<int>{0, 0, 0, 255}));
|
||||
QCOMPARE(pixel(stroke.image(), 41, 10), (std::vector<int>{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<int> 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"
|
||||
Reference in new issue
Block a user