Files
OmaPhoto/tests/LayerEffectsRendererTests.cpp
T

244 lines
12 KiB
C++

#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
#include "IO/ImageExporter.h"
#include "Rendering/LayerEffectsKernel.h"
#include "SessionFixtures.h"
#include <QPainter>
#include <QtTest>
#include <cmath>
// Swift's LayerEffectsRenderer and the effects in the export.
namespace {
QImage filled(int width, int height, const QColor &colour)
{
QImage image = BrushRaster::context(width, height, false);
image.fill(colour);
return image;
}
LayerEffects stroke(double size, bool inside = false)
{
LayerEffects effects;
effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1, .inside = inside};
return effects;
}
std::array<int, 4> pixel(const QImage &image, int x, int y)
{
const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
const uchar *at = bytes.constScanLine(y) + x * 4;
return {at[0], at[1], at[2], at[3]};
}
QImage exported(const EditorSession &session)
{
return ImageExporter::render(session.projectSnapshot().value()).image;
}
}
class LayerEffectsRendererTests : public QObject {
Q_OBJECT
private slots:
void theMarginHoldsWhatReachesOut();
void thePlacementGrowsAboutTheCentre();
void renderingPadsMasksThenRunsTheKernel();
void theCacheKeepsEightWithinItsBudget();
void theExportAndTheCompositeDrawTheEffects();
};
void LayerEffectsRendererTests::theMarginHoldsWhatReachesOut()
{
QCOMPARE(LayerEffectsRenderer::margin(LayerEffects()), 2.0);
QCOMPARE(LayerEffectsRenderer::margin(stroke(4.2)), 7.0);
// An inside stroke, overlay or inner shadow reach nowhere.
LayerEffects within = stroke(40, true);
within.colorOverlay = ColorOverlayEffect();
within.innerShadow = InnerShadowEffect{.distance = 90, .blur = 90};
QCOMPARE(LayerEffectsRenderer::margin(within), 2.0);
// A shadow's distance and three blurs; the larger reach wins.
LayerEffects cast = stroke(10);
cast.shadow = ShadowEffect{.distance = 5, .blur = 2.5};
QCOMPARE(LayerEffectsRenderer::margin(cast), 15.0);
cast.shadow.value().blur = 4;
QCOMPARE(LayerEffectsRenderer::margin(cast), 19.0);
// Switched off, it takes no room.
cast.shadow.value().enabled = false;
cast.stroke.value().enabled = false;
QCOMPARE(LayerEffectsRenderer::margin(cast), 2.0);
}
void LayerEffectsRendererTests::thePlacementGrowsAboutTheCentre()
{
const LayerTransform transform{.origin = QPointF(10, 20), .size = QSizeF(40, 20), .rotation = 30, .flipX = true, .sampling = LayerSampling::nearest};
const LayerTransform grown = LayerEffectsRenderer::placed(transform, QImage(60, 30, QImage::Format_Alpha8), 5);
// The 50 by 20 inner pixels keep the layer's box.
QCOMPARE(grown.size, QSizeF(48, 30));
QCOMPARE(grown.center(), transform.center());
QVERIFY(grown.rotation == 30 && grown.flipX && !grown.flipY && grown.sampling == LayerSampling::nearest);
// No pixels inside the margin: the transform as it was.
QVERIFY(LayerEffectsRenderer::placed(transform, QImage(10, 30, QImage::Format_Alpha8), 5) == transform);
QVERIFY(LayerEffectsRenderer::placed(transform, QImage(60, 10, QImage::Format_Alpha8), 5) == transform);
}
void LayerEffectsRendererTests::renderingPadsMasksThenRunsTheKernel()
{
const QImage image = filled(20, 10, QColor(0, 0, 255));
const LayerEffectsRenderer::Rendered made = LayerEffectsRenderer::render(image, std::nullopt, stroke(3));
QCOMPARE(made.inset, 5.0);
QCOMPARE(made.image.size(), QSize(30, 20));
// The kernel over the pixels padded by the inset.
QImage padded = filled(30, 20, Qt::transparent);
{
QPainter painter(&padded);
painter.drawImage(QRectF(5, 5, 20, 10), image);
}
QVERIFY(made.image == LayerEffectsKernel::render(padded, stroke(3)));
QVERIFY((pixel(made.image, 3, 10) == std::array{255, 255, 0, 255}) && (pixel(made.image, 1, 10)[3] == 0));
// A mask hides pixels first: the stroke follows what shows.
QImage mask(20, 10, QImage::Format_Grayscale8);
mask.fill(0);
for (int y = 0; y < 10; ++y)
std::fill(mask.scanLine(y), mask.scanLine(y) + 10, uchar(255));
const LayerEffectsRenderer::Rendered masked = LayerEffectsRenderer::render(image, mask, stroke(3));
QVERIFY((pixel(masked.image, 10, 10) == std::array{0, 0, 255, 255}) && (pixel(masked.image, 20, 10) == std::array{0, 0, 0, 0}));
QVERIFY((pixel(masked.image, 16, 10) == std::array{255, 255, 0, 255}) && (pixel(masked.image, 18, 10)[3] == 0));
// A small mask scales smoothly, as CoreGraphics clips.
QImage halves(2, 1, QImage::Format_Grayscale8);
halves.scanLine(0)[0] = 0;
halves.scanLine(0)[1] = 255;
const QImage soft = LayerEffectsRenderer::render(filled(64, 16, QColor(0, 0, 255)), halves, stroke(0)).image;
const int left = pixel(soft, 2 + 31, 10)[3], right = pixel(soft, 2 + 32, 10)[3];
QVERIFY2(left > 100 && left < right && right < 160, qPrintable(QStringLiteral("%1 %2").arg(left).arg(right)));
QVERIFY(pixel(soft, 2, 10)[3] == 0 && pixel(soft, 65, 10)[3] == 255);
// Switched-off effects are none; invalid or vast ones refuse.
LayerEffects hidden = stroke(3);
hidden.stroke.value().enabled = false;
QCOMPARE(LayerEffectsRenderer::render(image, std::nullopt, hidden).image.size(), QSize(24, 14));
// A switched-off effect is not judged.
LayerEffects wild = stroke(600);
wild.stroke.value().enabled = false;
QVERIFY(LayerEffectsRenderer::render(image, std::nullopt, wild).image == LayerEffectsRenderer::render(image, std::nullopt, hidden).image);
try {
LayerEffectsRenderer::render(image, std::nullopt, stroke(501));
QFAIL("an invalid stroke rendered");
} catch (const ProjectError &error) {
QCOMPARE(error.kind, ProjectError::Kind::invalid);
}
LayerEffects shadow;
shadow.shadow = ShadowEffect{.distance = 10};
try {
LayerEffectsRenderer::render(QImage(14'140, 14'140, QImage::Format_Mono), std::nullopt, shadow);
QFAIL("a vast layer rendered");
} catch (const ProjectError &error) {
QCOMPARE(error.kind, ProjectError::Kind::tooLarge);
}
}
void LayerEffectsRendererTests::theCacheKeepsEightWithinItsBudget()
{
const QImage image = filled(12, 8, Qt::red);
// Nothing to draw, nothing made: the layer draws as is.
QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, std::nullopt));
QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, LayerEffects()));
LayerEffects hidden = stroke(2);
hidden.stroke.value().enabled = false;
QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, hidden));
QVERIFY(!LayerEffectsRenderer::cached(image, std::nullopt, stroke(600)));
// The same pixels, mask and effects: the same result.
const auto first = LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value();
QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey(), first.image.cacheKey());
QCOMPARE(first.inset, 4.0);
// Switched-off effects are left out of the key.
LayerEffects extra = stroke(2);
extra.shadow = ShadowEffect{.enabled = false};
QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, extra).value().image.cacheKey(), first.image.cacheKey());
// Equal pixels elsewhere, or another mask: made anew.
const QImage twin = image.copy();
QVERIFY(LayerEffectsRenderer::cached(twin, std::nullopt, stroke(2)).value().image.cacheKey() != first.image.cacheKey());
QImage mask(12, 8, QImage::Format_Grayscale8);
mask.fill(255);
const auto masked = LayerEffectsRenderer::cached(image, mask, stroke(2)).value();
QVERIFY(masked.image.cacheKey() != first.image.cacheKey() && masked.image == first.image);
QCOMPARE(LayerEffectsRenderer::cached(image, mask, stroke(2)).value().image.cacheKey(), masked.image.cacheKey());
// Another mask of the same size is another key.
QImage half = mask.copy();
half.fill(128);
QVERIFY(LayerEffectsRenderer::cached(image, half, stroke(2)).value().image.cacheKey() != masked.image.cacheKey());
// Eight are kept: the ninth pushes the first out.
for (int size = 3; size <= 6; ++size)
LayerEffectsRenderer::cached(image, std::nullopt, stroke(size));
QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey(), first.image.cacheKey());
LayerEffectsRenderer::cached(image, std::nullopt, stroke(7));
QVERIFY(LayerEffectsRenderer::cached(image, std::nullopt, stroke(2)).value().image.cacheKey() != first.image.cacheKey());
// Two 32 MiB entries fill it; a third evicts one.
const QImage one = filled(2'000, 2'000, Qt::red), two = filled(2'000, 2'000, Qt::green), three = filled(2'000, 2'000, Qt::blue);
const qint64 kept = LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey();
const qint64 second = LayerEffectsRenderer::cached(two, std::nullopt, stroke(1)).value().image.cacheKey();
QCOMPARE(LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey(), kept);
LayerEffectsRenderer::cached(three, std::nullopt, stroke(1));
QCOMPARE(LayerEffectsRenderer::cached(two, std::nullopt, stroke(1)).value().image.cacheKey(), second);
QVERIFY(LayerEffectsRenderer::cached(one, std::nullopt, stroke(1)).value().image.cacheKey() != kept);
// Past 64 MiB: made, not kept, nothing evicted.
const qint64 small = LayerEffectsRenderer::cached(image, std::nullopt, stroke(5)).value().image.cacheKey();
const QImage big = filled(3'000, 3'000, Qt::red);
const auto large = LayerEffectsRenderer::cached(big, std::nullopt, stroke(1)).value();
QVERIFY(LayerEffectsRenderer::cached(big, std::nullopt, stroke(1)).value().image.cacheKey() != large.image.cacheKey());
QCOMPARE(LayerEffectsRenderer::cached(image, std::nullopt, stroke(5)).value().image.cacheKey(), small);
// Too large to make: the layer draws bare.
LayerEffects shadow;
shadow.shadow = ShadowEffect{.distance = 10};
QVERIFY(!LayerEffectsRenderer::cached(QImage(14'140, 14'140, QImage::Format_Mono), std::nullopt, shadow));
}
void LayerEffectsRendererTests::theExportAndTheCompositeDrawTheEffects()
{
EditorSession session;
session.createDocument(40, 30);
session.insert(ImportedImage(filled(10, 10, QColor(0, 0, 255)), QImage(), QStringLiteral("Square")), QPointF(20, 15));
LayerEffects effects = stroke(2);
effects.shadow = ShadowEffect{.angle = 90, .distance = 6, .blur = 0, .red = 1, .green = 0, .blue = 0, .opacity = 1};
session.setEffects(effects);
const QImage image = exported(session);
// Square 15..25 by 10..20; stroke two out; shadow six down.
QVERIFY((pixel(image, 20, 15) == std::array{0, 0, 255, 255}) && (pixel(image, 13, 15) == std::array{255, 255, 0, 255}));
QVERIFY((pixel(image, 20, 24) == std::array{255, 0, 0, 255}) && (pixel(image, 20, 8) == std::array{255, 255, 0, 255}));
QVERIFY(pixel(image, 20, 7)[3] == 0 && pixel(image, 12, 15)[3] == 0 && pixel(image, 20, 27)[3] == 0);
// A mask hides pixels, the effects follow; opacity fades all.
session.addLayerMask(false);
QVERIFY(pixel(exported(session), 13, 15)[3] == 0 && pixel(exported(session), 20, 24)[3] == 0);
session.deleteLayerMask();
session.setLayerOpacity(0.5);
QVERIFY(std::abs(pixel(exported(session), 13, 15)[3] - 128) <= 1);
// Its blend mode applies to the effects too.
session.setLayerOpacity(1);
const QUuid square = session.activeLayerID().value();
session.insert(ImportedImage(filled(40, 30, QColor(128, 128, 128)), QImage(), QStringLiteral("Gray")));
session.moveActiveLayer(-1);
session.selectLayer(square);
session.setLayerBlendMode(LayerBlendMode::multiply);
QVERIFY((pixel(exported(session), 13, 15) == std::array{128, 128, 0, 255}));
// A folder's mask clips the effects inside it.
session.groupSelectedLayers();
session.addLayerMask(false);
QVERIFY((pixel(exported(session), 13, 15) == std::array{128, 128, 128, 255}));
// The composite draws what the export does.
QImage live = BrushRaster::context(40, 30, false);
live.fill(0);
{
QPainter painter(&live);
session.drawLiveComposite(session.document().value(), painter);
}
QVERIFY(live == exported(session));
session.deleteLayerMask();
live.fill(0);
{
QPainter painter(&live);
session.drawLiveComposite(session.document().value(), painter);
}
QVERIFY(live == exported(session) && pixel(live, 13, 15) == (std::array{128, 128, 0, 255}));
}
QTEST_GUILESS_MAIN(LayerEffectsRendererTests)
#include "LayerEffectsRendererTests.moc"