Merge branch 'port-b'
This commit is contained in:
commit
90a8e2860e
58 files changed
+1711
-224
No files matched your search
@@ -44,6 +44,7 @@ add_library(compositor_core STATIC
|
||||
src/Document/AdjustmentEditing.cpp
|
||||
src/Document/LayerAppearance.cpp
|
||||
src/Document/LayerGroups.cpp
|
||||
src/Document/LayerGroups+Order.cpp
|
||||
src/Document/LayerMask.cpp
|
||||
src/Document/LayerTransform.cpp
|
||||
src/Document/LiveLayerMask.cpp
|
||||
|
||||
@@ -26,3 +26,35 @@ void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t cou
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A color lookup through `cube` (`dimension`³ RGBA entries, red varying fastest), blended between the eight nearest
|
||||
/// entries, on unpremultiplied colors; alpha is kept.
|
||||
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension) {
|
||||
const float scale = (dimension - 1) / 255.0f;
|
||||
const size_t dy = dimension, dz = (size_t)dimension * dimension;
|
||||
for (size_t i=0; i<count; ++i) {
|
||||
uint8_t *p = pixels + i*4;
|
||||
float alpha = p[3];
|
||||
if (!alpha) continue;
|
||||
float position[3], fraction[3];
|
||||
int lo[3];
|
||||
for (int channel=0; channel<3; ++channel) {
|
||||
position[channel] = fminf(255, p[channel]*255.0f/alpha) * scale;
|
||||
lo[channel] = (int)position[channel];
|
||||
if (lo[channel] > dimension - 2) lo[channel] = dimension - 2;
|
||||
fraction[channel] = position[channel] - lo[channel];
|
||||
}
|
||||
const float *base = cube + ((size_t)lo[0] + lo[1]*dy + lo[2]*dz) * 4;
|
||||
const size_t sx = 4, sy = dy*4, sz = dz*4;
|
||||
for (int channel=0; channel<3; ++channel) {
|
||||
const float *c = base + channel;
|
||||
float x00 = c[0] + (c[sx] - c[0]) * fraction[0];
|
||||
float x10 = c[sy] + (c[sy+sx] - c[sy]) * fraction[0];
|
||||
float x01 = c[sz] + (c[sz+sx] - c[sz]) * fraction[0];
|
||||
float x11 = c[sz+sy] + (c[sz+sy+sx] - c[sz+sy]) * fraction[0];
|
||||
float y0 = x00 + (x10 - x00) * fraction[1];
|
||||
float y1 = x01 + (x11 - x01) * fraction[1];
|
||||
float result = y0 + (y1 - y0) * fraction[2];
|
||||
p[channel] = (uint8_t)fminf(alpha, fmaxf(0, roundf(result*alpha)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@
|
||||
#include <stddef.h>
|
||||
void levels_apply(uint8_t *pixels, size_t count, const float *tables);
|
||||
void levels_histogram(const uint8_t *pixels, const uint8_t *coverage, size_t count, double *bins);
|
||||
void cube_apply(uint8_t *pixels, size_t count, const float *cube, int dimension);
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "IO/ImageExporter.h"
|
||||
#include <QtConcurrent>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <numeric>
|
||||
#include <stdexcept>
|
||||
|
||||
@@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask)
|
||||
return surface;
|
||||
}
|
||||
|
||||
QImage BrushRaster::copy(const QImage &image)
|
||||
{
|
||||
QImage result = context(image.width(), image.height(), false);
|
||||
if (image.format() != QImage::Format_RGBA8888_Premultiplied) {
|
||||
QPainter painter(&result);
|
||||
draw(image, QRectF(0, 0, image.width(), image.height()), painter);
|
||||
return result;
|
||||
}
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body)
|
||||
{
|
||||
const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2;
|
||||
const qsizetype size = (count + bands - 1) / bands;
|
||||
try {
|
||||
std::vector<int> indices(static_cast<size_t>(bands));
|
||||
std::iota(indices.begin(), indices.end(), 0);
|
||||
PoolMap::blocking(indices, [&](int band) {
|
||||
const qsizetype start = band * size;
|
||||
if (start < count)
|
||||
body(start, std::min(size, count - start));
|
||||
});
|
||||
} catch (const std::bad_alloc &) {
|
||||
// Out of memory fails as a context would.
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
}
|
||||
}
|
||||
|
||||
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
|
||||
{
|
||||
context.save();
|
||||
|
||||
@@ -44,6 +44,10 @@ struct BrushSettings {
|
||||
// Shared raster drawing; coverage is copied without color conversion.
|
||||
namespace BrushRaster {
|
||||
QImage context(int width, int height, bool mask);
|
||||
// A colour context holding `image`; its own layout copies bytewise.
|
||||
QImage copy(const QImage &image);
|
||||
// Runs `body` over `count` pixels, band by band: start, length.
|
||||
void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body);
|
||||
// Copies `image`, or its `source` part, into `rect`.
|
||||
void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF());
|
||||
// A solid colour through gray coverage, and a clip, 1:1.
|
||||
|
||||
@@ -104,8 +104,8 @@ struct SessionJobs {
|
||||
FilterSettings settings;
|
||||
// Camera Raw's scope of the grade itself.
|
||||
std::optional<CameraRawScope> scope = std::nullopt;
|
||||
// Its grid's version: a newer grid drops it.
|
||||
quint64 sourceVersion = 0;
|
||||
// The grown layer it was made from: it shows there.
|
||||
std::optional<LayerTransform> placement = std::nullopt;
|
||||
};
|
||||
struct FilterMade {
|
||||
std::optional<ImportedImage> asset;
|
||||
|
||||
@@ -75,7 +75,8 @@ struct CanvasDocument {
|
||||
|
||||
QSizeF size() const { return QSizeF(width, height); }
|
||||
static std::optional<int> validDimension(const QString &value);
|
||||
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
|
||||
// Drawing order and what shows (LayerOrder), kept between asks.
|
||||
LayerOrder::Result hierarchy() const;
|
||||
QSet<QUuid> effectiveVisibleIDs() const;
|
||||
QHash<QUuid, double> effectiveOpacities() const;
|
||||
std::vector<ImageLayer> renderLayers() const;
|
||||
|
||||
@@ -257,8 +257,8 @@ LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const
|
||||
if (const std::optional<LayerTransform> pending = pendingTransform(layer))
|
||||
return *pending;
|
||||
// Content-Aware Fill past the edge previews on the grown layer.
|
||||
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
|
||||
return *m_filterEdit->grownTransform;
|
||||
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
|
||||
return *m_filterEdit->preparedTransform;
|
||||
return layer.transform;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,10 +80,6 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
|
||||
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
|
||||
try {
|
||||
edit.growForBlur();
|
||||
edit.preparedPreview.reset();
|
||||
// A waiting job moves to the grid its version names.
|
||||
if (edit.pending)
|
||||
edit.pending = edit.previewJob();
|
||||
} catch (const ProjectError &error) {
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
} catch (const ExportError &error) {
|
||||
@@ -111,6 +107,7 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
|
||||
return;
|
||||
}
|
||||
edit.pending = edit.previewJob();
|
||||
edit.pendingTransform = edit.grownTransform;
|
||||
renderFilterPreview();
|
||||
}
|
||||
|
||||
@@ -127,22 +124,22 @@ void EditorSession::renderFilterPreview()
|
||||
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
|
||||
m_filterPreviewFor = edit.id;
|
||||
m_filterRendering = true;
|
||||
m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered {
|
||||
m_filterPreview.setFuture(QtConcurrent::run([job, placement = edit.pendingTransform]() -> Filtered {
|
||||
try {
|
||||
if (job.kind == FilterKind::cameraRaw) {
|
||||
auto [image, scope] = CameraRawScope::preview(job);
|
||||
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version};
|
||||
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), placement};
|
||||
}
|
||||
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version};
|
||||
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, placement};
|
||||
} catch (const ContentFillError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
} catch (const SubjectRemovalError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
} catch (const ExportError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
} catch (const ProjectError &error) {
|
||||
// Settings past their bounds: Black & White, Color Balance.
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -153,18 +150,17 @@ void EditorSession::finishFilterPreview()
|
||||
{
|
||||
m_filterRendering = false;
|
||||
const Filtered result = m_filterPreview.result();
|
||||
// A cancelled edit takes nothing; an older grid renders again.
|
||||
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) {
|
||||
m_filterEdit->preparing = false;
|
||||
notify();
|
||||
} else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
|
||||
// A cancelled edit takes nothing.
|
||||
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
|
||||
FilterEdit &edit = *m_filterEdit;
|
||||
edit.preparing = false;
|
||||
edit.previewError = result.failure;
|
||||
if (result.scope)
|
||||
edit.rawPanel.scope = result.scope;
|
||||
// Made before the layer grew, it shows there till replaced.
|
||||
if (edit.preview || isAutomatic(edit.kind)) {
|
||||
edit.preparedPreview = result.image;
|
||||
edit.preparedTransform = result.placement;
|
||||
edit.preparedSettings = result.settings;
|
||||
++m_brushRevision;
|
||||
}
|
||||
|
||||
@@ -272,7 +272,6 @@ void FilterEdit::grow(const QRectF &extent)
|
||||
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
|
||||
{
|
||||
const QSize size = source.size();
|
||||
++previewSourceVersion;
|
||||
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
|
||||
// Noise, grain and dither preview whole: enlarged, they look coarse.
|
||||
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
|
||||
|
||||
@@ -171,10 +171,12 @@ public:
|
||||
// Add Noise's grain, fixed while the panel is open.
|
||||
const quint32 seed = QRandomGenerator::global()->generate();
|
||||
std::optional<QImage> preparedPreview;
|
||||
// Where the prepared preview sits; none: the layer's own place.
|
||||
std::optional<LayerTransform> preparedTransform;
|
||||
// The grown layer `pending` is made from.
|
||||
std::optional<LayerTransform> pendingTransform;
|
||||
// The settings the prepared preview was made with.
|
||||
std::optional<FilterSettings> preparedSettings;
|
||||
// Moves with each new grid: an older render lands nothing.
|
||||
quint64 previewSourceVersion = 0;
|
||||
std::optional<FilterJob> pending;
|
||||
CameraRawPanel rawPanel;
|
||||
// Vignette on an empty layer: the canvas it fills.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Rendering/PoolMap.h"
|
||||
#include "Document/HueSaturation.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include <QPainter>
|
||||
#include <QtConcurrent>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
extern "C" {
|
||||
#include "LevelsPixels.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct HSL {
|
||||
@@ -48,38 +49,38 @@ HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness
|
||||
return {clamp(color.red), clamp(color.green), clamp(color.blue)};
|
||||
}
|
||||
|
||||
// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back.
|
||||
void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows)
|
||||
// The last tables built: building costs more than applying.
|
||||
struct CubeCache {
|
||||
std::mutex lock;
|
||||
std::deque<std::pair<HueSaturationSettings, std::vector<float>>> cubes;
|
||||
};
|
||||
|
||||
CubeCache &cubeCache()
|
||||
{
|
||||
constexpr int size = HueSaturationFilter::dimension, last = size - 1;
|
||||
PoolMap::blocking(rows, [&](int y) {
|
||||
uchar *pixel = bits + y * stride;
|
||||
for (int x = 0; x < width; ++x, pixel += 4) {
|
||||
const float alpha = pixel[3];
|
||||
if (alpha == 0) {
|
||||
std::fill_n(pixel, 3, uchar(0));
|
||||
continue;
|
||||
static CubeCache cache;
|
||||
return cache;
|
||||
}
|
||||
|
||||
std::vector<float> built(const HueSaturationSettings &settings)
|
||||
{
|
||||
const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(settings);
|
||||
constexpr int dimension = HueSaturationFilter::dimension;
|
||||
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
|
||||
size_t index = 0;
|
||||
const double step = dimension - 1;
|
||||
for (int blue = 0; blue < dimension; ++blue) {
|
||||
for (int green = 0; green < dimension; ++green) {
|
||||
for (int red = 0; red < dimension; ++red) {
|
||||
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(red / step, green / step, blue / step, settings, response);
|
||||
values[index] = float(color.red);
|
||||
values[index + 1] = float(color.green);
|
||||
values[index + 2] = float(color.blue);
|
||||
values[index + 3] = 1;
|
||||
index += 4;
|
||||
}
|
||||
float place[3], fraction[3];
|
||||
int low[3];
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
place[channel] = std::min(1.0f, pixel[channel] / alpha) * last;
|
||||
low[channel] = std::min(int(place[channel]), last - 1);
|
||||
fraction[channel] = place[channel] - float(low[channel]);
|
||||
}
|
||||
float out[3] = {0, 0, 0};
|
||||
for (int corner = 0; corner < 8; ++corner) {
|
||||
const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1);
|
||||
const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1])
|
||||
* ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]);
|
||||
const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)];
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
out[channel] += share * entry[channel];
|
||||
}
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha));
|
||||
}
|
||||
});
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,22 +105,20 @@ std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(c
|
||||
|
||||
std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings)
|
||||
{
|
||||
const std::vector<HueResponse> response = hueResponse(settings);
|
||||
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
|
||||
size_t index = 0;
|
||||
const double step = dimension - 1;
|
||||
for (int blue = 0; blue < dimension; ++blue) {
|
||||
for (int green = 0; green < dimension; ++green) {
|
||||
for (int red = 0; red < dimension; ++red) {
|
||||
const Color color = adjust(red / step, green / step, blue / step, settings, response);
|
||||
values[index] = float(color.red);
|
||||
values[index + 1] = float(color.green);
|
||||
values[index + 2] = float(color.blue);
|
||||
values[index + 3] = 1;
|
||||
index += 4;
|
||||
}
|
||||
CubeCache &cache = cubeCache();
|
||||
{
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
for (const auto &[known, values] : cache.cubes) {
|
||||
if (known == settings)
|
||||
return values;
|
||||
}
|
||||
}
|
||||
std::vector<float> values = built(settings);
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
std::erase_if(cache.cubes, [&](const auto &entry) { return entry.first == settings; });
|
||||
cache.cubes.emplace_front(settings, values);
|
||||
if (cache.cubes.size() > 8)
|
||||
cache.cubes.pop_back();
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -144,8 +143,7 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
|
||||
hue = std::fmod(hue + sampled.shift, 360);
|
||||
if (hue < 0)
|
||||
hue += 360;
|
||||
// Multiplied, so neutral grays stay neutral.
|
||||
saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100)));
|
||||
saturation = adjustedSaturation(saturation, sampled.saturation);
|
||||
}
|
||||
// Toward white above zero, black below, reaching either at 100.
|
||||
const double amount = std::min(1.0, std::max(-1.0, lightnessAmount));
|
||||
@@ -153,6 +151,15 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
|
||||
return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness)));
|
||||
}
|
||||
|
||||
double HueSaturationFilter::adjustedSaturation(double saturation, double amount)
|
||||
{
|
||||
const double share = std::min(1.0, std::max(-1.0, amount / 100));
|
||||
// Multiplied both ways, so neutral grays stay neutral.
|
||||
if (!(share > 0))
|
||||
return std::max(0.0, saturation * (1 + share));
|
||||
return share >= 1 ? (saturation > 0 ? 1 : 0) : std::min(1.0, saturation / (1 - share));
|
||||
}
|
||||
|
||||
double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings)
|
||||
{
|
||||
double shift = 0;
|
||||
@@ -166,23 +173,19 @@ double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &
|
||||
|
||||
AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job)
|
||||
{
|
||||
const int width = job.image.width(), height = job.image.height();
|
||||
QImage result = BrushRaster::context(width, height, false);
|
||||
{
|
||||
QPainter painter(&result);
|
||||
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
|
||||
}
|
||||
// Across the cores: it runs over the view each frame.
|
||||
QImage result = BrushRaster::copy(job.image);
|
||||
std::vector<float> table;
|
||||
std::vector<int> rows;
|
||||
try {
|
||||
table = cube(job.settings);
|
||||
rows.resize(size_t(height));
|
||||
} catch (const std::bad_alloc &) {
|
||||
// Out of memory fails as a context would.
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
}
|
||||
std::iota(rows.begin(), rows.end(), 0);
|
||||
applyCube(result.bits(), result.bytesPerLine(), width, table, rows);
|
||||
// The lookup unpremultiplies around itself.
|
||||
uchar *const pixels = result.bits();
|
||||
BrushRaster::inBands(qsizetype(result.width()) * result.height(),
|
||||
[&](qsizetype start, qsizetype length) { cube_apply(pixels + start * 4, size_t(length), table.data(), dimension); });
|
||||
// Swift blends over the image drawn again: the same pixels.
|
||||
if (job.selection)
|
||||
result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false);
|
||||
|
||||
@@ -131,6 +131,8 @@ std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings);
|
||||
std::vector<float> cube(const HueSaturationSettings &settings);
|
||||
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings);
|
||||
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response);
|
||||
// Photoshop's: down toward gray, up by dividing by what's left.
|
||||
double adjustedSaturation(double saturation, double amount);
|
||||
// The hue a spectrum swatch becomes.
|
||||
double shiftedHue(double hue, const HueSaturationSettings &settings);
|
||||
}
|
||||
|
||||
@@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region,
|
||||
FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()};
|
||||
// Swift's region origin, counted in the image's own pixels.
|
||||
if (region)
|
||||
job.noiseOrigin = region->topLeft() * (image.width() / region->width());
|
||||
job.noiseOrigin = QPointF(region->left() * image.width() / std::max(1.0, region->width()),
|
||||
region->top() * image.height() / std::max(1.0, region->height()));
|
||||
return PixelFilter::run(job);
|
||||
}
|
||||
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/LayerGroups.h"
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
using Children = std::map<std::optional<QUuid>, std::vector<const LayerOrder::Node *>>;
|
||||
|
||||
void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool shown, LayerOrder::Result &result)
|
||||
{
|
||||
if (depth > 64)
|
||||
return;
|
||||
const auto found = children.find(parent);
|
||||
if (found == children.end())
|
||||
return;
|
||||
for (const LayerOrder::Node *node : found->second) {
|
||||
const bool effective = shown && node->isVisible;
|
||||
result.order.push_back(node->id);
|
||||
if (effective) {
|
||||
result.visible.insert(node->id);
|
||||
if (!node->isGroup)
|
||||
result.drawn.push_back(node->id);
|
||||
}
|
||||
if (node->isGroup)
|
||||
visit(children, node->id, depth + 1, effective, result);
|
||||
}
|
||||
}
|
||||
|
||||
// The last nodes asked about and their answer.
|
||||
struct Known {
|
||||
std::mutex lock;
|
||||
std::optional<std::pair<std::vector<LayerOrder::Node>, LayerOrder::Result>> last;
|
||||
};
|
||||
|
||||
Known &known()
|
||||
{
|
||||
static Known value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
LayerOrder::Result LayerOrder::resolve(const std::vector<ImageLayer> &layers)
|
||||
{
|
||||
std::vector<Node> nodes;
|
||||
nodes.reserve(layers.size());
|
||||
for (const ImageLayer &layer : layers)
|
||||
nodes.push_back(Node{layer.id, layer.parentID, layer.isGroup, layer.isVisible});
|
||||
Known &cache = known();
|
||||
{
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
if (cache.last && cache.last->first == nodes)
|
||||
return cache.last->second;
|
||||
}
|
||||
Children children;
|
||||
for (const Node &node : nodes)
|
||||
children[node.parentID].push_back(&node);
|
||||
Result result;
|
||||
visit(children, std::nullopt, 0, true, result);
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
cache.last.emplace(std::move(nodes), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
LayerOrder::Result CanvasDocument::hierarchy() const
|
||||
{
|
||||
return LayerOrder::resolve(layers);
|
||||
}
|
||||
|
||||
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
|
||||
{
|
||||
return hierarchy().visible;
|
||||
}
|
||||
|
||||
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
|
||||
{
|
||||
// Each layer's opacity and folder, no whole layers copied.
|
||||
QHash<QUuid, std::pair<double, std::optional<QUuid>>> folders;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (folders.contains(layer.id))
|
||||
throw std::logic_error("two layers share an id");
|
||||
folders.insert(layer.id, {layer.opacity, layer.parentID});
|
||||
}
|
||||
QHash<QUuid, double> result;
|
||||
for (auto entry = folders.cbegin(); entry != folders.cend(); ++entry) {
|
||||
result.insert(entry.key(), LayerOpacity::effective(entry->first, entry->second, [&folders](QUuid id) {
|
||||
return folders.contains(id) ? std::optional(folders.value(id)) : std::nullopt;
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<ImageLayer> CanvasDocument::renderLayers() const
|
||||
{
|
||||
const std::vector<QUuid> order = hierarchy().drawn;
|
||||
if (order.empty())
|
||||
return {};
|
||||
std::map<QUuid, size_t> index;
|
||||
for (size_t place = 0; place < layers.size(); ++place) {
|
||||
if (!index.emplace(layers[place].id, place).second)
|
||||
throw std::logic_error("two layers share an id");
|
||||
}
|
||||
std::vector<ImageLayer> result;
|
||||
result.reserve(order.size());
|
||||
for (const QUuid id : order)
|
||||
result.push_back(layers[index.at(id)]);
|
||||
return result;
|
||||
}
|
||||
@@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRe
|
||||
});
|
||||
}
|
||||
|
||||
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
|
||||
{
|
||||
std::map<QUuid, ImageLayer> byID;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (!byID.emplace(layer.id, layer).second)
|
||||
throw std::logic_error("two layers share an id");
|
||||
}
|
||||
QHash<QUuid, double> result;
|
||||
for (const ImageLayer &layer : layers)
|
||||
result.insert(layer.id, layer.effectiveOpacity(byID));
|
||||
return result;
|
||||
}
|
||||
|
||||
ProjectLayerRecord ImageLayer::hierarchyRecord() const
|
||||
{
|
||||
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
|
||||
@@ -159,40 +146,6 @@ ProjectLayerRecord ImageLayer::hierarchyRecord() const
|
||||
.text = typed ? std::optional(typed->style) : std::nullopt};
|
||||
}
|
||||
|
||||
std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const
|
||||
{
|
||||
std::vector<ProjectLayerRecord> records;
|
||||
for (const ImageLayer &layer : layers)
|
||||
records.push_back(layer.hierarchyRecord());
|
||||
return LayerHierarchy::entries(records);
|
||||
}
|
||||
|
||||
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
|
||||
{
|
||||
QSet<QUuid> result;
|
||||
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
|
||||
if (entry.visible)
|
||||
result.insert(entry.layer.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<ImageLayer> CanvasDocument::renderLayers() const
|
||||
{
|
||||
QHash<QUuid, const ImageLayer *> byID;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (byID.contains(layer.id))
|
||||
throw std::logic_error("two layers share an id");
|
||||
byID.insert(layer.id, &layer);
|
||||
}
|
||||
std::vector<ImageLayer> result;
|
||||
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
|
||||
if (entry.visible && entry.layer.isGroup != true)
|
||||
result.push_back(*byID.value(entry.layer.id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary)
|
||||
{
|
||||
dropEffectSelection();
|
||||
@@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers()
|
||||
}
|
||||
std::vector<QUuid> ordered;
|
||||
std::vector<Chain> chains;
|
||||
for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) {
|
||||
if (rootIDs.contains(entry.layer.id)) {
|
||||
ordered.push_back(entry.layer.id);
|
||||
chains.push_back(ancestors(entry.layer.id));
|
||||
for (const QUuid id : m_document->hierarchy().order) {
|
||||
if (rootIDs.contains(id)) {
|
||||
ordered.push_back(id);
|
||||
chains.push_back(ancestors(id));
|
||||
}
|
||||
}
|
||||
// The nearest folder they all share; the root when none.
|
||||
|
||||
@@ -23,3 +23,26 @@ namespace LayerOpacity {
|
||||
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
|
||||
double effective(double own, std::optional<QUuid> parent, const Node &folder);
|
||||
}
|
||||
|
||||
struct ImageLayer;
|
||||
|
||||
// Swift's LayerOrder: the hierarchy from ids, folders and visibility alone.
|
||||
namespace LayerOrder {
|
||||
struct Node {
|
||||
QUuid id;
|
||||
std::optional<QUuid> parentID;
|
||||
bool isGroup;
|
||||
bool isVisible;
|
||||
friend bool operator==(const Node &, const Node &) = default;
|
||||
};
|
||||
struct Result {
|
||||
// Every layer and folder, as LayerHierarchy::entries lists them.
|
||||
std::vector<QUuid> order;
|
||||
// Visible, in folders that are.
|
||||
QSet<QUuid> visible;
|
||||
// The layers that show, folders left out, bottom to top.
|
||||
std::vector<QUuid> drawn;
|
||||
};
|
||||
// Kept until a node changes: the canvas asks each event.
|
||||
Result resolve(const std::vector<ImageLayer> &layers);
|
||||
}
|
||||
@@ -392,7 +392,7 @@ std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageL
|
||||
if (!layer.mask)
|
||||
return std::nullopt;
|
||||
// Previewed on a grown layer, the mask keeps its bounds.
|
||||
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
|
||||
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
|
||||
return layer.mask->placement.value_or(layer.transform);
|
||||
if (m_transformEdit && m_transformEdit->group) {
|
||||
const TransformGroup &group = *m_transformEdit->group;
|
||||
|
||||
@@ -104,15 +104,17 @@ QImage LevelsFilter::run(const LevelsJob &job)
|
||||
{
|
||||
if (job.settings.isIdentity())
|
||||
return job.image;
|
||||
QImage context = drawn(job.image);
|
||||
QImage context = BrushRaster::copy(job.image);
|
||||
// On the stack: a heap failure would escape the catch.
|
||||
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(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
|
||||
}
|
||||
// The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice).
|
||||
levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data());
|
||||
// The kernel unpremultiplies edges itself; bands use every core.
|
||||
uchar *const pixels = context.bits();
|
||||
BrushRaster::inBands(qsizetype(context.width()) * context.height(),
|
||||
[&](qsizetype start, qsizetype length) { levels_apply(pixels + start * 4, size_t(length), tables.data()); });
|
||||
if (job.selection)
|
||||
return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false);
|
||||
return context;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#include "IO/PSD/PSDDocumentBuilder.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Logging.h"
|
||||
#include <QPainter>
|
||||
|
||||
namespace {
|
||||
ImportedImage imported(const QImage &image, const QString &name)
|
||||
@@ -114,7 +118,10 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
|
||||
ImageLayer layer = layerFor(record, canvas, assets, typed);
|
||||
if (record.mask) {
|
||||
try {
|
||||
layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked);
|
||||
const std::optional<QImage> placed = maskOnLayerGrid(*record.mask, record, layer, canvas);
|
||||
if (!placed)
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
layer.mask = LayerMask(LayerMask::assetFrom(*placed), record.maskEnabled, std::nullopt, record.maskLinked);
|
||||
} catch (const std::runtime_error &) {
|
||||
// Swift's `try?`: a mask that fails is reported, not fatal.
|
||||
conversions.push_back(PSDConversion{.layerName = record.name,
|
||||
@@ -147,3 +154,28 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
|
||||
}
|
||||
return PSDImport{document.width, document.height, document.resolution, std::move(layers), std::move(conversions)};
|
||||
}
|
||||
|
||||
std::optional<QImage> PSDDocumentBuilder::maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas)
|
||||
{
|
||||
const QSizeF grid = layer.asset ? QSizeF(layer.asset->size()) : canvas;
|
||||
const QRectF placed(layer.transform.origin, layer.transform.size);
|
||||
if (grid.width() < 1 || grid.height() < 1 || !(placed.width() > 0) || !(placed.height() > 0) || !(record.maskBounds.width() > 0)
|
||||
|| !(record.maskBounds.height() > 0))
|
||||
return patch;
|
||||
const double scaleX = grid.width() / placed.width(), scaleY = grid.height() / placed.height();
|
||||
const QRectF rect((record.maskBounds.left() - placed.left()) * scaleX, (record.maskBounds.top() - placed.top()) * scaleY,
|
||||
record.maskBounds.width() * scaleX, record.maskBounds.height() * scaleY);
|
||||
const int width = int(grid.width()), height = int(grid.height());
|
||||
// Already the layer's grid: nothing to place.
|
||||
if (rect.toAlignedRect() == QRect(0, 0, width, height) && patch.size() == QSize(width, height))
|
||||
return patch;
|
||||
QImage context(width, height, QImage::Format_Grayscale8);
|
||||
if (context.isNull()) {
|
||||
qCWarning(lcIO) << "a PSD layer mask could not be placed on its layer:" << QSize(width, height);
|
||||
return std::nullopt;
|
||||
}
|
||||
context.fill(uint(record.maskDefault));
|
||||
QPainter painter(&context);
|
||||
BrushRaster::draw(patch, rect, painter);
|
||||
return context;
|
||||
}
|
||||
@@ -15,4 +15,6 @@ namespace PSDDocumentBuilder {
|
||||
// Each pixel record's asset and thumbnail, off the UI thread.
|
||||
std::map<QUuid, ImportedImage> assets(const PSDDocument &document);
|
||||
PSDImport makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets = {});
|
||||
// The stored patch on the layer's grid; none without memory.
|
||||
std::optional<QImage> maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "IO/PSD/PSDReader.h"
|
||||
#include <QtEndian>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
quint16 u16(const QByteArray &data, qsizetype at)
|
||||
@@ -12,21 +13,23 @@ qint16 i16(const QByteArray &data, qsizetype at)
|
||||
{
|
||||
return qFromBigEndian<qint16>(data.constData() + at);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<LayerAdjustment> levels(const QByteArray &data)
|
||||
std::optional<LayerAdjustment> PSDAdjustments::levels(const QByteArray &data)
|
||||
{
|
||||
if (data.size() < 292)
|
||||
return std::nullopt;
|
||||
LevelsSettings settings;
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
const qsizetype base = 2 + channel * 10;
|
||||
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 256, double(u16(data, base + 2)),
|
||||
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 100, double(u16(data, base + 2)),
|
||||
double(u16(data, base + 4)), double(u16(data, base + 6))}
|
||||
.normalized();
|
||||
}
|
||||
return LayerAdjustment{.kind = AdjustmentKind::levels, .levels = settings};
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::optional<LayerAdjustment> curves(const QByteArray &data)
|
||||
{
|
||||
if (data.size() < 5)
|
||||
@@ -66,22 +69,33 @@ std::optional<LayerAdjustment> curves(const QByteArray &data)
|
||||
return std::nullopt;
|
||||
return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings};
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<LayerAdjustment> hue(const QByteArray &data)
|
||||
std::optional<LayerAdjustment> PSDAdjustments::hue(const QByteArray &data)
|
||||
{
|
||||
if (data.size() < 4)
|
||||
if (data.size() < 16)
|
||||
return std::nullopt;
|
||||
HueSaturationSettings settings(0, 0, 0, data[2] != 0);
|
||||
qsizetype at = 4;
|
||||
for (const ColorRange range : allColorRanges) {
|
||||
if (at + 6 > data.size())
|
||||
const bool colorize = data[2] != 0;
|
||||
HueSaturationSettings settings(0, 0, 0, colorize);
|
||||
const auto values = [&data](qsizetype at) { return RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))}; };
|
||||
// Colorize has values of its own; else the Master's.
|
||||
settings.adjustments[ColorRange::master] = values(colorize ? 4 : 10);
|
||||
if (colorize)
|
||||
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
|
||||
qsizetype at = 16;
|
||||
for (const ColorRange range : colorRanges) {
|
||||
if (at + 14 > data.size())
|
||||
break;
|
||||
settings.adjustments[range] = RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))};
|
||||
at += 6;
|
||||
const auto degrees = [&data](qsizetype from) {
|
||||
const double value = std::fmod(double(i16(data, from)), 360);
|
||||
return value < 0 ? value + 360 : value;
|
||||
};
|
||||
settings.bands[range] = HueBand{degrees(at), degrees(at + 2), degrees(at + 4), degrees(at + 6)};
|
||||
settings.adjustments[range] = values(at + 8);
|
||||
at += 14;
|
||||
}
|
||||
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<LayerAdjustment> PSDAdjustments::parse(const std::map<QString, QByteArray> &extra)
|
||||
{
|
||||
|
||||
@@ -180,8 +180,7 @@ PSDRecords::RawLayer PSDRecords::readRecord(Cursor &cursor, bool isPSB)
|
||||
layer.maskRight = cursor.i32();
|
||||
layer.sourceMaskTop = layer.maskTop, layer.sourceMaskLeft = layer.maskLeft;
|
||||
layer.sourceMaskBottom = layer.maskBottom, layer.sourceMaskRight = layer.maskRight;
|
||||
// The mask's default colour is skipped, as Swift ignores it.
|
||||
cursor.skip(1);
|
||||
layer.maskDefault = cursor.u8();
|
||||
const uchar flags = cursor.u8();
|
||||
layer.maskDisabled = (flags & 2) != 0;
|
||||
layer.maskLinked = (flags & 1) == 0;
|
||||
|
||||
@@ -50,6 +50,8 @@ struct RawLayer {
|
||||
std::map<QString, QByteArray> extra;
|
||||
qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
|
||||
qint64 sourceMaskTop = 0, sourceMaskLeft = 0, sourceMaskBottom = 0, sourceMaskRight = 0;
|
||||
// The mask's value outside its stored bounds.
|
||||
uchar maskDefault = 255;
|
||||
bool maskDisabled = false;
|
||||
bool maskLinked = true;
|
||||
bool maskFromRender = false;
|
||||
|
||||
@@ -80,6 +80,8 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
|
||||
}
|
||||
}
|
||||
record.mask = layer.maskFromRender ? std::nullopt : layer.maskImage;
|
||||
record.maskBounds = QRectF(layer.maskLeft, layer.maskTop, layer.maskRight - layer.maskLeft, layer.maskBottom - layer.maskTop);
|
||||
record.maskDefault = layer.maskDefault;
|
||||
record.maskEnabled = !layer.maskDisabled;
|
||||
record.maskLinked = layer.maskLinked;
|
||||
if (!isGroup)
|
||||
|
||||
@@ -21,4 +21,8 @@ extern const std::set<QString> adjustmentKeys;
|
||||
// Levels, Curves and Hue/Saturation layers, as Swift reads them.
|
||||
namespace PSDAdjustments {
|
||||
std::optional<LayerAdjustment> parse(const std::map<QString, QByteArray> &extra);
|
||||
// 'levl': each channel's levels, gamma in hundredths.
|
||||
std::optional<LayerAdjustment> levels(const QByteArray &data);
|
||||
// 'hue2': Colorize or the Master, then each range's values.
|
||||
std::optional<LayerAdjustment> hue(const QByteArray &data);
|
||||
}
|
||||
@@ -38,6 +38,9 @@ struct PSDRecord {
|
||||
QRectF bounds;
|
||||
std::optional<QImage> image;
|
||||
std::optional<QImage> mask;
|
||||
// Where `mask` sits, and Photoshop's value everywhere else.
|
||||
QRectF maskBounds;
|
||||
uchar maskDefault = 255;
|
||||
bool maskEnabled = true;
|
||||
bool maskLinked = true;
|
||||
std::optional<LayerAdjustment> adjustment;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "Rendering/AdjustmentSurface.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Logging.h"
|
||||
#include "Rendering/LayerRenderer.h"
|
||||
#include <QPaintEngine>
|
||||
|
||||
void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding, qint64 pixelBudget)
|
||||
@@ -15,11 +16,18 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
|
||||
// Spatial adjustments read a halo; the clip keeps what shows.
|
||||
const QRectF shown = toDevice.inverted().mapRect(QRectF(output));
|
||||
const QRect area = padding > 0 ? toDevice.mapRect(shown.adjusted(-padding, -padding, padding, padding)).toAlignedRect() : output;
|
||||
if (qint64(area.width()) * area.height() > pixelBudget) {
|
||||
// Device pixels; past the budget, painter units.
|
||||
double scale = 1;
|
||||
QSize size = area.size();
|
||||
if (qint64(size.width()) * size.height() > pixelBudget) {
|
||||
scale = 1 / LayerRenderer::deviceScale(context);
|
||||
size = QSize(std::max(1, qRound(area.width() * scale)), std::max(1, qRound(area.height() * scale)));
|
||||
}
|
||||
if (qint64(size.width()) * size.height() > pixelBudget) {
|
||||
qCWarning(lcRendering) << "an adjustment surface passes its pixel budget:" << area.size();
|
||||
return;
|
||||
}
|
||||
QImage surface(area.size(), QImage::Format_RGBA8888_Premultiplied);
|
||||
QImage surface(size, QImage::Format_RGBA8888_Premultiplied);
|
||||
if (surface.isNull()) {
|
||||
qCWarning(lcRendering) << "an adjustment surface could not be allocated:" << area.size();
|
||||
return;
|
||||
@@ -28,12 +36,13 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
|
||||
{
|
||||
// The body draws in the painter's own coordinates.
|
||||
QPainter painter(&surface);
|
||||
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()));
|
||||
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()) * QTransform::fromScale(scale, scale));
|
||||
body(painter);
|
||||
}
|
||||
// Drawn back under the painter's own opacity, mode and clip.
|
||||
context.save();
|
||||
context.setWorldTransform(context.deviceTransform().inverted() * context.worldTransform());
|
||||
context.setRenderHint(QPainter::SmoothPixmapTransform, scale != 1);
|
||||
context.drawImage(QRectF(area), surface);
|
||||
context.restore();
|
||||
}
|
||||
@@ -430,6 +430,10 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
|
||||
live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; };
|
||||
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); };
|
||||
live.adjustmentScale = scale;
|
||||
const QPointF corner = center(QPointF(0, 0));
|
||||
live.adjustmentRegion = [corner, scale](const QRectF &rect) {
|
||||
return QRectF((rect.left() - corner.x()) / scale, (rect.top() - corner.y()) / scale, rect.width() / scale, rect.height() / scale);
|
||||
};
|
||||
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
|
||||
const ImageLayer &layer = byID.at(id);
|
||||
if (!layer.mask || !layer.mask->isEnabled)
|
||||
|
||||
@@ -20,8 +20,6 @@ QPainter::CompositionMode compositionMode(LayerBlendMode mode)
|
||||
return QPainter::CompositionMode_Screen;
|
||||
case LayerBlendMode::overlay:
|
||||
return QPainter::CompositionMode_Overlay;
|
||||
case LayerBlendMode::softLight:
|
||||
return QPainter::CompositionMode_SoftLight;
|
||||
case LayerBlendMode::darken:
|
||||
return QPainter::CompositionMode_Darken;
|
||||
case LayerBlendMode::lighten:
|
||||
@@ -56,7 +54,7 @@ void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, Q
|
||||
const double device = deviceScale(context);
|
||||
const Reduced source = reduced(image, width, device, transform.sampling);
|
||||
const InterpolationQuality quality = interpolation(
|
||||
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level));
|
||||
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level), transform.radians() == 0);
|
||||
const QRectF bounds(-width / 2, -height / 2, width, height);
|
||||
QTransform placement;
|
||||
placement.translate(center.x(), center.y());
|
||||
@@ -294,9 +292,9 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
|
||||
context.restore();
|
||||
}
|
||||
|
||||
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor)
|
||||
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor, bool upright)
|
||||
{
|
||||
if (sampling == LayerSampling::nearest)
|
||||
if (sampling == LayerSampling::nearest || (upright && std::abs(finalFactor - 1) < 0.001))
|
||||
return InterpolationQuality::none;
|
||||
return finalFactor <= 1 ? InterpolationQuality::low : quality(sampling);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ void drawBrushPreview(const QImage &image, const LayerTransform &transform, QPoi
|
||||
void composite(QPainter &context, const QTransform &placement, const QRectF &extent, LayerSampling sampling,
|
||||
InterpolationQuality quality, const Options &options, const QRectF &maskBounds,
|
||||
const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {});
|
||||
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor);
|
||||
// An upright layer drawn pixel for pixel copies straight across.
|
||||
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor, bool upright = false);
|
||||
Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling);
|
||||
QRectF coverage(const Reduced &reduced, const QRectF &bounds);
|
||||
double deviceScale(const QPainter &context);
|
||||
|
||||
@@ -208,8 +208,9 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
|
||||
qCWarning(lcRendering) << "an adjustment passes its pixel budget:" << device->size();
|
||||
return;
|
||||
}
|
||||
// The document the device shows, where Grain's pattern sits.
|
||||
const QRectF region = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
|
||||
// What the device shows; Grain and Noise keep the document.
|
||||
const QRectF shown = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
|
||||
const QRectF region = adjustmentRegion ? adjustmentRegion(shown) : shown;
|
||||
try {
|
||||
// The kernels' byte order, one image pixel a device pixel.
|
||||
QImage original = device->convertToFormat(QImage::Format_RGBA8888_Premultiplied).copy();
|
||||
@@ -217,7 +218,7 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
original.setDevicePixelRatio(1);
|
||||
// Device pixels a document pixel, where Swift's surface counts points.
|
||||
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / region.width());
|
||||
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / shown.width());
|
||||
const auto found = m_modes.find(id);
|
||||
const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal;
|
||||
if (mode != LayerBlendMode::normal)
|
||||
|
||||
@@ -31,6 +31,8 @@ public:
|
||||
std::function<void(QUuid, const QPainter &, QImage &coverage)> adjustmentClip = [](QUuid, const QPainter &, QImage &) {};
|
||||
// Painter units a document pixel: the canvas's zoom.
|
||||
double adjustmentScale = 1;
|
||||
// The document a painter region shows: the canvas maps it.
|
||||
std::function<QRectF(const QRectF &)> adjustmentRegion;
|
||||
|
||||
private:
|
||||
// The pixels beneath, adjusted and mixed back through coverage.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "Rendering/SeparableBlend.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
@@ -22,6 +23,7 @@ float dodge(float backdrop, float source)
|
||||
bool SeparableBlend::needsSurface(LayerBlendMode mode)
|
||||
{
|
||||
switch (mode) {
|
||||
case LayerBlendMode::softLight:
|
||||
case LayerBlendMode::linearBurn:
|
||||
case LayerBlendMode::linearDodge:
|
||||
case LayerBlendMode::vividLight:
|
||||
@@ -39,6 +41,10 @@ bool SeparableBlend::needsSurface(LayerBlendMode mode)
|
||||
float SeparableBlend::channel(LayerBlendMode mode, float backdrop, float source)
|
||||
{
|
||||
switch (mode) {
|
||||
case LayerBlendMode::softLight:
|
||||
// Photoshop's, as Core Image's: the PDF's strays 17 levels.
|
||||
return source <= 0.5f ? 2 * backdrop * source + backdrop * backdrop * (1 - 2 * source)
|
||||
: 2 * backdrop * (1 - source) + std::sqrt(backdrop) * (2 * source - 1);
|
||||
case LayerBlendMode::linearBurn:
|
||||
return std::max(0.0f, backdrop + source - 1);
|
||||
case LayerBlendMode::linearDodge:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
#include "Document/LayerAppearance.h"
|
||||
|
||||
// Swift's SeparableBlend: channel-by-channel modes QPainter lacks.
|
||||
// Swift's SeparableBlend: channel-by-channel modes QPainter lacks or differs on.
|
||||
namespace SeparableBlend {
|
||||
// Blended by hand against a surface, Swift's `needsSurface`.
|
||||
bool needsSurface(LayerBlendMode mode);
|
||||
|
||||
@@ -150,7 +150,8 @@ void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRe
|
||||
return;
|
||||
const double device = LayerRenderer::deviceScale(context);
|
||||
const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth);
|
||||
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level));
|
||||
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
|
||||
transform.radians() == 0);
|
||||
QTransform placement;
|
||||
placement.translate(center.x(), center.y());
|
||||
placement.rotateRadians(transform.radians());
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "CanvasContractFixtures.h"
|
||||
|
||||
// Compositor 1.4's GPU canvas contracts on the CPU canvas: adjustments.
|
||||
namespace {
|
||||
// A canvas over a photo and a smaller, translucent layer.
|
||||
struct Scene : ContractScene {
|
||||
Scene(int width, int height, int canvasWidth, int canvasHeight) : ContractScene(width, height, canvasWidth, canvasHeight)
|
||||
{
|
||||
const QImage photo = noise(width, height, 3);
|
||||
session.insert(ImportedImage(photo, photo, QStringLiteral("Photo")));
|
||||
const QImage top = noise(width / 2, height / 2, 5, 160);
|
||||
session.insert(ImportedImage(top, top, QStringLiteral("Top")));
|
||||
}
|
||||
// An adjustment layer of this kind on top, at 80%.
|
||||
QUuid adjust(const LayerAdjustment &adjustment)
|
||||
{
|
||||
session.addAdjustment(adjustment.kind);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
session.updateAdjustment(id, adjustment);
|
||||
session.setLayerOpacity(0.8);
|
||||
return id;
|
||||
}
|
||||
};
|
||||
|
||||
LayerAdjustment visible(AdjustmentKind kind)
|
||||
{
|
||||
LayerAdjustment adjustment{kind};
|
||||
switch (kind) {
|
||||
case AdjustmentKind::hsv: adjustment.hsvSettings = HueSaturationSettings(120, 30); break;
|
||||
case AdjustmentKind::levels: adjustment.levels.ranges[0].outputWhite = 160; break;
|
||||
case AdjustmentKind::curves: adjustment.curves.channels[0] = {{0, 20}, {120, 170}, {255, 235}}; break;
|
||||
case AdjustmentKind::exposure: adjustment.setExposure(ExposureSettings{.exposure = 0.8, .gamma = 1.2}); break;
|
||||
case AdjustmentKind::colorBalance: adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 40}); break;
|
||||
case AdjustmentKind::gaussianBlur: adjustment.setGaussianRadius(6); break;
|
||||
case AdjustmentKind::motionBlur:
|
||||
adjustment.setResolvedMotionDistance(30);
|
||||
adjustment.setResolvedMotionAngle(30);
|
||||
break;
|
||||
case AdjustmentKind::addNoise:
|
||||
adjustment.setResolvedNoiseAmount(25);
|
||||
adjustment.setResolvedNoiseSeed(7);
|
||||
break;
|
||||
case AdjustmentKind::grain: adjustment.setGrain(GrainSettings{.amount = 60, .size = 3, .seed = 11}); break;
|
||||
default: break;
|
||||
}
|
||||
return adjustment;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AdjustmentCanvasTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void everyAdjustmentShowsAsTheExport_data();
|
||||
void everyAdjustmentShowsAsTheExport();
|
||||
void patternsStayWithTheDocument_data();
|
||||
void patternsStayWithTheDocument();
|
||||
};
|
||||
|
||||
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport_data()
|
||||
{
|
||||
QTest::addColumn<AdjustmentKind>("kind");
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
QTest::newRow(qPrintable(rawValue(kind))) << kind;
|
||||
}
|
||||
|
||||
// Swift's matchesEveryAdjustment: a mask, 80%, one to one.
|
||||
void AdjustmentCanvasTests::everyAdjustmentShowsAsTheExport()
|
||||
{
|
||||
QFETCH(AdjustmentKind, kind);
|
||||
Scene scene(60, 50, 60, 50);
|
||||
const QImage before = scene.exported();
|
||||
const QUuid id = scene.adjust(visible(kind));
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, rampMask(60, 50)); });
|
||||
scene.session.zoom(1);
|
||||
QVERIFY(scene.exported() != before);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
|
||||
}
|
||||
|
||||
void AdjustmentCanvasTests::patternsStayWithTheDocument_data()
|
||||
{
|
||||
QTest::addColumn<AdjustmentKind>("kind");
|
||||
QTest::newRow("addNoise") << AdjustmentKind::addNoise;
|
||||
QTest::newRow("grain") << AdjustmentKind::grain;
|
||||
}
|
||||
|
||||
// Swift's patternsStayWithTheDocument: wherever it sits, however zoomed.
|
||||
void AdjustmentCanvasTests::patternsStayWithTheDocument()
|
||||
{
|
||||
QFETCH(AdjustmentKind, kind);
|
||||
Scene scene(60, 50, 140, 120);
|
||||
scene.adjust(visible(kind));
|
||||
// One to one, and the crisp path's document pixels.
|
||||
for (const double zoom : {1.0, 2.0}) {
|
||||
scene.session.zoom(zoom);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1: %2").arg(zoom).arg(scene.gapToExport())));
|
||||
scene.session.viewport.translate(QSizeF(3, -2));
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QStringLiteral("%1 panned: %2").arg(zoom).arg(scene.gapToExport())));
|
||||
}
|
||||
// Reduced: a whole-pixel pan moves the pattern with the image.
|
||||
scene.session.zoom(0.8);
|
||||
const QImage before = scene.shot();
|
||||
const QPoint corner = scene.origin().toPoint();
|
||||
scene.session.viewport.translate(QSizeF(5, 3));
|
||||
const QImage after = scene.shot();
|
||||
QCOMPARE(scene.origin().toPoint(), corner + QPoint(5, 3));
|
||||
int worst = 0;
|
||||
for (int y = corner.y() + 2; y < corner.y() + 36; ++y) {
|
||||
for (int x = corner.x() + 2; x < corner.x() + 44; ++x) {
|
||||
const QRgb was = before.pixel(x, y), now = after.pixel(x + 5, y + 3);
|
||||
worst = std::max({worst, std::abs(qRed(was) - qRed(now)), std::abs(qGreen(was) - qGreen(now)), std::abs(qBlue(was) - qBlue(now))});
|
||||
}
|
||||
}
|
||||
QVERIFY2(worst <= 1, qPrintable(QString::number(worst)));
|
||||
// A partial repaint shows the whole's pattern, resampled alike.
|
||||
const QRect part(corner + QPoint(18, 12), QSize(20, 15));
|
||||
const QImage piece = scene.canvas.grab(part).toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
QVERIFY(ContractScene::gap(piece, after.copy(part)) <= 2);
|
||||
}
|
||||
|
||||
QTEST_MAIN(AdjustmentCanvasTests)
|
||||
#include "AdjustmentCanvasTests.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);
|
||||
|
||||
@@ -70,11 +70,10 @@ Rgb reference(LayerBlendMode mode, const Rgb &backdrop, const Rgb &source)
|
||||
case LayerBlendMode::multiply: result[channel] = b * s; break;
|
||||
case LayerBlendMode::screen: result[channel] = b + s - b * s; break;
|
||||
case LayerBlendMode::overlay: result[channel] = hardLight(s, b); break;
|
||||
case LayerBlendMode::softLight: {
|
||||
const double d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : std::sqrt(b);
|
||||
result[channel] = s <= 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (d - b);
|
||||
// Photoshop's, as Core Image computes it (Compositor 1.4).
|
||||
case LayerBlendMode::softLight:
|
||||
result[channel] = s <= 0.5 ? 2 * b * s + b * b * (1 - 2 * s) : 2 * b * (1 - s) + std::sqrt(b) * (2 * s - 1);
|
||||
break;
|
||||
}
|
||||
case LayerBlendMode::darken: result[channel] = std::min(b, s); break;
|
||||
case LayerBlendMode::lighten: result[channel] = std::max(b, s); break;
|
||||
case LayerBlendMode::difference: result[channel] = std::abs(b - s); break;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "RenderFixtures.h"
|
||||
#include <QPainter>
|
||||
#include <QRadialGradient>
|
||||
#include <QtTest>
|
||||
|
||||
// Compositor 1.4's blending: stacks, adjustments in a mode, Soft Light.
|
||||
namespace {
|
||||
ImportedImage filled(int width, int height, QColor colour, const QString &name)
|
||||
{
|
||||
const QImage image = solid(width, height, colour.rgba());
|
||||
return ImportedImage(image, image, name);
|
||||
}
|
||||
|
||||
// A canvas at 1:1 over the document.
|
||||
struct Scene {
|
||||
EditorSession session;
|
||||
CanvasView canvas{session};
|
||||
Scene(int width, int height)
|
||||
{
|
||||
session.createDocument(width, height);
|
||||
session.setShowsTransformControls(false);
|
||||
canvas.resize(width + 40, height + 40);
|
||||
session.viewport.resize(QSizeF(width + 40, height + 40), 1, QSizeF(width, height));
|
||||
session.zoom(1);
|
||||
}
|
||||
QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); }
|
||||
// Copy Merged of the whole canvas.
|
||||
QImage merged()
|
||||
{
|
||||
session.selectAll();
|
||||
const QImage image = session.renderMergedPixels().value().image.convertToFormat(QImage::Format_ARGB32);
|
||||
session.deselect();
|
||||
return image;
|
||||
}
|
||||
// The worst channel gap to the export, inside the border.
|
||||
int canvasGap()
|
||||
{
|
||||
const QImage expected = exported(), shot = canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
const QPointF origin = session.viewport.viewPoint(QPointF(0, 0), session.document().value().size());
|
||||
int worst = 0;
|
||||
for (int y = 1; y < expected.height() - 1; ++y) {
|
||||
for (int x = 1; x < expected.width() - 1; ++x) {
|
||||
const QRgb wanted = expected.pixel(x, y), shown = shot.pixel(int(origin.x() + x + 0.5), int(origin.y() + y + 0.5));
|
||||
worst = std::max({worst, std::abs(qRed(wanted) - qRed(shown)), std::abs(qGreen(wanted) - qGreen(shown)),
|
||||
std::abs(qBlue(wanted) - qBlue(shown))});
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
void mode(LayerBlendMode mode)
|
||||
{
|
||||
session.setLayerBlendMode(mode);
|
||||
}
|
||||
};
|
||||
|
||||
std::array<int, 3> rgb(QRgb pixel)
|
||||
{
|
||||
return {qRed(pixel), qGreen(pixel), qBlue(pixel)};
|
||||
}
|
||||
|
||||
bool near(QRgb pixel, std::array<int, 3> expected, int tolerance)
|
||||
{
|
||||
const std::array<int, 3> actual = rgb(pixel);
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
if (std::abs(actual[size_t(channel)] - expected[size_t(channel)]) > tolerance)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QString text(QRgb pixel)
|
||||
{
|
||||
return QStringLiteral("(%1, %2, %3)").arg(qRed(pixel)).arg(qGreen(pixel)).arg(qBlue(pixel));
|
||||
}
|
||||
}
|
||||
|
||||
class BlendingTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void clippingStacksBlendInTheirBasesMode_data();
|
||||
void clippingStacksBlendInTheirBasesMode();
|
||||
void adjustmentsBlendInAnyMode_data();
|
||||
void adjustmentsBlendInAnyMode();
|
||||
void softLightMatchesPhotoshop();
|
||||
void softEdgedLayersBlendTheSame_data();
|
||||
void softEdgedLayersBlendTheSame();
|
||||
};
|
||||
|
||||
void BlendingTests::clippingStacksBlendInTheirBasesMode_data()
|
||||
{
|
||||
QTest::addColumn<LayerBlendMode>("mode");
|
||||
QTest::newRow("linearDodge") << LayerBlendMode::linearDodge;
|
||||
QTest::newRow("colorDodge") << LayerBlendMode::colorDodge;
|
||||
QTest::newRow("multiply") << LayerBlendMode::multiply;
|
||||
}
|
||||
|
||||
// Swift's: the stack takes its base's mode wherever drawn.
|
||||
void BlendingTests::clippingStacksBlendInTheirBasesMode()
|
||||
{
|
||||
QFETCH(LayerBlendMode, mode);
|
||||
Scene scene(100, 100);
|
||||
EditorSession &session = scene.session;
|
||||
session.insert(filled(100, 100, QColor::fromRgbF(0.4f, 0.2f, 0.1f), QStringLiteral("Base")));
|
||||
session.insert(filled(100, 100, QColor::fromRgbF(0.3f, 0.3f, 0.3f), QStringLiteral("Blended")));
|
||||
const QUuid blended = session.activeLayerID().value();
|
||||
scene.mode(mode);
|
||||
session.insert(filled(50, 50, QColor::fromRgbF(0.2f, 0.05f, 0), QStringLiteral("Clipped")), QPointF(25, 25));
|
||||
QVERIFY(session.linkMask(blended, session.activeLayerID().value()));
|
||||
const QImage exported = scene.exported(), merged = scene.merged();
|
||||
// Under the clipped layer, and the stack's base alone.
|
||||
for (const QPoint point : {QPoint(20, 20), QPoint(80, 80)})
|
||||
QCOMPARE(text(merged.pixel(point)), text(exported.pixel(point)));
|
||||
if (mode == LayerBlendMode::linearDodge) {
|
||||
QVERIFY2(near(exported.pixel(20, 20), {153, 64, 26}, 1), qPrintable(text(exported.pixel(20, 20))));
|
||||
QVERIFY2(near(exported.pixel(80, 80), {179, 128, 102}, 1), qPrintable(text(exported.pixel(80, 80))));
|
||||
}
|
||||
QVERIFY(text(exported.pixel(20, 20)) != text(exported.pixel(80, 80)));
|
||||
QVERIFY(scene.canvasGap() <= 1);
|
||||
}
|
||||
|
||||
void BlendingTests::adjustmentsBlendInAnyMode_data()
|
||||
{
|
||||
QTest::addColumn<LayerBlendMode>("mode");
|
||||
QTest::addColumn<bool>("clipped");
|
||||
QTest::addColumn<QList<int>>("expected");
|
||||
// (0.2, 0.4, 0.6) inverted is (0.8, 0.6, 0.4).
|
||||
QTest::newRow("difference") << LayerBlendMode::difference << false << QList<int>{153, 51, 51};
|
||||
QTest::newRow("linearLight") << LayerBlendMode::linearLight << false << QList<int>{204, 153, 102};
|
||||
QTest::newRow("vividLight in a stack") << LayerBlendMode::vividLight << true << QList<int>{128, 128, 128};
|
||||
QTest::newRow("subtract in a stack") << LayerBlendMode::subtract << true << QList<int>{0, 0, 51};
|
||||
}
|
||||
|
||||
// Blended at full coverage, alpha restored, then mixed by opacity.
|
||||
void BlendingTests::adjustmentsBlendInAnyMode()
|
||||
{
|
||||
QFETCH(LayerBlendMode, mode);
|
||||
QFETCH(bool, clipped);
|
||||
QFETCH(QList<int>, expected);
|
||||
Scene scene(40, 30);
|
||||
EditorSession &session = scene.session;
|
||||
session.insert(filled(40, 30, QColor::fromRgbF(0.2f, 0.4f, 0.6f), QStringLiteral("Photo")));
|
||||
const QUuid photo = session.activeLayerID().value();
|
||||
session.addAdjustment(AdjustmentKind::invert);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid adjustment = session.activeLayerID().value();
|
||||
scene.mode(mode);
|
||||
if (clipped)
|
||||
QVERIFY(session.linkMask(photo, adjustment));
|
||||
const QImage exported = scene.exported();
|
||||
const QRgb shown = exported.pixel(10, 10);
|
||||
QVERIFY2(near(shown, {expected[0], expected[1], expected[2]}, 1), qPrintable(text(shown)));
|
||||
QCOMPARE(qAlpha(shown), 255);
|
||||
QCOMPARE(text(scene.merged().pixel(10, 10)), text(shown));
|
||||
QVERIFY(scene.canvasGap() <= 1);
|
||||
// At half opacity the result mixes halfway back.
|
||||
session.selectLayer(adjustment);
|
||||
session.setLayerOpacity(0.5);
|
||||
const QRgb half = scene.exported().pixel(10, 10);
|
||||
QVERIFY2(near(half, {(expected[0] + 51) / 2, (expected[1] + 102) / 2, (expected[2] + 153) / 2}, 1), qPrintable(text(half)));
|
||||
}
|
||||
|
||||
// Photoshop's: 2·b·(1 − s) + √b·(2s − 1) above half.
|
||||
void BlendingTests::softLightMatchesPhotoshop()
|
||||
{
|
||||
const auto blended = [](double backdrop, double source) {
|
||||
Scene scene(10, 10);
|
||||
scene.session.insert(filled(10, 10, QColor::fromRgbF(float(backdrop), float(backdrop), float(backdrop)), QStringLiteral("Base")));
|
||||
scene.session.insert(filled(10, 10, QColor::fromRgbF(float(source), float(source), float(source)), QStringLiteral("Soft")));
|
||||
scene.mode(LayerBlendMode::softLight);
|
||||
const int exported = qRed(scene.exported().pixel(5, 5));
|
||||
if (qRed(scene.merged().pixel(5, 5)) != exported || scene.canvasGap() > 1)
|
||||
return -1;
|
||||
return exported;
|
||||
};
|
||||
// 2·0.5·0.1 + √0.5·0.8 = 0.666: 170.
|
||||
QCOMPARE(blended(0.5, 0.9), 170);
|
||||
// Dark: √(5/255) is 0.140, so 36; the PDF gives 19.
|
||||
QCOMPARE(blended(5 / 255.0, 1), 36);
|
||||
// Below half, 2bs + b²(1 − 2s): 0.1375 here.
|
||||
QCOMPARE(blended(0.25, 0.2), 35);
|
||||
}
|
||||
|
||||
void BlendingTests::softEdgedLayersBlendTheSame_data()
|
||||
{
|
||||
QTest::addColumn<LayerBlendMode>("mode");
|
||||
QTest::newRow("softLight") << LayerBlendMode::softLight;
|
||||
QTest::newRow("overlay") << LayerBlendMode::overlay;
|
||||
QTest::newRow("multiply") << LayerBlendMode::multiply;
|
||||
QTest::newRow("colorDodge") << LayerBlendMode::colorDodge;
|
||||
}
|
||||
|
||||
// A soft brush's layer, mostly partly clear, alike everywhere.
|
||||
void BlendingTests::softEdgedLayersBlendTheSame()
|
||||
{
|
||||
QFETCH(LayerBlendMode, mode);
|
||||
Scene scene(60, 50);
|
||||
const QImage photo = noise(60, 50, 4);
|
||||
scene.session.insert(ImportedImage(photo, photo, QStringLiteral("Photo")));
|
||||
QImage soft(28, 18, QImage::Format_RGBA8888_Premultiplied);
|
||||
soft.fill(Qt::transparent);
|
||||
{
|
||||
QPainter painter(&soft);
|
||||
QRadialGradient gradient(QPointF(14, 9), 11);
|
||||
gradient.setColorAt(0.3, QColor::fromRgbF(1, 0.05f, 0, 1));
|
||||
gradient.setColorAt(1, QColor::fromRgbF(1, 0.05f, 0, 0));
|
||||
painter.fillRect(soft.rect(), gradient);
|
||||
}
|
||||
scene.session.insert(ImportedImage(soft, soft, QStringLiteral("Paint")), QPointF(30, 23));
|
||||
scene.mode(mode);
|
||||
const QImage exported = scene.exported();
|
||||
// Partly clear pixels blend, as do the opaque ones.
|
||||
QVERIFY(exported.pixel(30, 23) != photo.pixel(30, 23) && exported.pixel(23, 23) != photo.pixel(23, 23));
|
||||
QCOMPARE(scene.merged(), exported);
|
||||
QVERIFY(scene.canvasGap() <= 1);
|
||||
}
|
||||
|
||||
QTEST_MAIN(BlendingTests)
|
||||
#include "BlendingTests.moc"
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "RenderFixtures.h"
|
||||
#include "SessionFixtures.h"
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <cmath>
|
||||
#include <QtTest>
|
||||
|
||||
// Compositor 1.4's GPU canvas tests, held against the CPU canvas.
|
||||
struct ContractScene {
|
||||
EditorSession session;
|
||||
CanvasView canvas{session};
|
||||
ContractScene(int width, int height, int canvasWidth, int canvasHeight)
|
||||
{
|
||||
session.createDocument(width, height);
|
||||
session.setShowsTransformControls(false);
|
||||
canvas.resize(canvasWidth, canvasHeight);
|
||||
session.viewport.resize(QSizeF(canvasWidth, canvasHeight), 1, QSizeF(width, height));
|
||||
}
|
||||
QImage shot() { return canvas.grab().toImage().convertToFormat(QImage::Format_ARGB32); }
|
||||
QPointF origin() { return session.viewport.viewPoint(QPointF(0, 0), session.document().value().size()); }
|
||||
QImage exported() { return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_ARGB32); }
|
||||
// What the canvas shows at each document pixel's centre.
|
||||
QImage documentShot()
|
||||
{
|
||||
canvas.synchronizeDisplay();
|
||||
const QImage view = shot();
|
||||
const QSizeF size = session.document().value().size();
|
||||
const double zoom = session.viewport.pointsPerPixel();
|
||||
QImage result(size.toSize(), QImage::Format_ARGB32);
|
||||
for (int y = 0; y < result.height(); ++y) {
|
||||
for (int x = 0; x < result.width(); ++x)
|
||||
result.setPixel(x, y, view.pixel(int(origin().x() + (x + 0.5) * zoom), int(origin().y() + (y + 0.5) * zoom)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// The export over the checkerboard, as the canvas shows it.
|
||||
QImage exportedOnCanvas()
|
||||
{
|
||||
const QImage exported = ImageExporter::render(session.projectSnapshot().value()).image;
|
||||
const double zoom = session.viewport.pointsPerPixel();
|
||||
QImage result(exported.size(), QImage::Format_ARGB32_Premultiplied);
|
||||
for (int y = 0; y < result.height(); ++y) {
|
||||
for (int x = 0; x < result.width(); ++x) {
|
||||
const bool light = (int(std::floor((x + 0.5) * zoom / 10)) + int(std::floor((y + 0.5) * zoom / 10))) % 2 == 0;
|
||||
result.setPixel(x, y, QColor::fromRgbF(light ? 0.35 : 0.30, light ? 0.35 : 0.30, light ? 0.35 : 0.30).rgb());
|
||||
}
|
||||
}
|
||||
QPainter painter(&result);
|
||||
painter.drawImage(QRectF(result.rect()), exported);
|
||||
painter.end();
|
||||
return result.convertToFormat(QImage::Format_ARGB32);
|
||||
}
|
||||
int gapToExport(const QPainterPath &skip = QPainterPath()) { return gap(documentShot(), exportedOnCanvas(), skip); }
|
||||
// The worst channel gap inside the border, `skip` aside.
|
||||
static int gap(const QImage &one, const QImage &other, const QPainterPath &skip = QPainterPath())
|
||||
{
|
||||
int worst = 0;
|
||||
for (int y = 1; y < one.height() - 1; ++y) {
|
||||
for (int x = 1; x < one.width() - 1; ++x) {
|
||||
if (skip.contains(QPointF(x + 0.5, y + 0.5)))
|
||||
continue;
|
||||
const QRgb a = one.pixel(x, y), b = other.pixel(x, y);
|
||||
worst = std::max({worst, std::abs(qRed(a) - qRed(b)), std::abs(qGreen(a) - qGreen(b)), std::abs(qBlue(a) - qBlue(b))});
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
// A band round a path, where overlays draw.
|
||||
static QPainterPath band(const QPainterPath &path, double width = 6)
|
||||
{
|
||||
QPainterPathStroker stroker;
|
||||
stroker.setWidth(width);
|
||||
return stroker.createStroke(path);
|
||||
}
|
||||
};
|
||||
|
||||
// A mask dark on the left, light on the right.
|
||||
inline ImportedImage rampMask(int width, int height)
|
||||
{
|
||||
QImage mask(width, height, QImage::Format_Grayscale8);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
for (int x = 0; x < width; ++x)
|
||||
mask.scanLine(y)[x] = uchar(x * 255 / (width - 1));
|
||||
}
|
||||
return LayerMask::assetFrom(mask);
|
||||
}
|
||||
@@ -83,7 +83,6 @@ private slots:
|
||||
void theLiveCompositeDrawsWhatTheExportDraws();
|
||||
void adjustmentsShowAsTheExportDoes();
|
||||
void anAdjustmentsMaskClipsItOnTheCanvas();
|
||||
void grainFollowsTheViewOnTheCanvas();
|
||||
};
|
||||
|
||||
void CanvasDisplayTests::theBackgroundCheckerboardShadowAndBorderSurroundTheDocument()
|
||||
@@ -411,32 +410,5 @@ void CanvasDisplayTests::anAdjustmentsMaskClipsItOnTheCanvas()
|
||||
session.cancelBrush();
|
||||
}
|
||||
|
||||
void CanvasDisplayTests::grainFollowsTheViewOnTheCanvas()
|
||||
{
|
||||
// Swift's canvas hands Grain the view's region, not the document's.
|
||||
Scene scene(40, 40, 120, 120);
|
||||
EditorSession &session = scene.session;
|
||||
session.insert(filled(40, 40, qRgba(128, 128, 128, 255), "Gray"));
|
||||
session.addAdjustment(AdjustmentKind::grain);
|
||||
session.setAdjustmentEditingID(std::nullopt);
|
||||
LayerAdjustment grain = session.activeLayer().value().adjustment.value();
|
||||
GrainSettings strong = grain.grain();
|
||||
strong.amount = 100;
|
||||
// Pinned: under some seeds the pan keeps one pixel's tone.
|
||||
strong.seed = 7;
|
||||
grain.setGrain(strong);
|
||||
session.updateAdjustment(session.activeLayerID().value(), grain);
|
||||
session.zoom(1);
|
||||
QVERIFY(gapToExport(scene) > 20);
|
||||
// A pan moves the pattern under the same document pixel.
|
||||
const auto pixel = [&scene] {
|
||||
const QPointF origin = scene.origin();
|
||||
return scene.shot().pixel(int(origin.x() + 20.5), int(origin.y() + 20.5));
|
||||
};
|
||||
const QRgb before = pixel();
|
||||
session.viewport.translate(QSizeF(3, 0));
|
||||
QVERIFY(pixel() != before);
|
||||
}
|
||||
|
||||
QTEST_MAIN(CanvasDisplayTests)
|
||||
#include "CanvasDisplayTests.moc"
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "CanvasContractFixtures.h"
|
||||
|
||||
// Compositor 1.4's GPU canvas contracts on the CPU canvas: edits.
|
||||
namespace {
|
||||
QUuid insertNoise(ContractScene &scene, int width, int height, quint32 seed, QPointF centre, int alpha = 255)
|
||||
{
|
||||
const QImage image = noise(width, height, seed, alpha);
|
||||
scene.session.insert(ImportedImage(image, image, QStringLiteral("Noise")), centre);
|
||||
return scene.session.activeLayerID().value();
|
||||
}
|
||||
|
||||
QPainterPath ellipse(const QRectF &rect)
|
||||
{
|
||||
QPainterPath path;
|
||||
path.addEllipse(rect);
|
||||
return path;
|
||||
}
|
||||
|
||||
bool finished(const std::function<void(std::function<void()>)> &start)
|
||||
{
|
||||
bool done = false;
|
||||
start([&done] { done = true; });
|
||||
return QTest::qWaitFor([&done] { return done; }, 20000);
|
||||
}
|
||||
}
|
||||
|
||||
class CanvasEditContractTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void aNewMaskRevealsTheWholeLayer_data();
|
||||
void aNewMaskRevealsTheWholeLayer();
|
||||
void movingPixelsShowAsTheirCommit_data();
|
||||
void movingPixelsShowAsTheirCommit();
|
||||
void aGradientInASelectionShowsAsItsCommit();
|
||||
void aGradientThatCannotFillCancels();
|
||||
void aLiveMaskFromFurtherDownShowsAsTheExport();
|
||||
};
|
||||
|
||||
void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer_data()
|
||||
{
|
||||
QTest::addColumn<double>("rotation");
|
||||
QTest::newRow("upright") << 0.0;
|
||||
QTest::newRow("turned") << 25.0;
|
||||
}
|
||||
|
||||
// Swift's aNewMaskRevealsTheWholeLayer: one white pixel, stretched.
|
||||
void CanvasEditContractTests::aNewMaskRevealsTheWholeLayer()
|
||||
{
|
||||
QFETCH(double, rotation);
|
||||
ContractScene scene(80, 70, 80, 70);
|
||||
const QUuid id = insertNoise(scene, 30, 20, 2, QPointF(40, 35));
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
|
||||
record(snapshot, id).transform = LayerTransform{.origin = {10, 12}, .size = {38, 30}, .rotation = rotation};
|
||||
});
|
||||
scene.session.zoom(1);
|
||||
scene.session.selectLayer(id);
|
||||
const QImage before = scene.exported();
|
||||
scene.session.addLayerMask();
|
||||
QCOMPARE(scene.session.activeLayer().value().mask.value().asset.size(), QSize(1, 1));
|
||||
const QImage after = scene.exported();
|
||||
// Wherever the layer covered its pixel, nothing changes.
|
||||
int covered = 0;
|
||||
for (int y = 0; y < after.height(); ++y) {
|
||||
for (int x = 0; x < after.width(); ++x) {
|
||||
if (qAlpha(before.pixel(x, y)) < 255)
|
||||
continue;
|
||||
++covered;
|
||||
QVERIFY2(ContractScene::gap(before.copy(x - 1, y - 1, 3, 3), after.copy(x - 1, y - 1, 3, 3)) <= 1,
|
||||
qPrintable(QStringLiteral("%1, %2").arg(x).arg(y)));
|
||||
}
|
||||
}
|
||||
QVERIFY(covered > 900);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
|
||||
}
|
||||
|
||||
void CanvasEditContractTests::movingPixelsShowAsTheirCommit_data()
|
||||
{
|
||||
QTest::addColumn<bool>("duplicate");
|
||||
QTest::newRow("moving") << false;
|
||||
QTest::newRow("duplicating") << true;
|
||||
}
|
||||
|
||||
// Swift's matchesWhileMovingPixels, on a masked layer at 60%.
|
||||
void CanvasEditContractTests::movingPixelsShowAsTheirCommit()
|
||||
{
|
||||
QFETCH(bool, duplicate);
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
const QUuid id = insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) {
|
||||
setMask(snapshot, id, rampMask(80, 60));
|
||||
record(snapshot, id).opacity = 0.6;
|
||||
});
|
||||
scene.session.zoom(1);
|
||||
scene.session.selectLayer(id);
|
||||
const QImage still = scene.documentShot();
|
||||
scene.session.applySelection(ellipse(QRectF(20, 15, 30, 24)), SelectionMode::replace, QStringLiteral("Select"));
|
||||
QVERIFY(scene.session.beginPixelMove(duplicate));
|
||||
scene.session.movePixels(QSizeF(9.4, 5.6));
|
||||
const QImage moving = scene.documentShot();
|
||||
QVERIFY(finished([&](std::function<void()> done) { scene.session.finishPixelMove(std::move(done)); }));
|
||||
const QImage moved = scene.documentShot();
|
||||
// The ants run round the moved outline: left out.
|
||||
const QPainterPath ants = ContractScene::band(ellipse(QRectF(29, 21, 30, 24)));
|
||||
QVERIFY(ContractScene::gap(still, moving, ants) > 20);
|
||||
QVERIFY2(ContractScene::gap(moving, moved, ants) <= 1, qPrintable(QString::number(ContractScene::gap(moving, moved, ants))));
|
||||
QVERIFY(scene.gapToExport(ants) <= 1);
|
||||
// A move leaves a hole; a duplicate leaves its pixels.
|
||||
const QRgb left = scene.exported().pixel(24, 27);
|
||||
QCOMPARE(qAlpha(left) == 0, !duplicate);
|
||||
}
|
||||
|
||||
// Swift's matchesWhileDraggingAGradient: inside a selection, at 70%.
|
||||
void CanvasEditContractTests::aGradientInASelectionShowsAsItsCommit()
|
||||
{
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
scene.session.zoom(1);
|
||||
const QImage still = scene.documentShot();
|
||||
const QPainterPath outline = ellipse(QRectF(15, 10, 40, 30));
|
||||
scene.session.applySelection(outline, SelectionMode::replace, QStringLiteral("Select"));
|
||||
GradientSettings settings;
|
||||
settings.opacity = 0.7;
|
||||
settings.style = GradientStyle::foregroundToBackground;
|
||||
scene.session.setGradientSettings(settings);
|
||||
scene.session.selectTool(NavigationTool::gradient);
|
||||
scene.session.beginGradient(QPointF(10, 30));
|
||||
scene.session.moveGradient(std::nullopt, QPointF(70, 30));
|
||||
scene.session.endGradientDrag();
|
||||
const QImage pending = scene.documentShot();
|
||||
QVERIFY(finished([&](std::function<void()> done) { scene.session.commitGradient(std::move(done)); }));
|
||||
const QImage committed = scene.documentShot();
|
||||
// The ants, the gradient's line and its ends: overlays.
|
||||
QPainterPath line;
|
||||
line.moveTo(10, 30);
|
||||
line.lineTo(70, 30);
|
||||
const QPainterPath overlays = ContractScene::band(outline).united(ContractScene::band(line, 30));
|
||||
QVERIFY(ContractScene::gap(still, pending, overlays) > 20);
|
||||
QVERIFY2(ContractScene::gap(pending, committed, overlays) <= 1, qPrintable(QString::number(ContractScene::gap(pending, committed, overlays))));
|
||||
// Outside the selection the photo stays.
|
||||
QCOMPARE(scene.exported().pixel(3, 3), still.pixel(3, 3));
|
||||
}
|
||||
|
||||
// Swift's 5708ed3: a fill that fails cancels with the error.
|
||||
void CanvasEditContractTests::aGradientThatCannotFillCancels()
|
||||
{
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
const QImage before = scene.exported();
|
||||
const int count = scene.session.history.undoCount();
|
||||
scene.session.selectTool(NavigationTool::gradient);
|
||||
scene.session.beginGradient(QPointF(10, 30));
|
||||
QVERIFY(scene.session.gradientEdit());
|
||||
scene.session.gradientEdit()->raster->pixelLimit = 100;
|
||||
scene.session.moveGradient(std::nullopt, QPointF(70, 30));
|
||||
QVERIFY(!scene.session.gradientEdit() && scene.session.brushError().has_value());
|
||||
QCOMPARE(scene.session.history.undoCount(), count);
|
||||
QCOMPARE(scene.exported(), before);
|
||||
}
|
||||
|
||||
// Swift's matchesALiveMaskOutsideAClippingStack: a turned, distant source.
|
||||
void CanvasEditContractTests::aLiveMaskFromFurtherDownShowsAsTheExport()
|
||||
{
|
||||
ContractScene scene(80, 60, 80, 60);
|
||||
insertNoise(scene, 80, 60, 2, QPointF(40, 30));
|
||||
const QUuid source = insertNoise(scene, 40, 30, 4, QPointF(35, 30), 140);
|
||||
rewrite(scene.session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).transform.rotation = 25; });
|
||||
insertNoise(scene, 16, 16, 5, QPointF(60, 20));
|
||||
const QUuid target = insertNoise(scene, 50, 40, 9, QPointF(40, 30));
|
||||
const QImage unlinked = scene.exported();
|
||||
QVERIFY(scene.session.linkMask(source, target));
|
||||
scene.session.zoom(1);
|
||||
QVERIFY(ContractScene::gap(unlinked, scene.exported()) > 20);
|
||||
QVERIFY2(scene.gapToExport() <= 1, qPrintable(QString::number(scene.gapToExport())));
|
||||
}
|
||||
|
||||
QTEST_MAIN(CanvasEditContractTests)
|
||||
#include "CanvasEditContractTests.moc"
|
||||
@@ -10,6 +10,7 @@ private slots:
|
||||
void aStackBlendsOnceWithItsBasesMode();
|
||||
void laterChildrenCoverEarlierOnes();
|
||||
void aStackInheritsThePaintersOpacity();
|
||||
void stacksAndCoverageTakeTheDevicesPixels();
|
||||
};
|
||||
|
||||
void ClippingStackTests::init()
|
||||
@@ -138,5 +139,42 @@ void ClippingStackTests::aStackInheritsThePaintersOpacity()
|
||||
QCOMPARE(reds(surface), (std::array<int, 4>{127, 64, 16, 0}));
|
||||
}
|
||||
|
||||
// Compositor 1.4: surfaces in device pixels keep half units sharp.
|
||||
void ClippingStackTests::stacksAndCoverageTakeTheDevicesPixels()
|
||||
{
|
||||
Scene scene;
|
||||
Layer white = placed(solid(4, 2, qRgba(255, 255, 255, 255)));
|
||||
white.transform = placedAt({0, 0}, {4, 2});
|
||||
Layer stripe = placed(solid(1, 2, qRgba(255, 0, 0, 255)));
|
||||
stripe.transform = placedAt({0.5, 0}, {1, 2});
|
||||
stripe.transform.sampling = LayerSampling::nearest;
|
||||
const QUuid base = scene.add(white);
|
||||
const QUuid child = scene.add(stripe);
|
||||
scene.layers[child].source = base;
|
||||
const auto row = [&](const std::vector<QUuid> &ids) {
|
||||
LiveMaskRenderer live = scene.renderer();
|
||||
live.prepareStacks(ids, [](QUuid) { return std::nullopt; }, [](QUuid) { return LayerBlendMode::normal; });
|
||||
QImage surface = BrushRaster::context(8, 4, false);
|
||||
QPainter painter(&surface);
|
||||
painter.scale(2, 2);
|
||||
for (const QUuid &id : ids)
|
||||
live.drawComposite(id, painter);
|
||||
painter.end();
|
||||
QList<QRgb> pixels;
|
||||
for (int x = 0; x < 4; ++x)
|
||||
pixels << surface.pixel(x, 1);
|
||||
return pixels;
|
||||
};
|
||||
const QRgb red = qRgba(255, 0, 0, 255), clear = qRgba(0, 0, 0, 0), whiteness = qRgba(255, 255, 255, 255);
|
||||
QCOMPARE(row({base, child}), (QList<QRgb>{whiteness, red, red, whiteness}));
|
||||
// Coverage from a source further down: a layer between.
|
||||
const QUuid between = scene.add(placed(solid(2, 2, qRgba(0, 0, 0, 0))));
|
||||
const QUuid target = scene.add(white);
|
||||
scene.layers[child].source = std::nullopt;
|
||||
scene.layers[child].blendMode = LayerBlendMode::normal;
|
||||
scene.layers[target].source = child;
|
||||
QCOMPARE(row({child, between, target}), (QList<QRgb>{clear, whiteness, whiteness, clear}));
|
||||
}
|
||||
|
||||
QTEST_MAIN(ClippingStackTests)
|
||||
#include "ClippingStackTests.moc"
|
||||
@@ -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"
|
||||
@@ -265,6 +265,15 @@ compositor_test(PSDVectorTests)
|
||||
compositor_test(PSDReaderTests)
|
||||
compositor_test(PSDReaderLayerTests)
|
||||
compositor_test(PSDImportTests)
|
||||
compositor_test(PSDAdjustmentTests)
|
||||
compositor_test(SharpCanvasTests)
|
||||
compositor_test(AdjustmentBandsTests)
|
||||
compositor_test(LayerOrderTests)
|
||||
compositor_test(BlendingTests)
|
||||
compositor_test(AdjustmentCanvasTests)
|
||||
compositor_test(CanvasEditContractTests)
|
||||
# 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