Files
OmaPhoto/tests/TiledLayerTests.cpp
雷电芽衣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

359 lines
19 KiB
C++

#include "Rendering/TiledLayerRenderer.h"
#include "RenderFixtures.h"
#include <QtTest>
class TiledLayerTests : public QObject {
Q_OBJECT
private slots:
void init();
void tiledLayersDrawLikeOneImage_data();
void tiledLayersDrawLikeOneImage();
void tilesAtTheLayersEdgeKeepItsOutline_data();
void tilesAtTheLayersEdgeKeepItsOutline();
void theOutlineOpensByOneDevicePixel_data();
void theOutlineOpensByOneDevicePixel();
void piecesOpenOnlyWhereTheirImageEnds_data();
void piecesOpenOnlyWhereTheirImageEnds();
void piecesMeetingOnAPixelCentreLeaveNoGap_data();
void piecesMeetingOnAPixelCentreLeaveNoGap();
void oddSizedLayersKeepTheirOverhang();
void clippedPaintersDrawTheTilesTheyShow();
void emptyLayersDrawNothing();
void transparentTilesReplaceTheBase();
void opacityBlendModeAndMaskApplyOnce();
};
void TiledLayerTests::init()
{
QTest::failOnWarning(QRegularExpression("saved states|Unbalanced save/restore"));
}
void TiledLayerTests::tiledLayersDrawLikeOneImage_data()
{
QTest::addColumn<double>("scale");
QTest::addColumn<double>("rotation");
QTest::addColumn<bool>("flipped");
QTest::newRow("0.2x") << 0.2 << 0.0 << false;
QTest::newRow("0.7x") << 0.7 << 0.0 << false;
QTest::newRow("0.3x turned") << 0.3 << 25.0 << false;
QTest::newRow("1x") << 1.0 << 0.0 << false;
QTest::newRow("0.4x flipped") << 0.4 << 0.0 << true;
QTest::newRow("a right angle") << 0.6 << 90.0 << false;
QTest::newRow("a right angle, flipped") << 0.6 << 270.0 << true;
}
void TiledLayerTests::tiledLayersDrawLikeOneImage()
{
QFETCH(double, scale);
QFETCH(double, rotation);
QFETCH(bool, flipped);
const QImage base = noise(1600, 1000, 7);
// The second tile crosses a piece boundary at x=1024.
const std::vector<BrushPatch> committed{{QRectF(1100, 400, 256, 256), noise(256, 256, 99)},
{QRectF(900, 100, 256, 256), noise(256, 256, 42)}};
const auto raster = painted(base, committed);
const LayerTransform transform{.origin = {0, 0}, .size = {1600, 1000}, .rotation = rotation,
.flipX = flipped, .flipY = flipped, .sampling = LayerSampling::high};
// Fixed-point texture stepping: 3 measured upright on noise.
const int tolerance = rotation == 0 ? 4 : 12;
const int side = int(std::ceil(1700 * scale));
const QPointF center(side / 2.0, side / 2.0);
const QImage finished = composite(base, committed);
const QImage expected = render(side, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter, {.scale = scale}); });
const QImage drawn = render(side, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter, {.scale = scale}); });
const int difference = largestDifference(expected, drawn);
QVERIFY2(difference <= tolerance, qPrintable(QString("a committed painted layer differs by %1").arg(difference)));
QVERIFY(largestDifference(expected, render(side, [](QPainter &) {})) > 100);
QVERIFY(!raster->hasMaterializedPixels());
}
void TiledLayerTests::tilesAtTheLayersEdgeKeepItsOutline_data()
{
QTest::addColumn<double>("scale");
QTest::addColumn<double>("rotation");
QTest::addColumn<LayerSampling>("sampling");
QTest::addColumn<bool>("transparentBase");
QTest::addColumn<int>("tolerance");
QTest::newRow("0.3x turned") << 0.3 << 25.0 << LayerSampling::high << false << 12;
QTest::newRow("1x turned") << 1.0 << 25.0 << LayerSampling::smooth << false << 12;
QTest::newRow("0.7x") << 0.7 << 0.0 << LayerSampling::high << false << 4;
QTest::newRow("nearest turned") << 0.5 << 25.0 << LayerSampling::nearest << false << 12;
QTest::newRow("turned past a right angle") << 0.6 << 205.0 << LayerSampling::high << false << 12;
// Below a device pixel per step, outlines still need room.
QTest::newRow("0.002x turned, tiles alone") << 0.002 << 25.0 << LayerSampling::high << true << 12;
QTest::newRow("0.01x turned, tiles alone") << 0.01 << 25.0 << LayerSampling::high << true << 12;
// Off the halving grid, pieces keep edge spill: 10 measured.
QTest::newRow("0.05x tiles alone") << 0.05 << 0.0 << LayerSampling::high << true << 12;
QTest::newRow("0.3x turned, tiles alone") << 0.3 << 25.0 << LayerSampling::high << true << 12;
}
void TiledLayerTests::tilesAtTheLayersEdgeKeepItsOutline()
{
QFETCH(double, scale);
QFETCH(double, rotation);
QFETCH(LayerSampling, sampling);
QFETCH(bool, transparentBase);
QFETCH(int, tolerance);
// Nearest picks whole texels: a ramp keeps one-texel slips small.
const auto pixels = [&](int width, int height, int seed) {
return sampling == LayerSampling::nearest ? ramp(width, height, seed) : noise(width, height, quint32(seed));
};
const QImage base = transparentBase ? BrushRaster::context(800, 600, false) : pixels(800, 600, 7);
const std::vector<BrushPatch> corners{{QRectF(0, 0, 256, 256), pixels(256, 256, 99)},
{QRectF(544, 344, 256, 256), pixels(256, 256, 42)}};
const auto raster = painted(base, corners);
const LayerTransform transform{.origin = {0, 0}, .size = {800, 600}, .rotation = rotation, .sampling = sampling};
const int side = int(std::ceil(1100 * scale)) + 4;
const QPointF center(side / 2.0, side / 2.0);
const QImage finished = composite(base, corners);
const QImage expected = render(side, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter, {.scale = scale}); });
const QImage drawn = render(side, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter, {.scale = scale}); });
// Every pixel counts here, the antialiased outline included.
int largest = 0, slipped = 0;
for (int y = 0; y < side; ++y) {
for (int x = 0; x < side * 4; ++x) {
const int difference = std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x]);
largest = std::max(largest, difference);
slipped += difference > tolerance;
}
}
if (sampling == LayerSampling::nearest) {
// Nearest slips single pixels along tile and layer edges.
QVERIFY2(slipped < 150, qPrintable(QString("%1 samples slipped").arg(slipped)));
return;
}
QVERIFY2(largest <= tolerance, qPrintable(QString("the outline differs by %1").arg(largest)));
}
void TiledLayerTests::theOutlineOpensByOneDevicePixel_data()
{
QTest::addColumn<QSizeF>("size");
QTest::addColumn<double>("rotation");
QTest::addColumn<QPointF>("painterScale");
QTest::addColumn<QPointF>("center");
QTest::addColumn<QList<QPoint>>("outline");
QTest::addColumn<int>("alpha");
// Outline pixels left, above, right and below the layer.
QTest::newRow("squashed") << QSizeF(801.2, 153.2) << 0.0 << QPointF(0.25, 0.25) << QPointF(420, 420)
<< QList<QPoint>{{4, 90}, {30, 85}, {205, 115}, {170, 124}} << 35;
QTest::newRow("narrowed") << QSizeF(201.2, 601.2) << 0.0 << QPointF(0.25, 0.25) << QPointF(420, 420)
<< QList<QPoint>{{79, 60}, {88, 29}, {130, 150}, {122, 180}} << 34;
QTest::newRow("unequal painter scales") << QSizeF(201.2, 630) << 0.0 << QPointF(0.25, 0.01) << QPointF(420, 500)
<< QList<QPoint>{{79, 3}, {88, 1}, {130, 6}, {122, 8}} << 34;
QTest::newRow("a flat painter, turned") << QSizeF(201.2, 630) << 25.0 << QPointF(0.25, 0.01) << QPointF(420, 2000)
<< QList<QPoint>{} << 0;
QTest::newRow("a thin painter, turned") << QSizeF(630, 201.2) << 25.0 << QPointF(0.01, 0.25) << QPointF(2000, 420)
<< QList<QPoint>{} << 0;
}
void TiledLayerTests::theOutlineOpensByOneDevicePixel()
{
QFETCH(QSizeF, size);
QFETCH(double, rotation);
QFETCH(QPointF, painterScale);
QFETCH(QPointF, center);
QFETCH(QList<QPoint>, outline);
QFETCH(int, alpha);
// Both sides are whole halving steps: no edge spill.
const QImage base = BrushRaster::context(832, 640, false);
const std::vector<BrushPatch> corners{{QRectF(0, 0, 256, 256), ramp(256, 256, 99)},
{QRectF(576, 384, 256, 256), ramp(256, 256, 42)}};
const auto raster = painted(base, corners);
// Upright, every edge lies 0.35 pixels past a pixel centre.
const LayerTransform transform{.origin = {0, 0}, .size = size, .rotation = rotation, .sampling = LayerSampling::high};
const auto scaled = [&](const std::function<void(QPainter &)> &draw) {
return render(210, [&](QPainter &painter) {
painter.scale(painterScale.x(), painterScale.y());
draw(painter);
});
};
const QImage finished = composite(base, corners);
const QImage expected = scaled([&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter); });
const QImage drawn = scaled([&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter); });
for (const QPoint &point : outline) {
QCOMPARE(qAlpha(expected.pixel(point)), alpha);
QCOMPARE(qAlpha(drawn.pixel(point)), alpha);
}
int largest = 0;
for (int y = 0; y < 210; ++y) {
for (int x = 0; x < 210 * 4; ++x)
largest = std::max(largest, std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x]));
}
QVERIFY2(largest <= 4, qPrintable(QString("the outline differs by %1").arg(largest)));
}
void TiledLayerTests::piecesOpenOnlyWhereTheirImageEnds_data()
{
QTest::addColumn<bool>("across");
QTest::addColumn<bool>("patchAtTheEnd");
QTest::newRow("an inner left edge") << true << true;
QTest::newRow("an inner right edge") << true << false;
QTest::newRow("an inner top edge") << false << true;
QTest::newRow("an inner bottom edge") << false << false;
}
void TiledLayerTests::piecesOpenOnlyWhereTheirImageEnds()
{
QFETCH(bool, across);
QFETCH(bool, patchAtTheEnd);
// One device pixel spans 1440 grid pixels, past a margin.
const double length = 2304, shown = 1.6;
const auto oriented = [&](double along, double aside) { return across ? QPointF(along, aside) : QPointF(aside, along); };
const QPointF extent = oriented(length, 640);
const QImage base = noise(int(extent.x()), int(extent.y()), 5);
const std::vector<BrushPatch> patches{{QRectF(oriented(patchAtTheEnd ? length - 64 : 0, 288), QSizeF(64, 64)), noise(64, 64, 9)}};
const auto raster = painted(base, patches);
// The base owns a pixel centred 96 grid pixels inside.
const double watched = patchAtTheEnd ? 5 : 7, inside = patchAtTheEnd ? 96 : length - 96;
const double start = watched + 0.5 - inside * shown / length;
const QPointF size = oriented(shown, 8), center = oriented(start + shown / 2, 6);
const LayerTransform transform{.origin = {0, 0}, .size = {size.x(), size.y()}, .sampling = LayerSampling::high};
const QImage finished = composite(base, patches);
const QImage expected = render(12, [&](QPainter &painter) { LayerRenderer::draw(finished, transform, center, painter); });
const QImage drawn = render(12, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter); });
QCOMPARE(qAlpha(expected.pixel(oriented(watched, 6).toPoint())), 143);
int largest = 0;
for (int y = 0; y < 12; ++y) {
for (int x = 0; x < 12 * 4; ++x)
largest = std::max(largest, std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x]));
}
QVERIFY2(largest <= 4, qPrintable(QString("the far end differs by %1").arg(largest)));
}
void TiledLayerTests::piecesMeetingOnAPixelCentreLeaveNoGap_data()
{
QTest::addColumn<bool>("flipX");
QTest::addColumn<bool>("flipY");
QTest::newRow("upright") << false << false;
QTest::newRow("flipped across") << true << false;
QTest::newRow("flipped down") << false << true;
}
void TiledLayerTests::piecesMeetingOnAPixelCentreLeaveNoGap()
{
QFETCH(bool, flipX);
QFETCH(bool, flipY);
// Cells start at (1,1): halved, shared edges hit pixel centres.
const QImage base = solid(1600, 1600, qRgba(255, 0, 0, 255));
const auto raster = std::make_shared<const RasterSnapshot>(1600, 1600, base, QRectF(base.rect()),
std::vector<BrushPatch>{BrushPatch{QRectF(900, 900, 256, 256), solid(256, 256, qRgba(0, 0, 255, 255))}}, false, QPointF(1, 1));
const LayerTransform transform{.origin = {0, 0}, .size = {1600, 1600}, .flipX = flipX, .flipY = flipY, .sampling = LayerSampling::smooth};
QImage surface = BrushRaster::context(800, 800, false);
QPainter painter(&surface);
TiledLayerRenderer::drawRaster(raster, transform, QPointF(400, 400), painter, {.scale = 0.5});
painter.end();
const int left = flipX ? 800 - 578 : 450, top = flipY ? 800 - 578 : 450;
for (int y = top + 2; y < top + 126; ++y) {
for (int x = left + 2; x < left + 126; ++x)
QCOMPARE(surface.pixel(x, y), qRgba(0, 0, 255, 255));
}
}
void TiledLayerTests::oddSizedLayersKeepTheirOverhang()
{
// 605x405 at 0.4x: the last halved texel passes the grid.
const QImage base = noise(605, 405, 71);
const std::vector<BrushPatch> corner{{QRectF(349, 149, 256, 256), noise(256, 256, 72)}};
const auto raster = painted(base, corner);
const LayerTransform transform{.origin = {0, 0}, .size = {605, 405}, .sampling = LayerSampling::high};
// The grid's far edges land on whole device pixels.
const QPointF center(10 + 121, 10 + 81);
const QImage expected = render(270, [&](QPainter &painter) { LayerRenderer::draw(composite(base, corner), transform, center, painter, {.scale = 0.4}); });
const QImage drawn = render(270, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter, {.scale = 0.4}); });
// Right of and below the grid: half a texel.
for (const QPoint &past : {QPoint(252, 100), QPoint(200, 172)}) {
QCOMPARE(qAlpha(expected.pixel(past)), 51);
QCOMPARE(qAlpha(drawn.pixel(past)), 51);
}
QVERIFY(largestAnywhere(expected, drawn) <= 4);
}
void TiledLayerTests::clippedPaintersDrawTheTilesTheyShow()
{
// A stretched grid: columns and rows scale apart.
const QImage base = noise(800, 600, 73);
const std::vector<BrushPatch> committed{{QRectF(500, 300, 256, 256), noise(256, 256, 74)}};
const auto raster = painted(base, committed);
const LayerTransform transform{.origin = {0, 0}, .size = {400, 1200}, .sampling = LayerSampling::high};
const QPointF center(210, 610);
const QRect shown(270, 700, 100, 300);
const auto clipped = [&](const std::function<void(QPainter &)> &draw) {
QImage surface = BrushRaster::context(420, 1220, false);
QPainter painter(&surface);
painter.setClipRect(shown);
draw(painter);
return surface;
};
const QImage expected = clipped([&](QPainter &painter) { LayerRenderer::draw(composite(base, committed), transform, center, painter); });
const QImage drawn = clipped([&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, center, painter); });
QVERIFY(largestAnywhere(expected, drawn) <= 4);
QCOMPARE(drawn.pixel(269, 800), qRgba(0, 0, 0, 0));
QVERIFY(qAlpha(drawn.pixel(300, 800)) == 255);
}
void TiledLayerTests::emptyLayersDrawNothing()
{
const auto raster = painted(noise(64, 64, 75), {BrushPatch{QRectF(0, 0, 32, 32), noise(32, 32, 76)}});
const QImage blank = BrushRaster::context(80, 80, false);
for (const QSizeF &size : {QSizeF(0, 64), QSizeF(64, 0)}) {
const LayerTransform transform{.origin = {8, 8}, .size = size, .sampling = LayerSampling::high};
QCOMPARE(render(80, [&](QPainter &painter) { TiledLayerRenderer::drawRaster(raster, transform, QPointF(40, 40), painter); }), blank);
}
const LayerTransform transform{.origin = {8, 8}, .size = {64, 64}, .sampling = LayerSampling::high};
for (const QSize &grid : {QSize(0, 64), QSize(64, 0)}) {
QCOMPARE(render(80, [&](QPainter &painter) {
TiledLayerRenderer::drawStroke(grid.width(), grid.height(), QRectF(0, 0, 64, 64), {}, noise(64, 64, 75), nullptr, transform, QPointF(40, 40), painter);
}), blank);
}
}
void TiledLayerTests::transparentTilesReplaceTheBase()
{
const QImage base = solid(600, 400, qRgba(255, 0, 0, 255));
const auto raster = painted(base, {BrushPatch{QRectF(256, 0, 256, 256), BrushRaster::context(256, 256, false)}});
const LayerTransform transform{.origin = {0, 0}, .size = {600, 400}, .sampling = LayerSampling::nearest};
QImage surface = solid(600, 400, qRgba(0, 0, 255, 255));
QPainter painter(&surface);
TiledLayerRenderer::drawRaster(raster, transform, transform.center(), painter);
painter.end();
QCOMPARE(surface.pixel(100, 100), qRgba(255, 0, 0, 255));
QCOMPARE(surface.pixel(300, 100), qRgba(0, 0, 255, 255));
QCOMPARE(surface.pixel(511, 255), qRgba(0, 0, 255, 255));
QCOMPARE(surface.pixel(512, 100), qRgba(255, 0, 0, 255));
QCOMPARE(surface.pixel(300, 256), qRgba(255, 0, 0, 255));
}
void TiledLayerTests::opacityBlendModeAndMaskApplyOnce()
{
const QImage base = noise(700, 500, 3);
const BrushPatch patch{QRectF(256, 256, 256, 244), noise(256, 244, 11, 180)};
const auto raster = painted(base, {patch});
const QImage finished = composite(base, {patch});
QImage mask = gray(700, 500, 255);
for (int y = 0; y < 500; ++y) {
for (int x = 0; x < 700; ++x)
mask.scanLine(y)[x] = uchar((x + y) % 256);
}
const LayerTransform transform{.origin = {20, 10}, .size = {700, 500}, .sampling = LayerSampling::high};
const LayerRenderer::Options options[] = {{.opacity = 0.6}, {.blendMode = LayerBlendMode::multiply}, {.mask = mask},
{.opacity = 0.5, .blendMode = LayerBlendMode::luminosity, .mask = mask}};
for (const LayerRenderer::Options &option : options) {
QImage expected = noise(760, 540, 21), drawn = expected;
QPainter first(&expected), second(&drawn);
LayerRenderer::draw(finished, transform, transform.center(), first, option);
TiledLayerRenderer::drawRaster(raster, transform, transform.center(), second, option);
first.end();
second.end();
int largest = 0;
for (int y = 12; y < 508; ++y) {
for (int x = 22 * 4; x < 718 * 4; ++x)
largest = std::max(largest, std::abs(expected.constScanLine(y)[x] - drawn.constScanLine(y)[x]));
}
QVERIFY2(largest <= 2, qPrintable(QString("differs by %1").arg(largest)));
QVERIFY(drawn != noise(760, 540, 21));
}
}
QTEST_MAIN(TiledLayerTests)
#include "TiledLayerTests.moc"