diff --git a/CMakeLists.txt b/CMakeLists.txt index afb2fc5..0823826 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/kernels/LevelsPixels.c b/kernels/LevelsPixels.c index a744088..fa32db1 100644 --- a/kernels/LevelsPixels.c +++ b/kernels/LevelsPixels.c @@ -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 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))); + } + } +} diff --git a/kernels/LevelsPixels.h b/kernels/LevelsPixels.h index 2fbb385..3281d6f 100644 --- a/kernels/LevelsPixels.h +++ b/kernels/LevelsPixels.h @@ -2,3 +2,4 @@ #include 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); diff --git a/src/Document/BrushStroke.cpp b/src/Document/BrushStroke.cpp index a5f3d44..ee2bb25 100644 --- a/src/Document/BrushStroke.cpp +++ b/src/Document/BrushStroke.cpp @@ -3,6 +3,7 @@ #include "IO/ImageExporter.h" #include #include +#include #include #include @@ -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 &body) +{ + const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2; + const qsizetype size = (count + bands - 1) / bands; + try { + std::vector indices(static_cast(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(); diff --git a/src/Document/BrushStroke.h b/src/Document/BrushStroke.h index 8c82c01..d5d96a2 100644 --- a/src/Document/BrushStroke.h +++ b/src/Document/BrushStroke.h @@ -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 &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. diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h index 2fd3b9e..9c3ec95 100644 --- a/src/Document/EditorSession+Jobs.h +++ b/src/Document/EditorSession+Jobs.h @@ -104,8 +104,8 @@ struct SessionJobs { FilterSettings settings; // Camera Raw's scope of the grade itself. std::optional 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 placement = std::nullopt; }; struct FilterMade { std::optional asset; diff --git a/src/Document/EditorSession+Model.h b/src/Document/EditorSession+Model.h index d41f704..ed48f36 100644 --- a/src/Document/EditorSession+Model.h +++ b/src/Document/EditorSession+Model.h @@ -75,7 +75,8 @@ struct CanvasDocument { QSizeF size() const { return QSizeF(width, height); } static std::optional validDimension(const QString &value); - std::vector hierarchyEntries() const; + // Drawing order and what shows (LayerOrder), kept between asks. + LayerOrder::Result hierarchy() const; QSet effectiveVisibleIDs() const; QHash effectiveOpacities() const; std::vector renderLayers() const; diff --git a/src/Document/EditorSession+Transform.cpp b/src/Document/EditorSession+Transform.cpp index 5f55454..c4b84e2 100644 --- a/src/Document/EditorSession+Transform.cpp +++ b/src/Document/EditorSession+Transform.cpp @@ -257,8 +257,8 @@ LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const if (const std::optional 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; } diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp index fe4dbe9..ad99f41 100644 --- a/src/Document/Filters+Session.cpp +++ b/src/Document/Filters+Session.cpp @@ -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; } diff --git a/src/Document/Filters.cpp b/src/Document/Filters.cpp index a1330be..7a9e6c7 100644 --- a/src/Document/Filters.cpp +++ b/src/Document/Filters.cpp @@ -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 diff --git a/src/Document/Filters.h b/src/Document/Filters.h index c301f4d..fc0a741 100644 --- a/src/Document/Filters.h +++ b/src/Document/Filters.h @@ -171,10 +171,12 @@ public: // Add Noise's grain, fixed while the panel is open. const quint32 seed = QRandomGenerator::global()->generate(); std::optional preparedPreview; + // Where the prepared preview sits; none: the layer's own place. + std::optional preparedTransform; + // The grown layer `pending` is made from. + std::optional pendingTransform; // The settings the prepared preview was made with. std::optional preparedSettings; - // Moves with each new grid: an older render lands nothing. - quint64 previewSourceVersion = 0; std::optional pending; CameraRawPanel rawPanel; // Vignette on an empty layer: the canvas it fills. diff --git a/src/Document/HueSaturation+Filter.cpp b/src/Document/HueSaturation+Filter.cpp index 8e357f8..8da2e01 100644 --- a/src/Document/HueSaturation+Filter.cpp +++ b/src/Document/HueSaturation+Filter.cpp @@ -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 -#include #include #include -#include +#include +#include +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 &cube, const std::vector &rows) +// The last tables built: building costs more than applying. +struct CubeCache { + std::mutex lock; + std::deque>> 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 built(const HueSaturationSettings &settings) +{ + const std::vector response = HueSaturationFilter::hueResponse(settings); + constexpr int dimension = HueSaturationFilter::dimension; + std::vector 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(c std::vector HueSaturationFilter::cube(const HueSaturationSettings &settings) { - const std::vector response = hueResponse(settings); - std::vector 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 held(cache.lock); + for (const auto &[known, values] : cache.cubes) { + if (known == settings) + return values; } } + std::vector values = built(settings); + const std::lock_guard 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 table; - std::vector 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); diff --git a/src/Document/HueSaturation.h b/src/Document/HueSaturation.h index 5ba7cbc..b302e8c 100644 --- a/src/Document/HueSaturation.h +++ b/src/Document/HueSaturation.h @@ -131,6 +131,8 @@ std::vector hueResponse(const HueSaturationSettings &settings); std::vector 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 &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); } diff --git a/src/Document/LayerAdjustment.cpp b/src/Document/LayerAdjustment.cpp index fb69336..81a634a 100644 --- a/src/Document/LayerAdjustment.cpp +++ b/src/Document/LayerAdjustment.cpp @@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional 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}); diff --git a/src/Document/LayerGroups+Order.cpp b/src/Document/LayerGroups+Order.cpp new file mode 100644 index 0000000..a8c4ebd --- /dev/null +++ b/src/Document/LayerGroups+Order.cpp @@ -0,0 +1,108 @@ +#include "Document/EditorSession.h" +#include "Document/LayerGroups.h" +#include +#include +#include + +namespace { +using Children = std::map, std::vector>; + +void visit(const Children &children, const std::optional &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, LayerOrder::Result>> last; +}; + +Known &known() +{ + static Known value; + return value; +} +} + +LayerOrder::Result LayerOrder::resolve(const std::vector &layers) +{ + std::vector 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 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 held(cache.lock); + cache.last.emplace(std::move(nodes), result); + return result; +} + +LayerOrder::Result CanvasDocument::hierarchy() const +{ + return LayerOrder::resolve(layers); +} + +QSet CanvasDocument::effectiveVisibleIDs() const +{ + return hierarchy().visible; +} + +QHash CanvasDocument::effectiveOpacities() const +{ + // Each layer's opacity and folder, no whole layers copied. + QHash>> 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 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 CanvasDocument::renderLayers() const +{ + const std::vector order = hierarchy().drawn; + if (order.empty()) + return {}; + std::map 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 result; + result.reserve(order.size()); + for (const QUuid id : order) + result.push_back(layers[index.at(id)]); + return result; +} diff --git a/src/Document/LayerGroups.cpp b/src/Document/LayerGroups.cpp index bba445a..b202890 100644 --- a/src/Document/LayerGroups.cpp +++ b/src/Document/LayerGroups.cpp @@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map CanvasDocument::effectiveOpacities() const -{ - std::map byID; - for (const ImageLayer &layer : layers) { - if (!byID.emplace(layer.id, layer).second) - throw std::logic_error("two layers share an id"); - } - QHash 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 CanvasDocument::hierarchyEntries() const -{ - std::vector records; - for (const ImageLayer &layer : layers) - records.push_back(layer.hierarchyRecord()); - return LayerHierarchy::entries(records); -} - -QSet CanvasDocument::effectiveVisibleIDs() const -{ - QSet result; - for (const LayerHierarchy::Entry &entry : hierarchyEntries()) { - if (entry.visible) - result.insert(entry.layer.id); - } - return result; -} - -std::vector CanvasDocument::renderLayers() const -{ - QHash 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 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 &ids, std::optional primary) { dropEffectSelection(); @@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers() } std::vector ordered; std::vector 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. diff --git a/src/Document/LayerGroups.h b/src/Document/LayerGroups.h index 1cc60bf..d334a23 100644 --- a/src/Document/LayerGroups.h +++ b/src/Document/LayerGroups.h @@ -23,3 +23,26 @@ namespace LayerOpacity { using Node = std::function>>(QUuid)>; double effective(double own, std::optional 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 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 order; + // Visible, in folders that are. + QSet visible; + // The layers that show, folders left out, bottom to top. + std::vector drawn; +}; +// Kept until a node changes: the canvas asks each event. +Result resolve(const std::vector &layers); +} diff --git a/src/Document/LayerMask.cpp b/src/Document/LayerMask.cpp index 0e0013e..b34fc1a 100644 --- a/src/Document/LayerMask.cpp +++ b/src/Document/LayerMask.cpp @@ -392,7 +392,7 @@ std::optional 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; diff --git a/src/Document/Levels.cpp b/src/Document/Levels.cpp index 1da0396..3d1aee3 100644 --- a/src/Document/Levels.cpp +++ b/src/Document/Levels.cpp @@ -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 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; diff --git a/src/IO/PSD/PSDDocumentBuilder.cpp b/src/IO/PSD/PSDDocumentBuilder.cpp index 80754a8..dffd8bc 100644 --- a/src/IO/PSD/PSDDocumentBuilder.cpp +++ b/src/IO/PSD/PSDDocumentBuilder.cpp @@ -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 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 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 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; +} diff --git a/src/IO/PSD/PSDDocumentBuilder.h b/src/IO/PSD/PSDDocumentBuilder.h index 0645721..f7fb5ad 100644 --- a/src/IO/PSD/PSDDocumentBuilder.h +++ b/src/IO/PSD/PSDDocumentBuilder.h @@ -15,4 +15,6 @@ namespace PSDDocumentBuilder { // Each pixel record's asset and thumbnail, off the UI thread. std::map assets(const PSDDocument &document); PSDImport makeImport(const PSDDocument &document, const std::map &assets = {}); +// The stored patch on the layer's grid; none without memory. +std::optional maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas); } diff --git a/src/IO/PSD/PSDReader+Adjustments.cpp b/src/IO/PSD/PSDReader+Adjustments.cpp index 2ef0b1a..83c4c54 100644 --- a/src/IO/PSD/PSDReader+Adjustments.cpp +++ b/src/IO/PSD/PSDReader+Adjustments.cpp @@ -1,6 +1,7 @@ #include "IO/PSD/PSDReader.h" #include #include +#include namespace { quint16 u16(const QByteArray &data, qsizetype at) @@ -12,21 +13,23 @@ qint16 i16(const QByteArray &data, qsizetype at) { return qFromBigEndian(data.constData() + at); } +} -std::optional levels(const QByteArray &data) +std::optional 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 curves(const QByteArray &data) { if (data.size() < 5) @@ -66,22 +69,33 @@ std::optional curves(const QByteArray &data) return std::nullopt; return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings}; } +} -std::optional hue(const QByteArray &data) +std::optional 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 PSDAdjustments::parse(const std::map &extra) { diff --git a/src/IO/PSD/PSDReader+Records.cpp b/src/IO/PSD/PSDReader+Records.cpp index 4b55aeb..008e342 100644 --- a/src/IO/PSD/PSDReader+Records.cpp +++ b/src/IO/PSD/PSDReader+Records.cpp @@ -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; diff --git a/src/IO/PSD/PSDReader+Records.h b/src/IO/PSD/PSDReader+Records.h index 64b1065..dd10d9d 100644 --- a/src/IO/PSD/PSDReader+Records.h +++ b/src/IO/PSD/PSDReader+Records.h @@ -50,6 +50,8 @@ struct RawLayer { std::map 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; diff --git a/src/IO/PSD/PSDReader.cpp b/src/IO/PSD/PSDReader.cpp index bdec74d..b0c47b2 100644 --- a/src/IO/PSD/PSDReader.cpp +++ b/src/IO/PSD/PSDReader.cpp @@ -80,6 +80,8 @@ std::vector assemble(const std::vector &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) diff --git a/src/IO/PSD/PSDReader.h b/src/IO/PSD/PSDReader.h index e92d3e3..a9c22ae 100644 --- a/src/IO/PSD/PSDReader.h +++ b/src/IO/PSD/PSDReader.h @@ -21,4 +21,8 @@ extern const std::set adjustmentKeys; // Levels, Curves and Hue/Saturation layers, as Swift reads them. namespace PSDAdjustments { std::optional parse(const std::map &extra); +// 'levl': each channel's levels, gamma in hundredths. +std::optional levels(const QByteArray &data); +// 'hue2': Colorize or the Master, then each range's values. +std::optional hue(const QByteArray &data); } diff --git a/src/IO/PSD/PSDTypes.h b/src/IO/PSD/PSDTypes.h index a49d965..0edafa2 100644 --- a/src/IO/PSD/PSDTypes.h +++ b/src/IO/PSD/PSDTypes.h @@ -38,6 +38,9 @@ struct PSDRecord { QRectF bounds; std::optional image; std::optional mask; + // Where `mask` sits, and Photoshop's value everywhere else. + QRectF maskBounds; + uchar maskDefault = 255; bool maskEnabled = true; bool maskLinked = true; std::optional adjustment; diff --git a/src/Rendering/AdjustmentSurface.cpp b/src/Rendering/AdjustmentSurface.cpp index d60707e..7eafa33 100644 --- a/src/Rendering/AdjustmentSurface.cpp +++ b/src/Rendering/AdjustmentSurface.cpp @@ -1,6 +1,7 @@ #include "Rendering/AdjustmentSurface.h" #include "Document/BrushStroke.h" #include "Logging.h" +#include "Rendering/LayerRenderer.h" #include void AdjustmentSurface::draw(QPainter &context, const std::function &body, double padding, qint64 pixelBudget) @@ -15,11 +16,18 @@ void AdjustmentSurface::draw(QPainter &context, const std::function 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::functionisEnabled) diff --git a/src/Rendering/LayerRenderer.cpp b/src/Rendering/LayerRenderer.cpp index 114680c..44d8e8f 100644 --- a/src/Rendering/LayerRenderer.cpp +++ b/src/Rendering/LayerRenderer.cpp @@ -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); } diff --git a/src/Rendering/LayerRenderer.h b/src/Rendering/LayerRenderer.h index 1bcc29e..3386756 100644 --- a/src/Rendering/LayerRenderer.h +++ b/src/Rendering/LayerRenderer.h @@ -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 &body, const std::function &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); diff --git a/src/Rendering/LiveMaskRenderer.cpp b/src/Rendering/LiveMaskRenderer.cpp index 9bfff0f..f735ca3 100644 --- a/src/Rendering/LiveMaskRenderer.cpp +++ b/src/Rendering/LiveMaskRenderer.cpp @@ -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) diff --git a/src/Rendering/LiveMaskRenderer.h b/src/Rendering/LiveMaskRenderer.h index 6bbafc8..312a348 100644 --- a/src/Rendering/LiveMaskRenderer.h +++ b/src/Rendering/LiveMaskRenderer.h @@ -31,6 +31,8 @@ public: std::function 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 adjustmentRegion; private: // The pixels beneath, adjusted and mixed back through coverage. diff --git a/src/Rendering/SeparableBlend.cpp b/src/Rendering/SeparableBlend.cpp index d8779ce..1c232be 100644 --- a/src/Rendering/SeparableBlend.cpp +++ b/src/Rendering/SeparableBlend.cpp @@ -1,5 +1,6 @@ #include "Rendering/SeparableBlend.h" #include +#include #include 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: diff --git a/src/Rendering/SeparableBlend.h b/src/Rendering/SeparableBlend.h index a603e01..595b84e 100644 --- a/src/Rendering/SeparableBlend.h +++ b/src/Rendering/SeparableBlend.h @@ -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); diff --git a/src/Rendering/TiledLayerRenderer.cpp b/src/Rendering/TiledLayerRenderer.cpp index 7f59940..d9a56a7 100644 --- a/src/Rendering/TiledLayerRenderer.cpp +++ b/src/Rendering/TiledLayerRenderer.cpp @@ -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()); diff --git a/tests/AdjustmentBandsTests.cpp b/tests/AdjustmentBandsTests.cpp new file mode 100644 index 0000000..4664e7e --- /dev/null +++ b/tests/AdjustmentBandsTests.cpp @@ -0,0 +1,124 @@ +#include "Document/BrushStroke.h" +#include "Document/HueSaturation.h" +#include "Document/Levels.h" +#include +#include +#include +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 bytes(const QImage &image) +{ + std::vector 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 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 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{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 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 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> 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" diff --git a/tests/AdjustmentCanvasTests.cpp b/tests/AdjustmentCanvasTests.cpp new file mode 100644 index 0000000..420edc9 --- /dev/null +++ b/tests/AdjustmentCanvasTests.cpp @@ -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("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("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" diff --git a/tests/AdjustmentSurfaceTests.cpp b/tests/AdjustmentSurfaceTests.cpp index 95391d2..336e77c 100644 --- a/tests/AdjustmentSurfaceTests.cpp +++ b/tests/AdjustmentSurfaceTests.cpp @@ -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); diff --git a/tests/BlendModeTests.cpp b/tests/BlendModeTests.cpp index c2878ef..c9ca8b9 100644 --- a/tests/BlendModeTests.cpp +++ b/tests/BlendModeTests.cpp @@ -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; diff --git a/tests/BlendingTests.cpp b/tests/BlendingTests.cpp new file mode 100644 index 0000000..41d1944 --- /dev/null +++ b/tests/BlendingTests.cpp @@ -0,0 +1,220 @@ +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include +#include +#include + +// 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 rgb(QRgb pixel) +{ + return {qRed(pixel), qGreen(pixel), qBlue(pixel)}; +} + +bool near(QRgb pixel, std::array expected, int tolerance) +{ + const std::array 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("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("mode"); + QTest::addColumn("clipped"); + QTest::addColumn>("expected"); + // (0.2, 0.4, 0.6) inverted is (0.8, 0.6, 0.4). + QTest::newRow("difference") << LayerBlendMode::difference << false << QList{153, 51, 51}; + QTest::newRow("linearLight") << LayerBlendMode::linearLight << false << QList{204, 153, 102}; + QTest::newRow("vividLight in a stack") << LayerBlendMode::vividLight << true << QList{128, 128, 128}; + QTest::newRow("subtract in a stack") << LayerBlendMode::subtract << true << QList{0, 0, 51}; +} + +// Blended at full coverage, alpha restored, then mixed by opacity. +void BlendingTests::adjustmentsBlendInAnyMode() +{ + QFETCH(LayerBlendMode, mode); + QFETCH(bool, clipped); + QFETCH(QList, 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("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" diff --git a/tests/BlurNoiseAdjustmentTests.cpp b/tests/BlurNoiseAdjustmentTests.cpp index 2948147..2be6384 100644 --- a/tests/BlurNoiseAdjustmentTests.cpp +++ b/tests/BlurNoiseAdjustmentTests.cpp @@ -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); diff --git a/tests/CanvasContractFixtures.h b/tests/CanvasContractFixtures.h new file mode 100644 index 0000000..ec128dc --- /dev/null +++ b/tests/CanvasContractFixtures.h @@ -0,0 +1,89 @@ +#pragma once +#include "Rendering/EditorCanvas.h" +#include "IO/ImageExporter.h" +#include "RenderFixtures.h" +#include "SessionFixtures.h" +#include +#include +#include +#include + +// 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); +} diff --git a/tests/CanvasDisplayTests.cpp b/tests/CanvasDisplayTests.cpp index cd237af..2812799 100644 --- a/tests/CanvasDisplayTests.cpp +++ b/tests/CanvasDisplayTests.cpp @@ -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" diff --git a/tests/CanvasEditContractTests.cpp b/tests/CanvasEditContractTests.cpp new file mode 100644 index 0000000..23540b0 --- /dev/null +++ b/tests/CanvasEditContractTests.cpp @@ -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)> &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("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("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 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 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" diff --git a/tests/ClippingStackTests.cpp b/tests/ClippingStackTests.cpp index e152709..3e223b9 100644 --- a/tests/ClippingStackTests.cpp +++ b/tests/ClippingStackTests.cpp @@ -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{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 &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 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{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{clear, whiteness, whiteness, clear})); +} + QTEST_MAIN(ClippingStackTests) #include "ClippingStackTests.moc" diff --git a/tests/FilterSessionTests.cpp b/tests/FilterSessionTests.cpp index a5c70f9..4c54659 100644 --- a/tests/FilterSessionTests.cpp +++ b/tests/FilterSessionTests.cpp @@ -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(); diff --git a/tests/FilterTests.cpp b/tests/FilterTests.cpp index 9523e8d..0d6d518 100644 --- a/tests/FilterTests.cpp +++ b/tests/FilterTests.cpp @@ -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) { diff --git a/tests/FinishingFilterTests.cpp b/tests/FinishingFilterTests.cpp index 988c7dd..0219e01 100644 --- a/tests/FinishingFilterTests.cpp +++ b/tests/FinishingFilterTests.cpp @@ -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 &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 &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() diff --git a/tests/GroupTests.cpp b/tests/GroupTests.cpp index 56abe7e..066a66a 100644 --- a/tests/GroupTests.cpp +++ b/tests/GroupTests.cpp @@ -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{folder.id, child.id, open.id, shown.id, top.id})); + QCOMPARE(document.hierarchy().drawn, (std::vector{shown.id, top.id})); QCOMPARE(document.effectiveVisibleIDs(), QSet({open.id, shown.id, top.id})); const std::vector rendered = document.renderLayers(); QCOMPARE(int(rendered.size()), 2); diff --git a/tests/HueSaturationFailureTests.cpp b/tests/HueSaturationFailureTests.cpp index 81de930..937b17b 100644 --- a/tests/HueSaturationFailureTests.cpp +++ b/tests/HueSaturationFailureTests.cpp @@ -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)); } diff --git a/tests/HueSaturationTests.cpp b/tests/HueSaturationTests.cpp index 4455d7c..9d67e12 100644 --- a/tests/HueSaturationTests.cpp +++ b/tests/HueSaturationTests.cpp @@ -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" diff --git a/tests/LayerOrderTests.cpp b/tests/LayerOrderTests.cpp new file mode 100644 index 0000000..564c75e --- /dev/null +++ b/tests/LayerOrderTests.cpp @@ -0,0 +1,143 @@ +#include "Document/EditorSession.h" +#include +#include + +// Swift's LayerOrder: the hierarchy kept until what shapes it changes. +namespace { +// Nested folders, some hidden, built from a fixed seed. +std::vector tree(int count) +{ + QRandomGenerator random(11); + std::vector layers; + std::vector 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 records(const std::vector &layers) +{ + std::vector 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 layers = tree(60); + const LayerOrder::Result result = LayerOrder::resolve(layers); + std::vector order, drawn; + QSet 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{inside.id, top.id})); + // A hidden folder hides what it holds. + document.layers[0].isVisible = false; + QCOMPARE(document.hierarchy().drawn, std::vector{top.id}); + QCOMPARE(document.effectiveVisibleIDs(), QSet{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{folder.id, inside.id, top.id})); + document.layers[1].parentID = top.id; + document.layers[2].isGroup = true; + QCOMPARE(document.hierarchy().order, (std::vector{folder.id, top.id, inside.id})); + QCOMPARE(document.hierarchy().drawn, std::vector{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{folder.id, document.layers[2].id, inside.id})); +} + +void LayerOrderTests::sixtyFourFoldersDeepAtMost() +{ + std::vector 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 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" diff --git a/tests/PSDAdjustmentTests.cpp b/tests/PSDAdjustmentTests.cpp new file mode 100644 index 0000000..cb96e51 --- /dev/null +++ b/tests/PSDAdjustmentTests.cpp @@ -0,0 +1,155 @@ +#include "Document/BrushStroke.h" +#include "IO/PSD/PSDDocumentBuilder.h" +#include "IO/PSD/PSDReader.h" +#include + +// Swift's PSDAdjustmentTests: adjustments and masks as Photoshop writes them. +namespace { +QByteArray shorts(const std::vector &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 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(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" diff --git a/tests/PSDReaderFixtures.h b/tests/PSDReaderFixtures.h index 515c624..1ffcca3 100644 --- a/tests/PSDReaderFixtures.h +++ b/tests/PSDReaderFixtures.h @@ -21,6 +21,7 @@ struct Record { uchar flags = 0; std::optional mask; uchar maskFlags = 0; + uchar maskDefault = 255; QByteArray name = "Layer"; std::vector extras; }; @@ -81,7 +82,7 @@ inline QByteArray file(const std::vector &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 { diff --git a/tests/PSDReaderTests.cpp b/tests/PSDReaderTests.cpp index 293b5ec..0643dab 100644 --- a/tests/PSDReaderTests.cpp +++ b/tests/PSDReaderTests.cpp @@ -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{"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}})); diff --git a/tests/SharpCanvasTests.cpp b/tests/SharpCanvasTests.cpp new file mode 100644 index 0000000..6611d5e --- /dev/null +++ b/tests/SharpCanvasTests.cpp @@ -0,0 +1,81 @@ +#include "Document/BrushStroke.h" +#include "Rendering/TiledLayerRenderer.h" +#include +#include + +// 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; + +// 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(100, 4, image, QRectF(0, 0, 100, 4), std::vector{}); + const std::vector 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" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index a62c962..ab48bd0 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -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)