Sharper canvas, PSD adjustments and masks, Photoshop's Saturation, Levels and Hue/Saturation on every core, a growing blur's preview kept, the hierarchy kept (13.8)
This commit is contained in:
1 parent
765e071c0c
commit
cb85b86d58
46 files changed
+1041
-186
No files matched your search
@@ -0,0 +1,124 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/HueSaturation.h"
|
||||
#include "Document/Levels.h"
|
||||
#include <QRandomGenerator>
|
||||
#include <QtTest>
|
||||
#include <mutex>
|
||||
extern "C" {
|
||||
#include "LevelsPixels.h"
|
||||
}
|
||||
|
||||
// Compositor 1.4: Levels and Hue/Saturation in bands across the cores.
|
||||
namespace {
|
||||
// Premultiplied noise, some of it partly clear.
|
||||
QImage noise(int width, int height)
|
||||
{
|
||||
QImage image = BrushRaster::context(width, height, false);
|
||||
QRandomGenerator random(7);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
uchar *row = image.scanLine(y);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const uchar alpha = uchar(random.bounded(256));
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
row[x * 4 + channel] = uchar(random.bounded(alpha + 1));
|
||||
row[x * 4 + 3] = alpha;
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
std::vector<uchar> bytes(const QImage &image)
|
||||
{
|
||||
std::vector<uchar> result;
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
result.insert(result.end(), image.constScanLine(y), image.constScanLine(y) + image.width() * 4);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class AdjustmentBandsTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void bandsCoverEveryPixelOnce();
|
||||
void aCopyKeepsTheBytes();
|
||||
void levelsInBandsMatchesOnePass();
|
||||
void hueSaturationInBandsMatchesOnePass();
|
||||
void cachedTablesFollowTheirSettings();
|
||||
};
|
||||
|
||||
void AdjustmentBandsTests::bandsCoverEveryPixelOnce()
|
||||
{
|
||||
for (const qsizetype count : {qsizetype(10), qsizetype(249'999), qsizetype(250'007), qsizetype(1'000'003)}) {
|
||||
std::vector<int> marks(size_t(count), 0);
|
||||
std::mutex lock;
|
||||
int calls = 0;
|
||||
BrushRaster::inBands(count, [&](qsizetype start, qsizetype length) {
|
||||
QVERIFY(length > 0);
|
||||
for (qsizetype index = start; index < start + length; ++index)
|
||||
++marks[size_t(index)];
|
||||
const std::lock_guard<std::mutex> held(lock);
|
||||
++calls;
|
||||
});
|
||||
QVERIFY(std::all_of(marks.begin(), marks.end(), [](int mark) { return mark == 1; }));
|
||||
// Small counts run as one band.
|
||||
QCOMPARE(calls > 1, count >= 250'000);
|
||||
}
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::aCopyKeepsTheBytes()
|
||||
{
|
||||
const QImage source = noise(37, 11);
|
||||
const QImage copied = BrushRaster::copy(source);
|
||||
QCOMPARE(copied.format(), QImage::Format_RGBA8888_Premultiplied);
|
||||
QCOMPARE(bytes(copied), bytes(source));
|
||||
QVERIFY(copied.constBits() != source.constBits());
|
||||
// Another layout is drawn into one.
|
||||
QImage argb(3, 1, QImage::Format_ARGB32);
|
||||
argb.fill(qRgba(255, 0, 0, 255));
|
||||
QCOMPARE(bytes(BrushRaster::copy(argb)), (std::vector<uchar>{255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255}));
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::levelsInBandsMatchesOnePass()
|
||||
{
|
||||
const QImage source = noise(701, 401);
|
||||
LevelsSettings settings;
|
||||
settings.ranges[0] = LevelRange{.black = 20, .gamma = 1.7, .white = 230, .outputBlack = 10, .outputWhite = 240};
|
||||
std::array<float, 3 * 256> tables;
|
||||
for (size_t channel = 0; channel < 3; ++channel) {
|
||||
for (size_t value = 0; value <= 255; ++value)
|
||||
tables[channel * 256 + value] = float(settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
|
||||
}
|
||||
QImage reference = source.copy();
|
||||
levels_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), tables.data());
|
||||
const QImage banded = LevelsFilter::run(LevelsJob{source, settings, std::nullopt, QTransform()});
|
||||
QCOMPARE(bytes(banded), bytes(reference));
|
||||
QVERIFY(bytes(banded) != bytes(source));
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::hueSaturationInBandsMatchesOnePass()
|
||||
{
|
||||
const QImage source = noise(701, 401);
|
||||
const HueSaturationSettings settings(120, 40, -10);
|
||||
const std::vector<float> cube = HueSaturationFilter::cube(settings);
|
||||
QImage reference = source.copy();
|
||||
cube_apply(reference.bits(), size_t(reference.width()) * size_t(reference.height()), cube.data(), HueSaturationFilter::dimension);
|
||||
const QImage banded = HueSaturationFilter::run(HueSaturationJob{source, settings, std::nullopt, QTransform(), false}).image;
|
||||
QCOMPARE(bytes(banded), bytes(reference));
|
||||
QVERIFY(bytes(banded) != bytes(source));
|
||||
}
|
||||
|
||||
void AdjustmentBandsTests::cachedTablesFollowTheirSettings()
|
||||
{
|
||||
// More settings than the cache holds, asked twice over.
|
||||
std::vector<std::vector<float>> first;
|
||||
for (int hue = 0; hue < 10; ++hue)
|
||||
first.push_back(HueSaturationFilter::cube(HueSaturationSettings(hue * 30)));
|
||||
for (int round = 0; round < 2; ++round) {
|
||||
for (int hue = 9; hue >= 0; --hue)
|
||||
QVERIFY(HueSaturationFilter::cube(HueSaturationSettings(hue * 30)) == first[size_t(hue)]);
|
||||
}
|
||||
QVERIFY(first[0] != first[1]);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(AdjustmentBandsTests)
|
||||
#include "AdjustmentBandsTests.moc"
|
||||
@@ -107,12 +107,31 @@ void AdjustmentSurfaceTests::pastItsPixelBudgetNothingIsDrawn()
|
||||
{
|
||||
QImage surface = BrushRaster::context(8, 8, false);
|
||||
QPainter painter(&surface);
|
||||
// The budget counts device pixels: 64 here, not 16 units.
|
||||
// 64 device pixels; past that 16 units; past that, none.
|
||||
painter.scale(2, 2);
|
||||
bool called = false;
|
||||
QTest::ignoreMessage(QtWarningMsg, "an adjustment surface passes its pixel budget: QSize(8, 8)");
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 63);
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &) { called = true; }, 0, 15);
|
||||
QVERIFY(!called);
|
||||
QSize allocated;
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &target) {
|
||||
allocated = QSize(target.device()->width(), target.device()->height());
|
||||
target.fillRect(QRectF(0, 0, 2, 4), Qt::blue);
|
||||
target.fillRect(QRectF(2, 0, 2, 4), Qt::green);
|
||||
}, 0, 63);
|
||||
QCOMPARE(allocated, QSize(4, 4));
|
||||
// A device pixel at a quarter: one pixel, not none.
|
||||
painter.save();
|
||||
painter.scale(2, 2);
|
||||
painter.setClipRect(QRectF(0, 0, 0.25, 1));
|
||||
allocated = QSize();
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &target) { allocated = QSize(target.device()->width(), target.device()->height()); }, 0, 3);
|
||||
painter.restore();
|
||||
QCOMPARE(allocated, QSize(1, 1));
|
||||
// Enlarged smoothly: the seam between blue and green blends.
|
||||
QCOMPARE(surface.pixel(0, 0), qRgba(0, 0, 255, 255));
|
||||
QCOMPARE(surface.pixel(7, 0), qRgba(0, 255, 0, 255));
|
||||
QVERIFY(qBlue(surface.pixel(4, 0)) > 0 && qBlue(surface.pixel(4, 0)) < 255);
|
||||
AdjustmentSurface::draw(painter, [&](QPainter &target) {
|
||||
called = true;
|
||||
target.fillRect(QRectF(0, 0, 4, 4), Qt::red);
|
||||
|
||||
@@ -295,6 +295,14 @@ void BlurNoiseAdjustmentTests::noiseStaysOneFieldAcrossRegions()
|
||||
QImage expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 40);
|
||||
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 4)), expected);
|
||||
// Each axis by its own scale (Compositor 1.4).
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 20, 10);
|
||||
QCOMPARE(noise.apply(gray, QRectF(10, 20, 4, 16)), expected);
|
||||
// A region under a unit wide counts as one.
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, 8, 16);
|
||||
QCOMPARE(noise.apply(gray, QRectF(1, 2, 0.5, 0.5)), expected);
|
||||
// A negative origin floors, as Swift rounds down.
|
||||
expected = gray.copy();
|
||||
noise_add_at(expected.bits(), 8, 8, size_t(expected.bytesPerLine()), 50, 0, 0, 3, -11, -21);
|
||||
|
||||
@@ -119,7 +119,8 @@ void FilterSessionTests::updatesNormalizeGrowAndDropWhatPreviewOffHides()
|
||||
session->updateFilter(FilterSettings{.radius = 1000}, true);
|
||||
QCOMPARE(settings(*session).radius, 250.0);
|
||||
QCOMPARE(session->filterEdit().value().grownMargin, 752.0);
|
||||
QVERIFY(!session->filterEdit().value().preparedPreview);
|
||||
// The last preview stays up while the grown grid renders.
|
||||
QVERIFY(session->filterEdit().value().preparedPreview);
|
||||
QVERIFY(settled(*session) && session->filterEdit().value().preparedPreview);
|
||||
// Off: the preview and the queue go at once.
|
||||
const int revision = session->brushRevision();
|
||||
|
||||
@@ -34,6 +34,8 @@ class FilterTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep();
|
||||
void growingABlurKeepsThePreviewUpUntilTheNextOne();
|
||||
void aMaskKeepsItsPlaceUnderAPreview();
|
||||
void motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal();
|
||||
void addNoiseChangesColorButNeverAlphaAndMonochromaticKeepsGrays();
|
||||
void removeDistortionBendsAboutTheCenterAndOnlyPincushionCorrectionOpensTheCorners();
|
||||
@@ -70,6 +72,56 @@ void FilterTests::gaussianBlurSoftensTheEdgesIntoRoomMadeForItAsOneUndoStep()
|
||||
QCOMPARE(alpha(27), 1);
|
||||
}
|
||||
|
||||
// The last preview stays where made until replaced.
|
||||
void FilterTests::growingABlurKeepsThePreviewUpUntilTheNextOne()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 20);
|
||||
QImage image = BrushRaster::context(40, 20, false);
|
||||
QPainter(&image).fillRect(0, 0, 20, 20, Qt::white);
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("Half")));
|
||||
const ImageLayer layer = session.activeLayer().value();
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
session.updateFilter(FilterSettings{.radius = 2}, true);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
const QImage first = session.filterEdit().value().previewImage(layer.id).value();
|
||||
const LayerTransform firstPlace = session.displayedTransform(session.activeLayer().value());
|
||||
QVERIFY(firstPlace.size.width() > 40);
|
||||
QCOMPARE(session.displayedMaskPlacement(session.activeLayer().value()), std::nullopt);
|
||||
session.updateFilter(FilterSettings{.radius = 12}, true);
|
||||
QCOMPARE(session.filterEdit().value().previewImage(layer.id).value().cacheKey(), first.cacheKey());
|
||||
QCOMPARE(session.displayedTransform(session.activeLayer().value()), firstPlace);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
const QImage second = session.filterEdit().value().previewImage(layer.id).value();
|
||||
QVERIFY(second.cacheKey() != first.cacheKey());
|
||||
QVERIFY(session.displayedTransform(session.activeLayer().value()).size.width() > firstPlace.size.width());
|
||||
session.cancelFilter();
|
||||
}
|
||||
|
||||
// A covering mask covers; on a grown preview, old bounds.
|
||||
void FilterTests::aMaskKeepsItsPlaceUnderAPreview()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(40, 20);
|
||||
QImage image = BrushRaster::context(40, 20, false);
|
||||
image.fill(Qt::white);
|
||||
session.insert(ImportedImage(image, image, QStringLiteral("White")));
|
||||
session.addLayerMask();
|
||||
const ImageLayer layer = session.activeLayer().value();
|
||||
session.selectLayerTarget(layer.id, false);
|
||||
session.beginFilter(FilterKind::addNoise);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
QVERIFY(session.filterEdit().value().previewImage(layer.id));
|
||||
QCOMPARE(session.displayedMaskPlacement(layer), std::nullopt);
|
||||
QCOMPARE(session.displayedTransform(layer), layer.transform);
|
||||
session.cancelFilter();
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
QVERIFY(QTest::qWaitFor([&] { return !session.filterEdit().value().preparing; }, 20000));
|
||||
QCOMPARE(session.displayedMaskPlacement(layer), std::optional(layer.transform));
|
||||
QVERIFY(session.displayedTransform(layer).size.width() > 40);
|
||||
session.cancelFilter();
|
||||
}
|
||||
|
||||
void FilterTests::motionBlurStreaksAlongItsAngleCounterclockwiseFromHorizontal()
|
||||
{
|
||||
const auto streak = [](double angle) {
|
||||
|
||||
@@ -71,8 +71,8 @@ private slots:
|
||||
void settingsKeepSwiftsDefaultsAndBounds();
|
||||
void nothingToChangeClosesWithoutAStep();
|
||||
void bloomGrowsItsGridAndIsTrimmed();
|
||||
void aPreviewForAnOlderGridLandsNothing();
|
||||
void aJobWaitingBehindAnotherEditTakesTheNewGrid();
|
||||
void aPreviewForAnOlderGridShowsOnThatGrid();
|
||||
void aJobWaitingBehindAnotherEditShowsOnItsGrid();
|
||||
void theVignettePickerPreviewsAndPutsBack();
|
||||
void theSheetShowsSwiftsControls();
|
||||
};
|
||||
@@ -264,28 +264,33 @@ void FinishingFilterTests::bloomGrowsItsGridAndIsTrimmed()
|
||||
QVERIFY(after.size.width() > before.size.width() && after.size.width() < before.size.width() + 2 * 74);
|
||||
}
|
||||
|
||||
void FinishingFilterTests::aPreviewForAnOlderGridLandsNothing()
|
||||
void FinishingFilterTests::aPreviewForAnOlderGridShowsOnThatGrid()
|
||||
{
|
||||
EditorSession session;
|
||||
session.createDocument(1200, 1200);
|
||||
session.insert(ImportedImage(filled(1200, 1200, QColor(90, 90, 90)), QImage(), QStringLiteral("Big")));
|
||||
session.beginFilter(FilterKind::bloomGlow);
|
||||
QTRY_VERIFY(!session.filterEdit().value().preparing);
|
||||
// Every landed preview matches the grid it shows on.
|
||||
// Every landed preview sits on the grid it came from.
|
||||
bool mismatched = false;
|
||||
int older = 0;
|
||||
QObject::connect(&session, &EditorSession::changed, &session, [&] {
|
||||
const std::optional<FilterEdit> &edit = session.filterEdit();
|
||||
if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size())
|
||||
if (!edit || !edit->preparedPreview)
|
||||
return;
|
||||
if (QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size)
|
||||
mismatched = true;
|
||||
older += edit->preparedPreview->size() != edit->previewSource.size();
|
||||
});
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 5}, true);
|
||||
const quint64 version = session.filterEdit().value().previewSourceVersion;
|
||||
const LayerTransform grid = session.filterEdit().value().grownTransform.value();
|
||||
// Wider than the grid: it grows mid-render.
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 40}, true);
|
||||
QCOMPARE(session.filterEdit().value().previewSourceVersion, version + 1);
|
||||
QVERIFY(session.filterEdit().value().grownTransform.value().size.width() > grid.size.width());
|
||||
QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing);
|
||||
QVERIFY(!mismatched);
|
||||
QVERIFY(!mismatched && older > 0);
|
||||
QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size());
|
||||
QCOMPARE(session.filterEdit().value().preparedTransform, session.filterEdit().value().grownTransform);
|
||||
// Preview off: nothing waits; the stale render ends it.
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 5}, true);
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 60}, false);
|
||||
@@ -293,7 +298,7 @@ void FinishingFilterTests::aPreviewForAnOlderGridLandsNothing()
|
||||
QVERIFY(!session.filterEdit().value().preparedPreview);
|
||||
}
|
||||
|
||||
void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid()
|
||||
void FinishingFilterTests::aJobWaitingBehindAnotherEditShowsOnItsGrid()
|
||||
{
|
||||
// The reviewer's sequence: a cancelled edit's worker still runs.
|
||||
EditorSession session;
|
||||
@@ -302,13 +307,13 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid()
|
||||
session.beginFilter(FilterKind::gaussianBlur);
|
||||
QVERIFY(session.filterEdit().value().preparing);
|
||||
session.cancelFilter();
|
||||
// Bloom's first job waits; Preview off grows its grid meanwhile.
|
||||
// Bloom's first job waits; Preview off grows the grid meanwhile.
|
||||
session.beginFilter(FilterKind::bloomGlow);
|
||||
session.updateFilter(FilterSettings{.bloomRadius = 50}, false);
|
||||
bool mismatched = false, armed = true;
|
||||
QObject::connect(&session, &EditorSession::changed, &session, [&] {
|
||||
const std::optional<FilterEdit> &edit = session.filterEdit();
|
||||
if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size())
|
||||
if (edit && edit->preparedPreview && QSizeF(edit->preparedPreview->size()) != edit->preparedTransform.value().size)
|
||||
mismatched = true;
|
||||
// The waiting job starts: Preview comes back next turn.
|
||||
if (std::exchange(armed, false))
|
||||
@@ -317,6 +322,7 @@ void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid()
|
||||
QTRY_VERIFY(!armed);
|
||||
QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing);
|
||||
QVERIFY(!mismatched);
|
||||
QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size());
|
||||
}
|
||||
|
||||
void FinishingFilterTests::theVignettePickerPreviewsAndPutsBack()
|
||||
|
||||
@@ -214,8 +214,8 @@ void GroupTests::documentHierarchyFollowsItsLayers()
|
||||
shown.parentID = open.id;
|
||||
ImageLayer top("Top", document.size());
|
||||
document.layers = {folder, child, open, shown, top};
|
||||
QCOMPARE(names(document.hierarchyEntries()),
|
||||
QStringList({"Folder:0:hidden", "Child:1:hidden", "Open:0:shown", "Shown:1:shown", "Top:0:shown"}));
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, child.id, open.id, shown.id, top.id}));
|
||||
QCOMPARE(document.hierarchy().drawn, (std::vector<QUuid>{shown.id, top.id}));
|
||||
QCOMPARE(document.effectiveVisibleIDs(), QSet<QUuid>({open.id, shown.id, top.id}));
|
||||
const std::vector<ImageLayer> rendered = document.renderLayers();
|
||||
QCOMPARE(int(rendered.size()), 2);
|
||||
|
||||
@@ -83,12 +83,12 @@ void HueSaturationFailureTests::failuresReachTheSessionsError()
|
||||
|
||||
void HueSaturationFailureTests::theFilterFailsAsAContextWhenMemoryRunsOut()
|
||||
{
|
||||
// One pixel wide: its row list asks as its pixels.
|
||||
// Its copy asks 64 MB, past the limit.
|
||||
QImage tall(1, 16'000'000, QImage::Format_RGBA8888_Premultiplied);
|
||||
tall.fill(Qt::red);
|
||||
const HueSaturationJob job{tall, HueSaturationSettings(120), std::nullopt, QTransform(), false};
|
||||
malloc_trim(0);
|
||||
const AddressSpaceLimit limit(80ll * 1024 * 1024);
|
||||
const AddressSpaceLimit limit(16ll * 1024 * 1024);
|
||||
QVERIFY_THROWS_EXCEPTION(ExportError, HueSaturationFilter::run(job));
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ private slots:
|
||||
void eyedroppersRecenterWidenAndNarrowTheBand();
|
||||
void targetedAdjustmentPicksTheRangeUnderTheCursor();
|
||||
void samplingNeedsAColorRangeAndAColorfulPixel();
|
||||
void positiveSaturationMatchesPhotoshop();
|
||||
};
|
||||
|
||||
void HueSaturationTests::defaultsAreAnExactNoOp()
|
||||
@@ -319,5 +320,23 @@ void HueSaturationTests::samplingNeedsAColorRangeAndAColorfulPixel()
|
||||
QVERIFY(gray->hueSaturation().value().settings.band() == before);
|
||||
}
|
||||
|
||||
// Photoshop divides by what's left: +50 doubles, +100 saturates.
|
||||
void HueSaturationTests::positiveSaturationMatchesPhotoshop()
|
||||
{
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.2, 50) - 0.4) < 1e-9);
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.3, 62) - 0.3 / 0.38) < 1e-9);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.1, 100), 1.0);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.8, 50), 1.0);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0, 100), 0.0);
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.6, -50) - 0.3) < 1e-9);
|
||||
// Clamped past ±100.
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.1, 150), 1.0);
|
||||
QCOMPARE(HueSaturationFilter::adjustedSaturation(0.6, -150), 0.0);
|
||||
QVERIFY(std::abs(HueSaturationFilter::adjustedSaturation(0.2, 0) - 0.2) < 1e-9);
|
||||
// A red at saturation 0.2, lightness 0.5, raised by 50.
|
||||
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(0.6, 0.4, 0.4, HueSaturationSettings(0, 50, 0));
|
||||
QVERIFY(std::abs(color.red - 0.7) < 1e-9 && std::abs(color.green - 0.3) < 1e-9 && std::abs(color.blue - 0.3) < 1e-9);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(HueSaturationTests)
|
||||
#include "HueSaturationTests.moc"
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QRandomGenerator>
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's LayerOrder: the hierarchy kept until what shapes it changes.
|
||||
namespace {
|
||||
// Nested folders, some hidden, built from a fixed seed.
|
||||
std::vector<ImageLayer> tree(int count)
|
||||
{
|
||||
QRandomGenerator random(11);
|
||||
std::vector<ImageLayer> layers;
|
||||
std::vector<QUuid> folders;
|
||||
for (int index = 0; index < count; ++index) {
|
||||
ImageLayer layer(QString::number(index), QSizeF(10, 10));
|
||||
layer.isGroup = random.bounded(3) == 0;
|
||||
layer.isVisible = random.bounded(4) != 0;
|
||||
if (!folders.empty() && random.bounded(3) != 0)
|
||||
layer.parentID = folders[size_t(random.bounded(int(folders.size())))];
|
||||
if (layer.isGroup)
|
||||
folders.push_back(layer.id);
|
||||
layers.push_back(layer);
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
std::vector<ProjectLayerRecord> records(const std::vector<ImageLayer> &layers)
|
||||
{
|
||||
std::vector<ProjectLayerRecord> result;
|
||||
for (const ImageLayer &layer : layers)
|
||||
result.push_back(layer.hierarchyRecord());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class LayerOrderTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void orderVisibilityAndDrawingFollowTheHierarchy();
|
||||
void eachThingThatShapesItIsSeen();
|
||||
void sixtyFourFoldersDeepAtMost();
|
||||
void nothingShownDrawsNothing();
|
||||
void opacitiesMultiplyUpKnownFoldersAlone();
|
||||
};
|
||||
|
||||
void LayerOrderTests::orderVisibilityAndDrawingFollowTheHierarchy()
|
||||
{
|
||||
const std::vector<ImageLayer> layers = tree(60);
|
||||
const LayerOrder::Result result = LayerOrder::resolve(layers);
|
||||
std::vector<QUuid> order, drawn;
|
||||
QSet<QUuid> visible;
|
||||
for (const LayerHierarchy::Entry &entry : LayerHierarchy::entries(records(layers))) {
|
||||
order.push_back(entry.layer.id);
|
||||
if (entry.visible)
|
||||
visible.insert(entry.layer.id);
|
||||
}
|
||||
for (const ProjectLayerRecord &record : LayerHierarchy::visibleLayers(records(layers)))
|
||||
drawn.push_back(record.id);
|
||||
QCOMPARE(result.order, order);
|
||||
QCOMPARE(result.visible, visible);
|
||||
QCOMPARE(result.drawn, drawn);
|
||||
// Hidden folders and hidden layers both play a part.
|
||||
QVERIFY(visible.size() > 5 && int(visible.size()) < 50 && drawn.size() > 5 && qsizetype(drawn.size()) < visible.size());
|
||||
}
|
||||
|
||||
void LayerOrderTests::eachThingThatShapesItIsSeen()
|
||||
{
|
||||
CanvasDocument document(10, 10);
|
||||
ImageLayer folder(QStringLiteral("Folder"), document.size());
|
||||
folder.isGroup = true;
|
||||
ImageLayer inside(QStringLiteral("Inside"), document.size());
|
||||
inside.parentID = folder.id;
|
||||
ImageLayer top(QStringLiteral("Top"), document.size());
|
||||
document.layers = {folder, inside, top};
|
||||
QCOMPARE(document.hierarchy().drawn, (std::vector<QUuid>{inside.id, top.id}));
|
||||
// A hidden folder hides what it holds.
|
||||
document.layers[0].isVisible = false;
|
||||
QCOMPARE(document.hierarchy().drawn, std::vector<QUuid>{top.id});
|
||||
QCOMPARE(document.effectiveVisibleIDs(), QSet<QUuid>{top.id});
|
||||
document.layers[0].isVisible = true;
|
||||
// Out of the folder: the order changes.
|
||||
document.layers[1].parentID = std::nullopt;
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, inside.id, top.id}));
|
||||
document.layers[1].parentID = top.id;
|
||||
document.layers[2].isGroup = true;
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, top.id, inside.id}));
|
||||
QCOMPARE(document.hierarchy().drawn, std::vector<QUuid>{inside.id});
|
||||
// A different layer of the same shape: its own id.
|
||||
document.layers[2].id = QUuid::createUuid();
|
||||
document.layers[1].parentID = document.layers[2].id;
|
||||
QCOMPARE(document.hierarchy().order, (std::vector<QUuid>{folder.id, document.layers[2].id, inside.id}));
|
||||
}
|
||||
|
||||
void LayerOrderTests::sixtyFourFoldersDeepAtMost()
|
||||
{
|
||||
std::vector<ImageLayer> layers;
|
||||
for (int index = 0; index < 70; ++index) {
|
||||
ImageLayer layer(QString::number(index), QSizeF(10, 10));
|
||||
layer.isGroup = index < 69;
|
||||
if (index > 0)
|
||||
layer.parentID = layers.back().id;
|
||||
layers.push_back(layer);
|
||||
}
|
||||
const LayerOrder::Result result = LayerOrder::resolve(layers);
|
||||
QCOMPARE(int(result.order.size()), 65);
|
||||
QVERIFY(result.drawn.empty());
|
||||
}
|
||||
|
||||
void LayerOrderTests::nothingShownDrawsNothing()
|
||||
{
|
||||
CanvasDocument document(10, 10);
|
||||
ImageLayer hidden(QStringLiteral("Hidden"), document.size());
|
||||
hidden.isVisible = false;
|
||||
// Two of one id: drawn, that throws; hidden, nothing draws.
|
||||
document.layers = {hidden, hidden};
|
||||
QVERIFY(document.renderLayers().empty());
|
||||
document.layers[1].isVisible = true;
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, document.renderLayers());
|
||||
}
|
||||
|
||||
void LayerOrderTests::opacitiesMultiplyUpKnownFoldersAlone()
|
||||
{
|
||||
CanvasDocument document(10, 10);
|
||||
ImageLayer folder(QStringLiteral("Folder"), document.size());
|
||||
folder.isGroup = true;
|
||||
folder.opacity = 0.5;
|
||||
ImageLayer inside(QStringLiteral("Inside"), document.size());
|
||||
inside.parentID = folder.id;
|
||||
inside.opacity = 0.8;
|
||||
// A folder nobody holds: the walk stops at its own.
|
||||
ImageLayer stray(QStringLiteral("Stray"), document.size());
|
||||
stray.parentID = QUuid::createUuid();
|
||||
stray.opacity = 0.6;
|
||||
document.layers = {folder, inside, stray};
|
||||
const QHash<QUuid, double> opacities = document.effectiveOpacities();
|
||||
QCOMPARE(opacities.value(folder.id), 0.5);
|
||||
QCOMPARE(opacities.value(inside.id), 0.4);
|
||||
QCOMPARE(opacities.value(stray.id), 0.6);
|
||||
document.layers.push_back(inside);
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, document.effectiveOpacities());
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(LayerOrderTests)
|
||||
#include "LayerOrderTests.moc"
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "IO/PSD/PSDDocumentBuilder.h"
|
||||
#include "IO/PSD/PSDReader.h"
|
||||
#include <QtTest>
|
||||
|
||||
// Swift's PSDAdjustmentTests: adjustments and masks as Photoshop writes them.
|
||||
namespace {
|
||||
QByteArray shorts(const std::vector<int> &values)
|
||||
{
|
||||
QByteArray data;
|
||||
for (const int value : values) {
|
||||
const quint16 bits = quint16(qint16(value));
|
||||
data.append(char(bits >> 8)).append(char(bits & 0xFF));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// A white patch on a gray grid, as text rows.
|
||||
QStringList rows(const QImage &mask)
|
||||
{
|
||||
QStringList result;
|
||||
for (int y = 0; y < mask.height(); ++y) {
|
||||
QString row;
|
||||
for (int x = 0; x < mask.width(); ++x)
|
||||
row += mask.constScanLine(y)[x] > 127 ? u'#' : u'.';
|
||||
result << row;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QImage whitePatch()
|
||||
{
|
||||
QImage patch = BrushRaster::context(2, 2, true);
|
||||
patch.fill(255);
|
||||
return patch;
|
||||
}
|
||||
}
|
||||
|
||||
class PSDAdjustmentTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void levelsGammaIsInHundredths();
|
||||
void hueSaturationReadsMasterAndEachRange();
|
||||
void maskPatchSitsWhereItIsOnTheCanvas();
|
||||
void maskPatchFollowsAPixelLayersGrid();
|
||||
};
|
||||
|
||||
void PSDAdjustmentTests::levelsGammaIsInHundredths()
|
||||
{
|
||||
// RGB 2–254 at gamma 2.50, the rest untouched; 29 records.
|
||||
std::vector<int> values{2, 2, 254, 0, 255, 250};
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
values.insert(values.end(), {0, 255, 0, 255, 100});
|
||||
QByteArray data = shorts(values);
|
||||
data.append(QByteArray(292 - data.size(), 0));
|
||||
const LevelsSettings levels = PSDAdjustments::levels(data).value().levels;
|
||||
QVERIFY(levels.ranges[0] == (LevelRange{.black = 2, .gamma = 2.5, .white = 254, .outputBlack = 0, .outputWhite = 255}));
|
||||
for (size_t channel = 1; channel < 4; ++channel)
|
||||
QCOMPARE(levels.ranges[channel].gamma, 1.0);
|
||||
QVERIFY(!PSDAdjustments::levels(data.left(291)));
|
||||
}
|
||||
|
||||
void PSDAdjustmentTests::hueSaturationReadsMasterAndEachRange()
|
||||
{
|
||||
// Version 2, Colorize off; its values, the Master's, the Reds'.
|
||||
QByteArray data = shorts({2}) + QByteArray(2, 0) + shorts({23, 25, 0, 5, 4, 0, 315, 345, 15, 45, 0, -30, 10});
|
||||
// Yellows' band wraps: −30 reads as 330.
|
||||
data += shorts({-30, 15, 45, 75, 7, 8, 9});
|
||||
data += shorts(std::vector<int>(4 * 7, 0));
|
||||
const HueSaturationSettings settings = PSDAdjustments::hue(data).value().hsvSettings.value();
|
||||
QVERIFY(!settings.colorize);
|
||||
QVERIFY(settings.adjustments.at(ColorRange::master) == (RangeAdjustment{5, 4, 0}));
|
||||
QVERIFY(settings.adjustments.at(ColorRange::reds) == (RangeAdjustment{0, -30, 10}));
|
||||
QVERIFY(settings.bands.at(ColorRange::reds) == (HueBand{315, 345, 15, 45}));
|
||||
QVERIFY(settings.bands.at(ColorRange::yellows) == (HueBand{330, 15, 45, 75}));
|
||||
QVERIFY(settings.adjustments.at(ColorRange::yellows) == (RangeAdjustment{7, 8, 9}));
|
||||
QVERIFY(settings.adjustments.at(ColorRange::magentas) == RangeAdjustment());
|
||||
|
||||
// Colorize on: its own values apply, and nothing else.
|
||||
data[2] = 1;
|
||||
const HueSaturationSettings colorized = PSDAdjustments::hue(data).value().hsvSettings.value();
|
||||
QVERIFY(colorized.colorize && colorized.adjustments.at(ColorRange::master) == (RangeAdjustment{23, 25, 0}));
|
||||
QVERIFY(!colorized.adjustments.contains(ColorRange::reds));
|
||||
QVERIFY(colorized.bands.at(ColorRange::reds) == defaultBand(ColorRange::reds));
|
||||
|
||||
// Cut short: the ranges that fit; under 16 bytes, none.
|
||||
data[2] = 0;
|
||||
const HueSaturationSettings cut = PSDAdjustments::hue(data.left(16 + 14 + 13)).value().hsvSettings.value();
|
||||
QVERIFY(cut.adjustments.at(ColorRange::reds) == (RangeAdjustment{0, -30, 10}));
|
||||
QVERIFY(!cut.adjustments.contains(ColorRange::yellows));
|
||||
QVERIFY(!PSDAdjustments::hue(data.left(15)));
|
||||
}
|
||||
|
||||
void PSDAdjustmentTests::maskPatchSitsWhereItIsOnTheCanvas()
|
||||
{
|
||||
// A white patch at (3, 1), black round it.
|
||||
PSDRecord record;
|
||||
record.id = QUuid::createUuid();
|
||||
record.maskBounds = QRectF(3, 1, 2, 2);
|
||||
record.maskDefault = 0;
|
||||
const ImageLayer layer(QStringLiteral("Levels"), QSizeF(6, 4));
|
||||
const QImage mask = PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value();
|
||||
QCOMPARE(mask.size(), QSize(6, 4));
|
||||
QCOMPARE(rows(mask), QStringList({"......", "...##.", "...##.", "......"}));
|
||||
|
||||
// Through the builder, the patch at the bottom left.
|
||||
record.mask = whitePatch();
|
||||
record.maskBounds = QRectF(0, 2, 2, 2);
|
||||
record.adjustment = LayerAdjustment{.kind = AdjustmentKind::levels};
|
||||
record.kind = PSDLayerKind::adjustment;
|
||||
const PSDImport imported = PSDDocumentBuilder::makeImport(PSDDocument{6, 4, 72, {record}});
|
||||
QCOMPARE(rows(imported.layers.at(0).mask.value().asset.image()), QStringList({"......", "......", "##....", "##...."}));
|
||||
QCOMPARE(imported.conversions.size(), size_t(1));
|
||||
QCOMPARE(imported.conversions[0].message, QStringLiteral("Adjustment parameters may not match Photoshop exactly."));
|
||||
}
|
||||
|
||||
void PSDAdjustmentTests::maskPatchFollowsAPixelLayersGrid()
|
||||
{
|
||||
QImage pixels = BrushRaster::context(4, 2, false);
|
||||
pixels.fill(Qt::red);
|
||||
ImageLayer layer(ImportedImage(pixels, pixels, QStringLiteral("Red")), QPointF(2, 1));
|
||||
PSDRecord record;
|
||||
record.maskBounds = QRectF(3, 1, 2, 2);
|
||||
record.maskDefault = 0;
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({".##.", ".##."}));
|
||||
// A layer stretched twice as wide: the patch is too.
|
||||
layer.transform.size = QSizeF(2, 2);
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({"..##", "..##"}));
|
||||
// Already on the grid: the patch itself.
|
||||
QImage grid = BrushRaster::context(4, 2, true);
|
||||
record.maskBounds = QRectF(2, 1, 2, 2);
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(grid, record, layer, QSizeF(6, 4)).value().cacheKey(), grid.cacheKey());
|
||||
// No bounds, or a sizeless layer: the patch stays.
|
||||
record.maskBounds = QRectF(2, 1, 0, 2);
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2));
|
||||
record.maskBounds = QRectF(2, 1, 2, 0);
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2));
|
||||
record.maskBounds = QRectF(2, 1, 2, 2);
|
||||
for (const QSizeF size : {QSizeF(0, 2), QSizeF(2, 0)}) {
|
||||
layer.transform.size = size;
|
||||
QCOMPARE(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value().size(), QSize(2, 2));
|
||||
}
|
||||
// The grid's bounds, a smaller patch: drawn up to it.
|
||||
layer.transform.size = QSizeF(4, 2);
|
||||
record.maskBounds = QRectF(2, 1, 4, 2);
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(whitePatch(), record, layer, QSizeF(6, 4)).value()), QStringList({"####", "####"}));
|
||||
// The grid's size, a pixel along: placed there.
|
||||
QImage wide = BrushRaster::context(4, 2, true);
|
||||
wide.fill(255);
|
||||
record.maskBounds = QRectF(3, 1, 4, 2);
|
||||
QCOMPARE(rows(PSDDocumentBuilder::maskOnLayerGrid(wide, record, layer, QSizeF(6, 4)).value()), QStringList({".###", ".###"}));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(PSDAdjustmentTests)
|
||||
#include "PSDAdjustmentTests.moc"
|
||||
@@ -21,6 +21,7 @@ struct Record {
|
||||
uchar flags = 0;
|
||||
std::optional<QRect> mask;
|
||||
uchar maskFlags = 0;
|
||||
uchar maskDefault = 255;
|
||||
QByteArray name = "Layer";
|
||||
std::vector<Extra> extras;
|
||||
};
|
||||
@@ -81,7 +82,7 @@ inline QByteArray file(const std::vector<Record> &records, const QByteArray &res
|
||||
extra.i32(record.mask->left());
|
||||
extra.i32(record.mask->top() + record.mask->height());
|
||||
extra.i32(record.mask->left() + record.mask->width());
|
||||
extra.u8(255);
|
||||
extra.u8(record.maskDefault);
|
||||
extra.u8(record.maskFlags);
|
||||
extra.u16(0);
|
||||
} else {
|
||||
|
||||
@@ -167,6 +167,7 @@ void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels()
|
||||
Record record = pixels();
|
||||
record.mask = QRect(1, 1, 3, 1);
|
||||
record.maskFlags = flags;
|
||||
record.maskDefault = flags == 1 ? 0 : 255;
|
||||
record.channels.push_back({-2, QByteArray("\0\0\x10\x20\x30", 5)});
|
||||
records.push_back(record);
|
||||
}
|
||||
@@ -178,12 +179,22 @@ void PSDReaderTests::maskFlagsAndMasksFromRenderedPixels()
|
||||
QVERIFY(document.layers[1].maskEnabled && !document.layers[1].maskLinked);
|
||||
QVERIFY(!document.layers[2].maskEnabled && document.layers[2].maskLinked);
|
||||
QVERIFY(!document.layers[3].mask);
|
||||
QCOMPARE(document.layers[0].maskBounds, QRectF(1, 1, 3, 1));
|
||||
QVERIFY(document.layers[0].maskDefault == 255 && document.layers[1].maskDefault == 0);
|
||||
const PSDImport imported = PSDDocumentBuilder::makeImport(document);
|
||||
// On the layer's grid, the default round it.
|
||||
const QImage placed = imported.layers[0].mask.value().asset.image();
|
||||
QCOMPARE(placed.size(), QSize(2, 2));
|
||||
QVERIFY(placed.constScanLine(0)[0] == 255 && placed.constScanLine(0)[1] == 255);
|
||||
QVERIFY(placed.constScanLine(1)[0] == 255 && placed.constScanLine(1)[1] == 0x10);
|
||||
QCOMPARE(imported.layers[1].mask.value().asset.image().constScanLine(1)[0], uchar(0));
|
||||
QVERIFY(imported.layers[1].mask.value().isEnabled && !imported.layers[1].mask.value().isLinked);
|
||||
QVERIFY(!imported.layers[2].mask.value().isEnabled);
|
||||
// A mask that is not gray is reported, left off.
|
||||
PSDDocument coloured = document;
|
||||
coloured.layers[0].mask = PSDFixture::colorImage(3, 1, 1, 1, 1);
|
||||
// Already the layer's grid, so nothing converts it.
|
||||
coloured.layers[0].mask = PSDFixture::colorImage(2, 2, 1, 1, 1);
|
||||
coloured.layers[0].maskBounds = QRectF(0, 0, 2, 2);
|
||||
const PSDImport skipped = PSDDocumentBuilder::makeImport(coloured);
|
||||
QVERIFY(!skipped.layers[0].mask);
|
||||
QCOMPARE(messages(skipped), std::vector<QString>{"Layer: The layer mask couldn’t be converted to 8-bit grayscale and was skipped."});
|
||||
@@ -317,8 +328,8 @@ void PSDReaderTests::levelsCurvesAndHueParse()
|
||||
levels.bytes(QByteArray(292 - levels.data.size(), '\0'));
|
||||
const LayerAdjustment level = PSDAdjustments::parse({{"levl", levels.data}}).value();
|
||||
QVERIFY(level.kind == AdjustmentKind::levels);
|
||||
QCOMPARE(level.levels.ranges[0], (LevelRange{10, 2, 240, 5, 250}));
|
||||
QCOMPARE(level.levels.ranges[3], (LevelRange{13, 515 / 256.0, 243, 8, 253}));
|
||||
QCOMPARE(level.levels.ranges[0], (LevelRange{10, 5.12, 240, 5, 250}));
|
||||
QCOMPARE(level.levels.ranges[3], (LevelRange{13, 5.15, 243, 8, 253}));
|
||||
QVERIFY(!PSDAdjustments::parse({{"levl", levels.data.left(291)}}));
|
||||
PSDFixture::Buffer curves;
|
||||
curves.u8(0);
|
||||
@@ -354,12 +365,12 @@ void PSDReaderTests::levelsCurvesAndHueParse()
|
||||
QVERIFY(shift.kind == AdjustmentKind::hsv);
|
||||
const HueSaturationSettings settings = shift.hsvSettings.value();
|
||||
QVERIFY(settings.colorize);
|
||||
// Colorize's own values; the Master's go unread.
|
||||
QCOMPARE(settings.adjustments.at(ColorRange::master), (RangeAdjustment{30, -20, 10}));
|
||||
QCOMPARE(settings.adjustments.at(ColorRange::reds), (RangeAdjustment{-5, 40, 0}));
|
||||
QVERIFY(!settings.adjustments.contains(ColorRange::yellows));
|
||||
QVERIFY(!settings.adjustments.contains(ColorRange::reds));
|
||||
// `hue2` comes before `hue `.
|
||||
PSDFixture::Buffer plain;
|
||||
plain.u32(0);
|
||||
plain.bytes(QByteArray(16, '\0'));
|
||||
QVERIFY(!PSDAdjustments::parse({{"hue2", plain.data}, {"hue ", hue.data}}).value().hsvSettings.value().colorize);
|
||||
QVERIFY(!PSDAdjustments::parse({{"hue ", QByteArray("\0\0\1", 3)}}));
|
||||
QVERIFY(!PSDAdjustments::parse({{"expA", hue.data}}));
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Rendering/TiledLayerRenderer.h"
|
||||
#include <QPainter>
|
||||
#include <QtTest>
|
||||
|
||||
// Compositor 1.4's sharper canvas: upright layers at 1:1 copy across.
|
||||
namespace {
|
||||
// Black and white columns, opaque, 100 by 4.
|
||||
QImage columns()
|
||||
{
|
||||
QImage image = BrushRaster::context(100, 4, false);
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
for (int x = 0; x < 100; ++x)
|
||||
image.setPixel(x, y, x % 2 ? qRgba(255, 255, 255, 255) : qRgba(0, 0, 0, 255));
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
using Draw = std::function<void(const LayerTransform &, QPainter &)>;
|
||||
|
||||
// Pixels under the layer neither black nor white.
|
||||
int blurred(double width, const Draw &draw)
|
||||
{
|
||||
QImage surface = BrushRaster::context(120, 4, false);
|
||||
QPainter painter(&surface);
|
||||
draw(LayerTransform{.origin = {0, 0}, .size = {width, 4}}, painter);
|
||||
painter.end();
|
||||
int count = 0;
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
for (int x = 0; x < 100; ++x)
|
||||
count += qRed(surface.pixel(x, y)) % 255 != 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
class SharpCanvasTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void anUprightLayerPixelForPixelTakesNoFilter();
|
||||
void plainMaskedAndTiledLayersCopyAcross();
|
||||
};
|
||||
|
||||
void SharpCanvasTests::anUprightLayerPixelForPixelTakesNoFilter()
|
||||
{
|
||||
for (LayerSampling sampling : {LayerSampling::smooth, LayerSampling::high}) {
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 1.0009, true), InterpolationQuality::none);
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 0.9991, true), InterpolationQuality::none);
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 0.9989, true), InterpolationQuality::low);
|
||||
QCOMPARE(LayerRenderer::interpolation(sampling, 1, false), InterpolationQuality::low);
|
||||
}
|
||||
QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1.0011, true), InterpolationQuality::high);
|
||||
QCOMPARE(LayerRenderer::interpolation(LayerSampling::high, 1.0009, false), InterpolationQuality::high);
|
||||
QCOMPARE(LayerRenderer::interpolation(LayerSampling::nearest, 1.5, true), InterpolationQuality::none);
|
||||
}
|
||||
|
||||
void SharpCanvasTests::plainMaskedAndTiledLayersCopyAcross()
|
||||
{
|
||||
const QImage image = columns();
|
||||
QImage white = BrushRaster::context(100, 4, true);
|
||||
white.fill(255);
|
||||
const auto raster = std::make_shared<const RasterSnapshot>(100, 4, image, QRectF(0, 0, 100, 4), std::vector<BrushPatch>{});
|
||||
const std::vector<Draw> draws{
|
||||
[&](const LayerTransform &transform, QPainter &painter) { LayerRenderer::draw(image, transform, transform.center(), painter); },
|
||||
[&](const LayerTransform &transform, QPainter &painter) {
|
||||
LayerRenderer::draw(image, transform, transform.center(), painter, {.mask = white});
|
||||
},
|
||||
[&](const LayerTransform &transform, QPainter &painter) {
|
||||
TiledLayerRenderer::drawRaster(raster, transform, transform.center(), painter);
|
||||
},
|
||||
};
|
||||
for (const Draw &draw : draws) {
|
||||
// A twentieth of a pixel over 100: within tolerance, crisp.
|
||||
QCOMPARE(blurred(100.05, draw), 0);
|
||||
// A fifth: filtered as before.
|
||||
QVERIFY(blurred(100.2, draw) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(SharpCanvasTests)
|
||||
#include "SharpCanvasTests.moc"
|
||||
@@ -262,6 +262,12 @@ compositor_test(PSDVectorTests)
|
||||
compositor_test(PSDReaderTests)
|
||||
compositor_test(PSDReaderLayerTests)
|
||||
compositor_test(PSDImportTests)
|
||||
compositor_test(PSDAdjustmentTests)
|
||||
compositor_test(SharpCanvasTests)
|
||||
compositor_test(AdjustmentBandsTests)
|
||||
compositor_test(LayerOrderTests)
|
||||
# It checks the bands against one pass of the kernels.
|
||||
target_include_directories(AdjustmentBandsTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(RawImporterTests)
|
||||
compositor_test(RawDevelopTests)
|
||||
compositor_test(ToolDefaultsTests)
|
||||
|
||||
Reference in new issue
Block a user