Files
OmaPhoto/tests/BrushPreviewTests.cpp
T
雷电芽衣andClaude c864304695
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-26 00:44:52 -04:00

308 lines
15 KiB
C++

#include "Rendering/LayerRenderer.h"
#include "RenderFixtures.h"
#include <QtTest>
namespace {
QImage coverage(int width, int height, int step)
{
QImage image = gray(width, height, 0);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x)
image.scanLine(y)[x] = uchar((x * step + y) % 256);
}
return image;
}
QImage surface(const std::function<void(QPainter &)> &draw)
{
QImage result = noise(96, 96, 77);
QPainter painter(&result);
draw(painter);
return result;
}
}
class BrushPreviewTests : public QObject {
Q_OBJECT
private slots:
void init();
void tilesReplaceTheImageAndBlendOnce_data();
void tilesReplaceTheImageAndBlendOnce();
void aPaintedSourceDrawsFromItsTiles();
void paintingAMaskShowsTheSourceThroughTheTiles();
void paintPastTheSourceIsRevealed();
void edgesStayHardAndSmoothLayersInterpolate();
void aSourcePastTheGridIsCutThere_data();
void aSourcePastTheGridIsCutThere();
void aPreviewNeedsAPixelGrid();
};
void BrushPreviewTests::init()
{
QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore"));
}
void BrushPreviewTests::tilesReplaceTheImageAndBlendOnce_data()
{
QTest::addColumn<double>("scale");
QTest::addColumn<LayerBlendMode>("blendMode");
QTest::addColumn<double>("rotation");
QTest::addColumn<bool>("flipX");
QTest::addColumn<bool>("flipY");
QTest::newRow("1x") << 1.0 << LayerBlendMode::normal << 0.0 << false << false;
QTest::newRow("2x multiplied") << 2.0 << LayerBlendMode::multiply << 0.0 << false << false;
QTest::newRow("a right angle, flipped across") << 1.0 << LayerBlendMode::normal << 90.0 << true << false;
QTest::newRow("half a turn, flipped down") << 1.0 << LayerBlendMode::normal << 180.0 << false << true;
}
void BrushPreviewTests::tilesReplaceTheImageAndBlendOnce()
{
QFETCH(double, scale);
QFETCH(LayerBlendMode, blendMode);
QFETCH(double, rotation);
QFETCH(bool, flipX);
QFETCH(bool, flipY);
const QImage image = noise(40, 30, 1, 200);
// Translucent tiles that touch: a double draw would show.
const std::vector<BrushPatch> patches{{QRectF(8, 4, 16, 16), noise(16, 16, 2, 120)}, {QRectF(24, 4, 16, 16), noise(16, 16, 3, 60)}};
LayerTransform transform = placedAt({6, 5}, {40, 30});
transform.rotation = rotation;
transform.flipX = flipX;
transform.flipY = flipY;
const QPointF center(6 + 20 * scale, 25 + 15 * scale);
const LayerRenderer::Options options{.scale = scale, .opacity = 0.5, .blendMode = blendMode};
const QImage expected = surface([&](QPainter &painter) { LayerRenderer::draw(composite(image, patches), transform, center, painter, options); });
const QImage drawn = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(image, transform, center, painter, options, {.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false});
});
QCOMPARE(drawn, expected);
QVERIFY(drawn != surface([&](QPainter &painter) { LayerRenderer::draw(image, transform, center, painter, options); }));
// Without an image the tiles stand alone.
const QImage alone = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(QImage(), transform, center, painter, options, {.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false});
});
QCOMPARE(alone, surface([&](QPainter &painter) {
LayerRenderer::draw(composite(BrushRaster::context(40, 30, false), patches), transform, center, painter, options);
}));
}
void BrushPreviewTests::aPaintedSourceDrawsFromItsTiles()
{
const QImage base = noise(32, 24, 4);
const std::vector<BrushPatch> committed{{QRectF(8, 8, 16, 16), noise(16, 16, 5, 90)}};
// The raster's base covers a part of it only.
const auto raster = std::make_shared<const RasterSnapshot>(40, 30, base, QRectF(4, 3, 32, 24), committed);
const std::vector<BrushPatch> patches{{QRectF(20, 10, 16, 16), noise(16, 16, 6, 150)}};
const LayerTransform transform = placedAt({6, 5}, {40, 30});
const LayerRenderer::Options options{.opacity = 0.5};
QImage finished = BrushRaster::context(40, 30, false);
{
QPainter painter(&finished);
raster->draw(QRectF(0, 0, 40, 30), painter);
for (const BrushPatch &patch : patches)
BrushRaster::draw(patch.image, patch.rect, painter);
}
const QImage expected = surface([&](QPainter &painter) { LayerRenderer::draw(finished, transform, transform.center(), painter, options); });
const QImage drawn = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, options,
{.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false, .raster = raster});
});
QCOMPARE(drawn, expected);
QVERIFY(!raster->hasMaterializedPixels());
// A painted source may sit inside a grown grid.
const QRectF sourceRect(5, 3, 40, 30);
QImage grownFinished = BrushRaster::context(48, 36, false);
{
QPainter painter(&grownFinished);
raster->draw(sourceRect, painter);
}
const LayerTransform grown = placedAt({6, 5}, {48, 36});
const QImage placed = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(QImage(), grown, grown.center(), painter, options,
{.patches = {}, .pixelWidth = 48, .pixelHeight = 36, .paintingMask = false, .sourceRect = sourceRect, .raster = raster});
});
QCOMPARE(placed, surface([&](QPainter &painter) { LayerRenderer::draw(grownFinished, grown, grown.center(), painter, options); }));
// A cropped raster's cut-away base stays hidden.
const auto cropped = std::make_shared<const RasterSnapshot>(40, 30, base, QRectF(-4, -3, 32, 24), committed);
QImage croppedFinished = BrushRaster::context(48, 36, false);
{
QPainter painter(&croppedFinished);
cropped->draw(sourceRect, painter);
}
const QImage hidden = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(QImage(), grown, grown.center(), painter, options,
{.patches = {}, .pixelWidth = 48, .pixelHeight = 36, .paintingMask = false, .sourceRect = sourceRect, .raster = cropped});
});
QCOMPARE(hidden, surface([&](QPainter &painter) { LayerRenderer::draw(croppedFinished, grown, grown.center(), painter, options); }));
QCOMPARE(hidden.pixel(6 + 3, 5 + 1), noise(96, 96, 77).pixel(6 + 3, 5 + 1));
// A stand-in base replaces the raster's own.
const QImage standIn = solid(32, 24, qRgba(0, 255, 0, 255));
const QImage replaced = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {},
{.patches = {}, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = false, .raster = raster, .rasterBase = standIn});
});
QCOMPARE(replaced.pixel(6 + 5, 5 + 4), qRgba(0, 255, 0, 255));
QCOMPARE(replaced.pixel(6 + 35, 5 + 26), qRgba(0, 255, 0, 255));
QCOMPARE(replaced.pixel(6 + 3, 5 + 4), noise(96, 96, 77).pixel(6 + 3, 5 + 4));
}
void BrushPreviewTests::paintingAMaskShowsTheSourceThroughTheTiles()
{
const QImage image = noise(40, 30, 7);
const QImage oldMask = coverage(40, 30, 3);
const std::vector<BrushPatch> patches{{QRectF(8, 4, 16, 16), coverage(16, 16, 11)}};
const LayerTransform transform = placedAt({6, 5}, {40, 30});
QImage finishedMask = oldMask;
{
QPainter painter(&finishedMask);
BrushRaster::draw(patches[0].image, patches[0].rect, painter);
}
const LayerRenderer::BrushPreview preview{.patches = patches, .pixelWidth = 40, .pixelHeight = 30, .paintingMask = true};
const QImage drawn = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(image, transform, transform.center(), painter, {.opacity = 0.5, .mask = oldMask}, preview);
});
QCOMPARE(drawn, surface([&](QPainter &painter) {
LayerRenderer::draw(image, transform, transform.center(), painter, {.opacity = 0.5, .mask = finishedMask});
}));
// With no old mask the rest of the source shows.
QImage fresh = gray(40, 30, 255);
{
QPainter painter(&fresh);
BrushRaster::draw(patches[0].image, patches[0].rect, painter);
}
const QImage bare = surface([&](QPainter &painter) { LayerRenderer::drawBrushPreview(image, transform, transform.center(), painter, {}, preview); });
QCOMPARE(bare, surface([&](QPainter &painter) { LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = fresh}); }));
// No source, nothing to show.
const QImage nothing = surface([&](QPainter &painter) { LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {}, preview); });
QCOMPARE(nothing, noise(96, 96, 77));
}
void BrushPreviewTests::paintPastTheSourceIsRevealed()
{
// The old 24x20 layer sits at (10,6) in the grid.
const QImage old = noise(24, 20, 8);
const QImage oldMask = coverage(24, 20, 5);
const QRectF sourceRect(10, 6, 24, 20);
// The last tile hangs over the grid's corner.
const std::vector<BrushPatch> patches{{QRectF(26, 12, 16, 16), noise(16, 16, 9, 200)}, {QRectF(0, 0, 8, 8), noise(8, 8, 10)},
{QRectF(44, 28, 8, 8), noise(8, 8, 12)}};
QImage finished = BrushRaster::context(52, 36, false), finishedMask = gray(52, 36, 255);
{
QPainter painter(&finished), masking(&finishedMask);
BrushRaster::draw(old, sourceRect, painter);
for (const BrushPatch &patch : patches)
BrushRaster::draw(patch.image, patch.rect, painter);
BrushRaster::draw(oldMask, sourceRect, masking);
}
const LayerTransform transform = placedAt({6, 5}, {48, 32});
const LayerRenderer::Options options{.opacity = 0.7, .mask = oldMask};
const QImage drawn = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(old, transform, transform.center(), painter, options,
{.patches = patches, .pixelWidth = 48, .pixelHeight = 32, .paintingMask = false, .sourceRect = sourceRect});
});
const LayerTransform grown = placedAt({6, 5}, {52, 36});
QCOMPARE(drawn, surface([&](QPainter &painter) {
LayerRenderer::draw(finished, grown, grown.center(), painter, {.opacity = 0.7, .mask = finishedMask});
}));
}
void BrushPreviewTests::edgesStayHardAndSmoothLayersInterpolate()
{
// Antialiased tile edges would blend twice where tiles meet.
const std::vector<BrushPatch> patches{{QRectF(4, 4, 8, 8), solid(8, 8, qRgba(0, 0, 255, 255))}};
const LayerTransform turned{.origin = {20, 20}, .size = {24, 16}, .rotation = 30, .sampling = LayerSampling::smooth};
QImage target = BrushRaster::context(64, 56, false);
{
QPainter painter(&target);
LayerRenderer::drawBrushPreview(solid(24, 16, qRgba(255, 0, 0, 255)), turned, turned.center(), painter, {},
{.patches = patches, .pixelWidth = 24, .pixelHeight = 16, .paintingMask = false});
}
int opaque = 0;
for (int y = 0; y < 56; ++y) {
for (int x = 0; x < 64; ++x) {
QVERIFY(qAlpha(target.pixel(x, y)) == 0 || qAlpha(target.pixel(x, y)) == 255);
opaque += qAlpha(target.pixel(x, y)) == 255;
}
}
QVERIFY(std::abs(opaque - 24 * 16) <= 12);
// The layer's own quality resamples the pixels.
QImage steps = BrushRaster::context(2, 1, false);
steps.setPixel(0, 0, qRgba(0, 0, 0, 255));
steps.setPixel(1, 0, qRgba(255, 255, 255, 255));
const auto enlarged = [&](LayerSampling sampling) {
const LayerTransform transform{.origin = {0, 0}, .size = {2, 1}, .sampling = sampling};
QImage result = BrushRaster::context(16, 8, false);
QPainter painter(&result);
LayerRenderer::drawBrushPreview(steps, transform, QPointF(8, 4), painter, {.scale = 8},
{.patches = {}, .pixelWidth = 2, .pixelHeight = 1, .paintingMask = false});
return result;
};
QCOMPARE(enlarged(LayerSampling::nearest).pixel(7, 4), qRgba(0, 0, 0, 255));
QCOMPARE(enlarged(LayerSampling::nearest).pixel(8, 4), qRgba(255, 255, 255, 255));
const int blended = qRed(enlarged(LayerSampling::smooth).pixel(7, 4));
QVERIFY2(blended > 90 && blended < 130, qPrintable(QString::number(blended)));
}
void BrushPreviewTests::aSourcePastTheGridIsCutThere_data()
{
QTest::addColumn<bool>("paintedSource");
QTest::newRow("an image") << false;
QTest::newRow("a painted source") << true;
}
void BrushPreviewTests::aSourcePastTheGridIsCutThere()
{
QFETCH(bool, paintedSource);
// The source passes the grid; a tile overhangs its right.
const QImage image = noise(40, 30, 13);
const auto raster = std::make_shared<const RasterSnapshot>(40, 30, image, QRectF(0, 0, 40, 30),
std::vector<BrushPatch>{BrushPatch{QRectF(20, 10, 16, 16), noise(16, 16, 15, 90)}});
const QRectF sourceRect(-6, -4, 40, 30);
const std::vector<BrushPatch> patches{{QRectF(28, 0, 8, 8), noise(8, 8, 14)}};
QImage finished = BrushRaster::context(36, 24, false);
{
QPainter painter(&finished);
painter.setClipRect(QRectF(0, 0, 32, 24));
if (paintedSource)
raster->draw(sourceRect, painter);
else
BrushRaster::draw(image, sourceRect, painter);
painter.setClipping(false);
BrushRaster::draw(patches[0].image, patches[0].rect, painter);
}
const LayerTransform transform = placedAt({20, 16}, {32, 24}), wide = placedAt({20, 16}, {36, 24});
const QImage drawn = surface([&](QPainter &painter) {
LayerRenderer::drawBrushPreview(paintedSource ? QImage() : image, transform, transform.center(), painter, {.opacity = 0.5},
{.patches = patches, .pixelWidth = 32, .pixelHeight = 24, .paintingMask = false, .sourceRect = sourceRect,
.raster = paintedSource ? raster : nullptr});
});
QCOMPARE(drawn, surface([&](QPainter &painter) { LayerRenderer::draw(finished, wide, wide.center(), painter, {.opacity = 0.5}); }));
// Left of the grid, and right below the tile: untouched.
QCOMPARE(drawn.pixel(17, 20), noise(96, 96, 77).pixel(17, 20));
QCOMPARE(drawn.pixel(20 + 33, 16 + 12), noise(96, 96, 77).pixel(20 + 33, 16 + 12));
QVERIFY(drawn.pixel(20 + 30, 16 + 12) != noise(96, 96, 77).pixel(20 + 30, 16 + 12));
}
void BrushPreviewTests::aPreviewNeedsAPixelGrid()
{
QImage target = BrushRaster::context(8, 8, false);
QPainter painter(&target);
const LayerTransform transform = placedAt({0, 0}, {8, 8});
QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {},
{.patches = {}, .pixelWidth = 0, .pixelHeight = 8, .paintingMask = false}),
std::logic_error);
QVERIFY_EXCEPTION_THROWN(LayerRenderer::drawBrushPreview(QImage(), transform, transform.center(), painter, {},
{.patches = {}, .pixelWidth = 8, .pixelHeight = 0, .paintingMask = false}),
std::logic_error);
}
QTEST_MAIN(BrushPreviewTests)
#include "BrushPreviewTests.moc"