OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
// Swift's AdjustmentEditing extension: the colour editors write layer settings.
public:
// Draws what lies beneath off the UI thread; `done` follows.
void beginAdjustmentEditing(QUuid id, std::function<void()> done = {});
// Writes the edited settings, or the original with Preview off.
bool previewAdjustmentEditing(bool preview);
// OK keeps the edited settings; Cancel puts the original back.
bool finishAdjustmentEditing(bool commit);
private:
LayerAdjustment editedAdjustment() const;
void openAdjustmentEditor(QUuid id, const LayerAdjustment &original, const AdjustmentInput &made);
+147
View File
@@ -0,0 +1,147 @@
#include "Document/EditorSession.h"
#include "Document/LayerGroups.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QtConcurrent>
// Swift's AdjustmentEditing.swift: pixels beneath feed histogram and sampling.
void EditorSession::beginAdjustmentEditing(QUuid id, std::function<void()> done)
{
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (m_adjustmentEditingID != id || m_adjustmentOriginal || m_levels || m_hueSaturation || m_filterEdit || index < 0
|| !m_document->layers[size_t(index)].adjustment) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
const LayerAdjustment original = *m_document->layers[size_t(index)].adjustment;
ProjectSnapshot source = projectSnapshot().value();
// Records stay for live masks and folders; beneath alone shows.
QSet<QUuid> underneath;
for (const LayerHierarchy::Entry &entry : LayerHierarchy::entries(source.manifest.layers)) {
if (entry.layer.id == id)
break;
underneath.insert(entry.layer.id);
}
for (ProjectLayerRecord &layer : source.manifest.layers) {
if (!underneath.contains(layer.id))
layer.isVisible = false;
}
// A watcher per call, as Swift's calls are tasks.
auto *watcher = new QFutureWatcher<AdjustmentInput>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, id, original, done = std::move(done)] {
watcher->deleteLater();
openAdjustmentEditor(id, original, watcher->result());
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
});
// A painted layer flattens there, where a failure is caught.
watcher->setFuture(QtConcurrent::run([source]() -> AdjustmentInput {
try {
const QImage image = ImageExporter::render(source).image;
return {ImportedImage(image, PixelAdjust::thumbnail(image), QStringLiteral("Adjustment input")), std::nullopt};
} catch (const ExportError &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::openAdjustmentEditor(QUuid id, const LayerAdjustment &original, const AdjustmentInput &made)
{
// Swift's guard after the await; an open editor wins.
if (m_adjustmentEditingID != id || m_adjustmentOriginal)
return;
std::optional<QString> failure = made.failure;
// Beyond Swift: an edit begun meanwhile keeps its editor.
if (!failure && (m_levels || m_hueSaturation || m_filterEdit)) {
qCWarning(lcApp) << "an adjustment's editor gave way to an edit begun meanwhile";
m_adjustmentEditingID.reset();
resumeFileRequests();
notify();
return;
}
if (!failure) {
try {
const ImageLayer layer(made.asset.value(), QPointF(0, 0));
switch (original.kind) {
case AdjustmentKind::levels:
m_levels.emplace(layer, std::nullopt);
m_levels->settings = original.levels;
countLevelsHistogram();
break;
case AdjustmentKind::hsv:
m_hueSaturation.emplace(layer.id, made.asset.value(), std::nullopt, layer.transform);
m_hueSaturation->settings = original.resolvedHSV();
break;
case AdjustmentKind::curves:
case AdjustmentKind::exposure:
case AdjustmentKind::gradientMap:
case AdjustmentKind::grain: {
FilterSettings settings;
settings.curves = original.curves;
settings.exposure = original.exposure();
settings.gradientMap = original.gradientMap();
settings.grain = original.grain();
m_filterEdit.emplace(filterKind(original.kind).value(), layer, std::nullopt, settings, std::nullopt);
break;
}
}
} catch (const ExportError &error) {
failure = QString::fromUtf8(error.what());
}
}
if (failure) {
m_adjustmentEditingID.reset();
resumeFileRequests();
setBrushError(failure);
return;
}
m_adjustmentOriginal = original;
beginEdit(QStringLiteral("Edit %1 Adjustment").arg(rawValue(original.kind)));
}
// The kind's edit lives as long as the original does.
LayerAdjustment EditorSession::editedAdjustment() const
{
LayerAdjustment value = m_adjustmentOriginal.value();
switch (value.kind) {
case AdjustmentKind::levels: value.levels = m_levels.value().settings; break;
case AdjustmentKind::hsv: value.hsvSettings = m_hueSaturation.value().settings; break;
case AdjustmentKind::curves: value.curves = m_filterEdit.value().settings.curves; break;
case AdjustmentKind::exposure: value.setExposure(m_filterEdit.value().settings.exposure); break;
case AdjustmentKind::gradientMap: value.setGradientMap(m_filterEdit.value().settings.gradientMap); break;
case AdjustmentKind::grain: value.setGrain(m_filterEdit.value().settings.grain); break;
}
return value;
}
bool EditorSession::previewAdjustmentEditing(bool preview)
{
if (!m_adjustmentEditingID || !m_adjustmentOriginal)
return false;
updateAdjustment(*m_adjustmentEditingID, preview ? editedAdjustment() : *m_adjustmentOriginal);
return true;
}
bool EditorSession::finishAdjustmentEditing(bool commit)
{
if (!m_adjustmentEditingID || !m_adjustmentOriginal)
return false;
updateAdjustment(*m_adjustmentEditingID, commit ? editedAdjustment() : *m_adjustmentOriginal);
m_hueSampleMode.reset();
m_hueTargeting = false;
m_hueTargetDrag.reset();
m_levels.reset();
m_hueSaturation.reset();
m_filterEdit.reset();
endEdit();
m_adjustmentOriginal.reset();
m_adjustmentEditingID.reset();
resumeFileRequests();
++m_canvasFocusRequest;
++m_brushRevision;
notify();
return true;
}
+4
View File
@@ -0,0 +1,4 @@
// Swift's BlurTool extension: what a Blur stroke paints.
public:
// The active layer, or its mask, softened at document size.
std::optional<QImage> blurSample(const CanvasDocument &document, bool mask = false) const;
+52
View File
@@ -0,0 +1,52 @@
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath>
// Swift's BlurTool extension: the layer, or its mask, softened.
std::optional<QImage> EditorSession::blurSample(const CanvasDocument &document, bool mask) const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!layer)
return std::nullopt;
// The softening follows the brush's size.
const double sigma = std::min(30.0, std::max(1.5, m_brushSettings.diameter / 10));
try {
if (mask) {
if (!layer->mask)
return std::nullopt;
QImage context = BrushRaster::context(document.width, document.height, true);
// Past its pixels a mask keeps its edge tone.
const int tone = int(std::round(LayerMask::background(layer->mask->asset.thumbnail) * 255));
context.fill(QColor(tone, tone, tone));
QPainter painter(&context);
// Coverage only adds white: the mask's area starts black.
const LayerTransform placement = layer->maskTransform();
painter.save();
QTransform box;
box.translate(placement.center().x(), placement.center().y());
box.rotateRadians(placement.radians());
painter.setTransform(box, true);
painter.fillRect(QRectF(-placement.size.width() / 2, -placement.size.height() / 2, placement.size.width(), placement.size.height()),
Qt::black);
painter.restore();
LayerRenderer::drawCoverage(layer->mask->asset.image(), placement, painter);
painter.end();
return PixelAdjust::gaussianBlur(context, sigma, true);
}
if (!layer->asset)
return std::nullopt;
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
const LayerTransform transform = displayedTransform(*layer);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
painter.end();
return PixelAdjust::gaussianBlur(context, sigma, false);
} catch (const ExportError &error) {
qCWarning(lcApp) << "the Blur tool cannot sample the layer:" << error.what();
return std::nullopt;
}
}
+180
View File
@@ -0,0 +1,180 @@
#include "Document/BrushStroke.h"
#include "Document/LayerMask.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Rendering/RasterSnapshot.h"
#include <QRandomGenerator>
#include <cstring>
#include <new>
extern "C" {
#include "BrushPixels.h"
#include "HealPixels.h"
}
// The stroke as a layer: bounds, placement, the commit's image.
namespace {
QRectF integral(const QRectF &rect)
{
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
}
// Half-open bounds of the pixels that are there, or empty.
QRectF alphaBounds(const QImage &pixels)
{
size_t edges[4] = {0, 0, 0, 0};
brush_alpha_bounds(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), edges);
if (edges[2] <= edges[0] || edges[3] <= edges[1])
return QRectF();
return QRectF(edges[0], edges[1], edges[2] - edges[0], edges[3] - edges[1]);
}
void drawSource(const ImportedImage &source, const QRectF &rect, QPainter &painter)
{
if (source.raster)
source.raster->draw(rect, painter);
else
BrushRaster::draw(source.image(), rect, painter);
}
}
QRectF BrushStroke::committedBounds() const
{
return integral(m_allocatedBounds.value_or(sourceRect));
}
LayerTransform BrushStroke::committedTransform() const
{
return transform(committedBounds());
}
LayerTransform BrushStroke::transform(const QRectF &bounds) const
{
const QPointF center = pixelToDocument.map(bounds.center());
LayerTransform result = paintTransform;
result.size = {bounds.width() * paintTransform.size.width() / width, bounds.height() * paintTransform.size.height() / height};
result.origin = {center.x() - result.size.width() / 2, center.y() - result.size.height() / 2};
return result;
}
// Swift's heal: the painted spot rebuilt from nearby texture.
void BrushStroke::heal()
{
if (!settings.healing || isMask)
return;
std::optional<QRectF> painted;
for (const auto &[key, coverage] : m_coverage) {
long edges[4];
heal_coverage_bounds(coverage.constBits(), size_t(coverage.width()), size_t(coverage.height()), size_t(coverage.bytesPerLine()), edges);
if (edges[2] <= edges[0] || edges[3] <= edges[1])
continue;
const QRectF rect = QRectF(edges[0], edges[1], edges[2] - edges[0], edges[3] - edges[1]).translated(m_tiles.at(key).rect.topLeft());
painted = painted ? painted->united(rect) : rect;
}
if (!painted)
return;
// Room for the kernel's search, about three spots away.
const double reach = (std::max(painted->width(), painted->height()) + 32) * 3.2;
const QRect region = painted->adjusted(-reach, -reach, reach, reach).intersected(QRectF(0, 0, width, height)).toAlignedRect();
QImage pixels = BrushRaster::context(region.width(), region.height(), false);
{
QPainter painter(&pixels);
const QRectF placed = sourceRect.translated(-region.topLeft());
if (m_source && m_source->raster)
m_source->raster->draw(placed, painter);
else if (m_source)
BrushRaster::draw(m_source->image(), placed, painter);
}
// The kernel reads coverage rows packed, as Swift's contexts are.
const std::unique_ptr<uint8_t[]> painting(new (std::nothrow) uint8_t[size_t(region.width()) * size_t(region.height())]());
if (!painting)
throw ExportError(ExportError::Kind::render);
for (const auto &[key, coverage] : m_coverage) {
const QRect tile = m_tiles.at(key).rect.toRect().translated(-region.topLeft());
const QRect within = tile.intersected(QRect(QPoint(0, 0), region.size()));
for (int y = within.top(); y <= within.bottom(); ++y)
std::memcpy(painting.get() + size_t(y) * size_t(region.width()) + size_t(within.left()),
coverage.constScanLine(y - tile.top()) + (within.left() - tile.left()), size_t(within.width()));
}
if (spot_heal(pixels.bits(), painting.get(), size_t(region.width()), size_t(region.height()), size_t(pixels.bytesPerLine()),
float(settings.opacity), int(settings.healingMode), QRandomGenerator::global()->generate()) != 0)
throw ProjectError(ProjectError::Kind::tooLarge);
for (const auto &[key, coverage] : m_coverage) {
Tile &tile = m_tiles.at(key);
QImage healed = tile.base;
healed.detach();
if (healed.isNull())
throw ExportError(ExportError::Kind::render);
{
QPainter painter(&healed);
BrushRaster::draw(pixels, QRectF(region).translated(-tile.rect.topLeft()), painter);
}
// Swift clips the context; the selection blends here instead.
tile.context = selectionClip ? PixelAdjust::blend(healed, tile.base, *selectionClip,
QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument, false)
: healed;
tile.image = tile.context;
}
}
// Painting only adds alpha: the touched tiles alone are inspected.
PaintSnapshot BrushStroke::paintSnapshot() const
{
std::optional<QRectF> bounds = m_source ? std::optional(sourceRect) : std::nullopt;
for (const auto &[key, tile] : m_tiles) {
if (isMask)
break;
const QRectF rect = alphaBounds(tile.context);
if (rect.isEmpty())
continue;
const QRectF placed = rect.translated(tile.rect.topLeft());
bounds = bounds ? std::optional(bounds->united(placed)) : std::optional(placed);
}
const QRectF crop = bounds.value_or(committedBounds());
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(m_source, sourceRect, patches(), crop, isMask);
return {ImportedImage(raster, raster->thumbnail(), layer.name), transform(crop), crop};
}
BrushCommit::Input BrushStroke::commitInput() const
{
const QRectF bounds = committedBounds();
std::vector<BrushPatch> shifted;
for (const BrushPatch &patch : patches())
shifted.push_back({patch.rect.translated(-bounds.topLeft()), patch.image});
return {int(bounds.width()), int(bounds.height()), m_source, shifted, isMask, layer.name, sourceRect.translated(-bounds.topLeft())};
}
ImportedImage BrushCommit::expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop)
{
if (input.sourceRect == crop)
return asset;
QImage context = BrushRaster::context(int(crop.width()), int(crop.height()), true);
// New canvas area has no mask; old coverage stays aligned.
context.fill(Qt::white);
QPainter painter(&context);
drawSource(asset, input.sourceRect.translated(-crop.topLeft()), painter);
painter.end();
return LayerMask::assetFrom(context);
}
BrushCommit::Output BrushCommit::render(const Input &input)
{
QImage context = BrushRaster::context(input.width, input.height, input.mask);
QPainter painter(&context);
if (input.source)
drawSource(*input.source, input.sourceRect, painter);
for (const BrushPatch &patch : input.patches)
BrushRaster::draw(patch.image, patch.rect, painter);
painter.end();
const QRectF fullBounds(0, 0, input.width, input.height);
if (input.mask)
return {LayerMask::assetFrom(context), fullBounds};
// Every pixel with alpha stays, the soft rim included.
const QRectF found = alphaBounds(context);
const QRectF crop = found.isEmpty() ? fullBounds : found;
const QImage image = crop == fullBounds ? context : context.copy(crop.toRect());
if (image.isNull())
throw ExportError(ExportError::Kind::render);
return {ImportedImage(image, PixelAdjust::thumbnail(image), input.name), crop};
}
+466
View File
@@ -0,0 +1,466 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Rendering/RasterSnapshot.h"
#include <QtConcurrent>
#include <cmath>
#include <functional>
#include <stdexcept>
// The stroke's geometry, samples, tiles and coverage.
namespace {
QRectF integral(const QRectF &rect)
{
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
}
// The clone through coverage and a clip: over, or copied.
void fillClone(const QImage &sample, const QImage &coverage, double alpha, QImage &context, const QImage &clip, bool replaces)
{
const bool gray = context.format() == QImage::Format_Grayscale8;
if (sample.format() != QImage::Format_RGBA8888_Premultiplied || (gray && replaces))
throw std::logic_error("a clone samples premultiplied colour, and a mask never copies");
const int channels = gray ? 1 : 4;
for (int y = 0; y < coverage.height(); ++y) {
const uchar *mask = coverage.constScanLine(y), *source = sample.constScanLine(y);
const uchar *within = clip.isNull() ? nullptr : clip.constScanLine(y);
uchar *row = context.scanLine(y);
for (int x = 0; x < coverage.width(); ++x) {
const double k = mask[x] / 255.0 * alpha * (within ? within[x] / 255.0 : 1.0);
// Copying replaces by coverage alone, so clear clears.
const double m = mask[x] / 255.0 * (within ? within[x] / 255.0 : 1.0);
if (replaces ? m <= 0 : k <= 0)
continue;
// A gray sample's edge keeps its coverage, as CoreGraphics draws.
const uchar *from = source + x * 4;
uchar *pixel = row + x * channels;
const double keep = replaces ? 1 - m : 1 - from[3] / 255.0 * k;
for (int c = 0; c < channels; ++c)
pixel[c] = uchar(pixel[c] * keep + from[c] * k + 0.5);
}
}
}
// The tiles a pixel rect touches, by key.
template <typename Body> void eachTile(const QRectF &affected, int width, const Body &body)
{
if (affected.isEmpty())
return;
const qint64 columns = (width + BrushStroke::tileSize - 1) / BrushStroke::tileSize;
const qint64 firstY = qint64(affected.top()) / BrushStroke::tileSize, lastY = (qint64(std::ceil(affected.bottom())) - 1) / BrushStroke::tileSize;
const qint64 firstX = qint64(affected.left()) / BrushStroke::tileSize, lastX = (qint64(std::ceil(affected.right())) - 1) / BrushStroke::tileSize;
for (qint64 y = firstY; y <= lastY; ++y) {
for (qint64 x = firstX; x <= lastX; ++x)
body(y * columns + x, x, y);
}
}
}
double BrushStroke::spacingFraction(double hardness)
{
return hardness >= 1 ? 0.015 : 0.025;
}
BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas)
{
// A placed mask paints in its grid; else the layer's.
const bool placedMask = mask && layer.mask && layer.mask->placement;
const LayerTransform base = placedMask ? *layer.mask->placement : layer.transform;
const int originalWidth = placedMask ? layer.mask->asset.size().width()
: layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width()));
const int originalHeight = placedMask ? layer.mask->asset.size().height()
: layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()));
if (originalWidth < 1 || originalWidth > 30'000 || originalHeight < 1 || originalHeight > 30'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const QTransform originalMapping = BrushRaster::pixelToDocument(base, originalWidth, originalHeight);
const QRectF originalBounds(0, 0, originalWidth, originalHeight);
const QRectF extent = mask ? originalBounds : originalBounds.united(integral(originalMapping.inverted().mapRect(canvas)));
// Judged as a double: past int the conversion is undefined.
if (extent.width() < 1 || extent.width() > 1'000'000'000 || extent.height() < 1 || extent.height() > 1'000'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
Grid grid{int(extent.width()), int(extent.height()), originalBounds.translated(-extent.topLeft()),
QTransform::fromTranslate(extent.left(), extent.top()) * originalMapping, base};
grid.paintTransform.size = {grid.width * base.size.width() / originalWidth, grid.height * base.size.height() / originalHeight};
const QPointF center = originalMapping.map(extent.center());
grid.paintTransform.origin = {center.x() - grid.paintTransform.size.width() / 2, center.y() - grid.paintTransform.size.height() / 2};
if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > 2000
|| !std::isfinite(settings.hardness) || settings.hardness < 0 || settings.hardness > 1
|| !std::isfinite(settings.opacity) || settings.opacity < 0.01 || settings.opacity > 1)
throw ProjectError(ProjectError::Kind::tooLarge);
return grid;
}
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas)
: BrushStroke(layer, mask, settings, QRectF(QPointF(0, 0), canvas), grid(layer, mask, settings, QRectF(QPointF(0, 0), canvas)))
{
}
BrushStroke::BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid)
: layer(layer), isMask(mask), settings(settings), canvas(canvas), width(grid.width), height(grid.height), sourceRect(grid.sourceRect),
pixelToDocument(grid.pixelToDocument), paintTransform(grid.paintTransform),
m_source(mask ? (layer.mask ? std::optional(layer.mask->asset) : std::nullopt) : layer.asset),
m_paintColor(mask ? QColor::fromRgbF(settings.red, settings.red, settings.red) : QColor::fromRgbF(settings.red, settings.green, settings.blue))
{
}
void BrushStroke::append(QPointF point)
{
if (!std::isfinite(point.x()) || !std::isfinite(point.y()) || std::abs(point.x()) > 10'000'000 || std::abs(point.y()) > 10'000'000)
return;
if (!m_samples.empty() && m_samples.back() == point)
return;
m_samples.push_back(point);
if (m_samples.size() > 4)
m_samples.erase(m_samples.begin());
const size_t n = m_samples.size();
std::vector<BrushSegment> settled;
if (n == 1)
settled = {BrushSegment{float(point.x()), float(point.y()), float(point.x()), float(point.y())}};
else if (n >= 3)
settled = continuousCurve(m_samples[n - 3], m_samples[n - 2], m_samples[n >= 4 ? n - 4 : 0], point);
std::vector<BrushSegment> tail;
if (n >= 2)
tail = {BrushSegment{float(m_samples[n - 2].x()), float(m_samples[n - 2].y()), float(point.x()), float(point.y())}};
renderContinuous(settled, tail);
}
void BrushStroke::flush()
{
const size_t n = m_samples.size();
if (n < 2)
return;
const std::vector<BrushSegment> settled = continuousCurve(m_samples[n - 2], m_samples[n - 1], m_samples[n >= 3 ? n - 3 : 0], m_samples[n - 1]);
renderContinuous(settled, {});
m_samples = {m_samples[n - 1]};
}
// Centripetal Catmull–Rom, subdivided within 0.2 document pixels.
std::vector<BrushSegment> BrushStroke::continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const
{
const auto knot = [](double t, QPointF a, QPointF b) { return t + std::max(0.0001, std::sqrt(std::hypot(b.x() - a.x(), b.y() - a.y()))); };
const auto mix = [](QPointF a, QPointF b, double ta, double tb, double t) {
const double wa = (tb - t) / (tb - ta), wb = (t - ta) / (tb - ta);
return QPointF(a.x() * wa + b.x() * wb, a.y() * wa + b.y() * wb);
};
const double t0 = 0, t1 = knot(t0, before, start), t2 = knot(t1, start, end), t3 = knot(t2, end, after);
const auto point = [&](double u) {
if (u == 0)
return start;
if (u == 1)
return end;
const double t = t1 + (t2 - t1) * u;
const QPointF a = mix(before, start, t0, t1, t), b = mix(start, end, t1, t2, t), c = mix(end, after, t2, t3, t);
return mix(mix(a, b, t0, t2, t), mix(b, c, t1, t3, t), t1, t2, t);
};
std::vector<BrushSegment> result;
const std::function<void(QPointF, QPointF, double, double, int)> subdivide = [&](QPointF a, QPointF b, double lo, double hi, int depth) {
const double dx = b.x() - a.x(), dy = b.y() - a.y(), lengthSquared = dx * dx + dy * dy;
const auto error = [&](QPointF p) {
const double t = lengthSquared > 0 ? std::min(1.0, std::max(0.0, ((p.x() - a.x()) * dx + (p.y() - a.y()) * dy) / lengthSquared)) : 0;
return std::hypot(p.x() - a.x() - t * dx, p.y() - a.y() - t * dy);
};
const double mid = (lo + hi) / 2;
const QPointF m = point(mid);
const double deviation = std::max({error(m), error(point((lo + mid) / 2)), error(point((mid + hi) / 2))});
if (deviation <= 0.2 || depth >= 10) {
result.push_back(BrushSegment{float(a.x()), float(a.y()), float(b.x()), float(b.y())});
return;
}
subdivide(a, m, lo, mid, depth + 1);
subdivide(m, b, mid, hi, depth + 1);
};
subdivide(start, end, 0, 1, 0);
return result;
}
std::set<qint64> BrushStroke::continuousKeys(const std::vector<BrushSegment> &segments) const
{
std::set<qint64> keys;
const double reach = settings.diameter / 2 + 2;
const QTransform inverse = pixelToDocument.inverted();
for (const BrushSegment &s : segments) {
const QRectF box = QRectF(QPointF(std::min(s.x0, s.x1), std::min(s.y0, s.y1)), QPointF(std::max(s.x0, s.x1), std::max(s.y0, s.y1)))
.adjusted(-reach, -reach, reach, reach).intersected(canvas);
if (box.isEmpty())
continue;
const QRectF affected = integral(inverse.mapRect(box)).intersected(QRectF(0, 0, width, height));
eachTile(affected, width, [&](qint64 key, qint64, qint64) { keys.insert(key); });
}
return keys;
}
void BrushStroke::renderContinuous(const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail)
{
const std::set<qint64> tailKeys = continuousKeys(tail);
std::set<qint64> changed = continuousKeys(settled);
changed.insert(tailKeys.begin(), tailKeys.end());
changed.insert(m_tailKeys.begin(), m_tailKeys.end());
const qint64 columns = (width + tileSize - 1) / tileSize;
std::vector<BrushCoverage::Work> work;
for (qint64 key : changed) {
allocateTile(key, key % columns, key / columns);
const Tile &tile = m_tiles.at(key);
if (!m_coverage.contains(key)) {
m_coverage.emplace(key, BrushRaster::context(int(tile.rect.width()), int(tile.rect.height()), true));
m_coverageTiles.emplace(key, BrushCoverage::tile(int(tile.rect.width()), int(tile.rect.height())));
}
work.push_back({&m_coverageTiles.at(key), tile.rect, &m_coverage.at(key)});
}
BrushCoverage::render(work, settled, tail, pixelToDocument, settings, canvas.size());
m_tailKeys = tailKeys;
publish(changed);
}
// Each tile anew: original, then colour × coverage × opacity.
void BrushStroke::publish(const std::set<qint64> &changed)
{
struct Fill {
Tile *tile;
const QImage *coverage;
QImage clip;
QImage sample;
};
// Copies here, where a failure throws; fills side by side.
std::vector<Fill> work;
m_dirtyDocumentRect = std::nullopt;
for (qint64 key : changed) {
Tile &tile = m_tiles.at(key);
tile.context = tile.base;
tile.context.detach();
if (tile.context.isNull())
throw ExportError(ExportError::Kind::render);
work.push_back({&tile, &m_coverage.at(key), selectionCoverage(tile), clonePixels(tile)});
const QRectF rect = pixelToDocument.mapRect(tile.rect).intersected(canvas);
m_dirtyDocumentRect = m_dirtyDocumentRect ? std::optional(m_dirtyDocumentRect->united(rect)) : std::optional(rect);
}
// Healing shows Swift's dark wash until heal() rebuilds the spot.
const bool healing = settings.healing && !isMask;
const QPainter::CompositionMode mode = !healing && settings.erasing && !isMask ? QPainter::CompositionMode_DestinationOut : QPainter::CompositionMode_SourceOver;
const QColor colour = healing ? QColor::fromRgbF(0.12, 0.12, 0.12) : m_paintColor;
const double alpha = healing ? 0.45 : settings.opacity;
PoolMap::blocking(work, [&](const Fill &item) {
Tile &tile = *item.tile;
if (clone && (!isMask || isBlur))
fillClone(item.sample, *item.coverage, settings.opacity, tile.context, item.clip, replacesWithClone);
else
BrushRaster::fill(colour, *item.coverage, QRectF(QPointF(0, 0), tile.rect.size()), alpha, tile.context, mode, item.clip);
tile.image = tile.context;
});
}
// Swift clips the context; the fill takes the selection instead.
QImage BrushStroke::selectionCoverage(const Tile &tile) const
{
if (!selectionClip)
return QImage();
return PixelAdjust::coverage(*selectionClip, int(tile.rect.width()), int(tile.rect.height()),
QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument);
}
// The clone as this tile sees it, before the fill.
QImage BrushStroke::clonePixels(const Tile &tile) const
{
if (!clone)
return QImage();
const int width = int(tile.rect.width()), height = int(tile.rect.height());
// Tile pixels to clone pixels: grid, document, then offset.
const QTransform toClone = QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument
* QTransform::fromTranslate(clone->offset.width(), clone->offset.height());
// Colour even for gray clones: edges carry their coverage.
const QImage &image = clone->image;
if (toClone.type() > QTransform::TxTranslate) {
// Swift's medium quality: bilinear under a scale or turn.
QImage sample = BrushRaster::context(width, height, false);
QPainter painter(&sample);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.setTransform(toClone.inverted());
painter.drawImage(QRectF(QPointF(0, 0), image.size()), image);
painter.end();
return sample;
}
// QPainter snaps a translation; a fraction is resampled by hand.
const double left = std::floor(toClone.dx()), top = std::floor(toClone.dy());
const bool whole = left == toClone.dx() && top == toClone.dy();
QImage sample = BrushRaster::context(whole ? width : width + 1, whole ? height : height + 1, false);
QPainter painter(&sample);
BrushRaster::draw(image, QRectF(-left, -top, image.width(), image.height()), painter);
painter.end();
if (whole)
return sample;
const QImage part = BrushRaster::shiftedByFraction(sample, 1 - (toClone.dx() - left), 1 - (toClone.dy() - top)).copy(1, 1, width, height);
if (part.isNull())
throw ExportError(ExportError::Kind::render);
return part;
}
void BrushStroke::allocateTile(qint64 key, qint64 x, qint64 y)
{
if (m_tiles.contains(key))
return;
const QRectF rect(x * tileSize, y * tileSize, std::min<qint64>(tileSize, width - x * tileSize), std::min<qint64>(tileSize, height - y * tileSize));
const QRectF nextBounds = m_allocatedBounds ? m_allocatedBounds->united(rect) : m_source ? sourceRect.united(rect) : rect;
if (nextBounds.width() > 30'000 || nextBounds.height() > 30'000 || nextBounds.width() * nextBounds.height() > pixelLimit)
throw ProjectError(ProjectError::Kind::tooLarge);
m_allocatedBounds = nextBounds;
QImage context = BrushRaster::context(int(rect.width()), int(rect.height()), isMask);
QPainter painter(&context);
const QRectF initialRect = sourceRect.translated(-rect.topLeft());
if (m_source && m_source->raster) {
m_source->raster->draw(initialRect, painter);
} else if (m_source) {
// This tile's share of the source; a 1×1 stretches.
const QImage source = m_source->image();
const QRectF overlap = rect.intersected(sourceRect);
if (source.width() > 2 && source.height() > 2 && !overlap.isEmpty()) {
const double scaleX = source.width() / sourceRect.width(), scaleY = source.height() / sourceRect.height();
const QRectF crop = integral(QRectF((overlap.left() - sourceRect.left()) * scaleX, (overlap.top() - sourceRect.top()) * scaleY,
overlap.width() * scaleX, overlap.height() * scaleY));
BrushRaster::draw(source, overlap.translated(-rect.topLeft()), painter, crop);
} else {
BrushRaster::draw(source, initialRect, painter);
}
}
painter.end();
m_tiles.emplace(key, Tile{rect, context, std::nullopt, context});
}
std::vector<BrushPatch> BrushStroke::patches() const
{
std::vector<BrushPatch> result;
for (const auto &[key, tile] : m_tiles) {
if (tile.image)
result.push_back({tile.rect, *tile.image});
}
return result;
}
// Redrawing restarts from each tile's original content.
void BrushStroke::paintCanvas(bool withinSource, const std::function<void(QPainter &)> &draw)
{
QRectF area = canvas;
if (selectionClip)
area = area.intersected(selectionClip->rect);
if (area.isEmpty())
return;
const QTransform inverse = pixelToDocument.inverted();
QRectF affected = integral(inverse.mapRect(area)).intersected(QRectF(0, 0, width, height));
if (withinSource)
affected = affected.intersected(sourceRect);
if (affected.isEmpty())
return;
eachTile(affected, width, [&](qint64 key, qint64 x, qint64 y) {
allocateTile(key, x, y);
Tile &tile = m_tiles.at(key);
QImage drawn = m_source ? tile.base : BrushRaster::context(int(tile.rect.width()), int(tile.rect.height()), isMask);
drawn.detach();
if (drawn.isNull())
throw ExportError(ExportError::Kind::render);
QPainter painter(&drawn);
painter.setTransform(inverse * QTransform::fromTranslate(-tile.rect.left(), -tile.rect.top()));
painter.setClipRect(canvas);
draw(painter);
painter.end();
// Swift clips to the selection; the twin mixes through it.
if (selectionClip)
drawn = PixelAdjust::blend(drawn, tile.base, *selectionClip, QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument, isMask);
tile.context = drawn;
tile.image = drawn;
});
m_dirtyDocumentRect = canvas;
}
void BrushStroke::fillGradient(GradientShape shape, QPointF start, QPointF end, const std::array<QColor, 2> &colors, double opacity)
{
// Padded both ways, as Swift's before and after locations.
QGradient gradient = shape == GradientShape::linear
? QGradient(QLinearGradient(start, end))
: QGradient(QRadialGradient(start, std::hypot(end.x() - start.x(), end.y() - start.y())));
gradient.setColorAt(0, colors[0]);
gradient.setColorAt(1, colors[1]);
paintCanvas(false, [&](QPainter &painter) {
painter.setOpacity(std::min(1.0, std::max(0.0, opacity)));
painter.fillRect(canvas, gradient);
});
}
void BrushStroke::fill(const QColor &color)
{
paintCanvas(false, [&](QPainter &painter) { painter.fillRect(canvas, color); });
}
void BrushStroke::clearPixels()
{
paintCanvas(true, [&](QPainter &painter) {
painter.setCompositionMode(QPainter::CompositionMode_DestinationOut);
painter.fillRect(canvas, Qt::black);
});
}
// Cuts the selected pixels from the source; false with none.
bool BrushStroke::liftSelection()
{
if (isMask || !m_source || !selectionClip || !selectionClip->coverage)
return false;
const QRectF region = integral(pixelToDocument.inverted().mapRect(selectionClip->rect)).intersected(sourceRect);
if (region.width() < 1 || region.height() < 1)
return false;
QImage lifted = BrushRaster::context(int(region.width()), int(region.height()), false);
QPainter painter(&lifted);
painter.translate(-region.left(), -region.top());
if (m_source->raster)
m_source->raster->draw(sourceRect, painter);
else
BrushRaster::draw(m_source->image(), sourceRect, painter);
painter.end();
// Swift clips the context; the selection's coverage multiplies in.
const QImage clip = PixelAdjust::coverage(*selectionClip, lifted.width(), lifted.height(), QTransform::fromTranslate(region.left(), region.top()) * pixelToDocument);
QPainter multiplying(&lifted);
multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn);
multiplying.drawImage(QRectF(lifted.rect()), BrushRaster::alphaView(clip));
multiplying.end();
m_lifted = Lifted{lifted, region};
return true;
}
// Tiles anew: a hole, and the pixels `offset` away.
void BrushStroke::moveLifted(QSizeF offset, bool duplicate)
{
if (!m_lifted || !selectionClip)
return;
const QTransform inverse = pixelToDocument.inverted();
const QPointF zero = inverse.map(QPointF(0, 0)), moved = inverse.map(QPointF(offset.width(), offset.height()));
const QRectF target = m_lifted->rect.translated(moved.x() - zero.x(), moved.y() - zero.y());
const bool whole = target.left() == std::round(target.left()) && target.top() == std::round(target.top());
// QPainter snaps a fraction; Swift's high quality resamples it.
const QPointF place(std::floor(target.left()), std::floor(target.top()));
const QImage lifted = whole ? m_lifted->image : BrushRaster::shiftedByFraction(m_lifted->image, target.left() - place.x(), target.top() - place.y());
const QRectF needed = integral(m_lifted->rect.united(target)).intersected(QRectF(0, 0, width, height));
std::set<qint64> keys = m_moveTiles;
eachTile(needed, width, [&](qint64 key, qint64 x, qint64 y) {
allocateTile(key, x, y);
keys.insert(key);
});
for (qint64 key : keys) {
Tile &tile = m_tiles.at(key);
QImage drawn = tile.base;
drawn.detach();
if (drawn.isNull())
throw ExportError(ExportError::Kind::render);
QPainter painter(&drawn);
if (!duplicate) {
const QImage clip = PixelAdjust::coverage(*selectionClip, drawn.width(), drawn.height(),
QTransform::fromTranslate(tile.rect.left(), tile.rect.top()) * pixelToDocument);
painter.setCompositionMode(QPainter::CompositionMode_DestinationOut);
painter.drawImage(QRectF(drawn.rect()), BrushRaster::alphaView(clip));
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
}
painter.drawImage(QRectF(place - tile.rect.topLeft(), lifted.size()), lifted);
painter.end();
tile.context = drawn;
tile.image = drawn;
}
m_moveTiles = keys;
m_dirtyDocumentRect = canvas;
}
+131
View File
@@ -0,0 +1,131 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <cmath>
#include <numeric>
#include <stdexcept>
QString rawValue(SpotHealingMode mode)
{
switch (mode) {
case SpotHealingMode::contentAware:
return QStringLiteral("Content-Aware");
case SpotHealingMode::createTexture:
return QStringLiteral("Create Texture");
case SpotHealingMode::proximityMatch:
return QStringLiteral("Proximity Match");
}
throw std::logic_error("unknown spot healing mode");
}
QImage BrushRaster::context(int width, int height, bool mask)
{
QImage surface(width, height, mask ? QImage::Format_Grayscale8 : QImage::Format_RGBA8888_Premultiplied);
if (surface.isNull())
throw ExportError(ExportError::Kind::render);
surface.fill(0);
return surface;
}
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
{
context.save();
context.setRenderHint(QPainter::SmoothPixmapTransform, false);
context.setCompositionMode(QPainter::CompositionMode_Source);
context.drawImage(rect, image, source.isNull() ? QRectF(image.rect()) : source);
context.restore();
}
QImage BrushRaster::alphaView(const QImage &mask)
{
if (mask.format() != QImage::Format_Grayscale8)
throw std::logic_error("a layer mask must be 8-bit gray");
return QImage(mask.constBits(), mask.width(), mask.height(), mask.bytesPerLine(), QImage::Format_Alpha8);
}
QRectF BrushRaster::visibleRect(const QPainter &context)
{
const QPaintDevice &device = *context.device();
// A widget's size is logical; an image's is device pixels.
QTransform toDevice = context.combinedTransform();
if (device.devType() == QInternal::Widget)
toDevice *= QTransform::fromScale(1 / device.devicePixelRatio(), 1 / device.devicePixelRatio());
const QRectF bounds = toDevice.inverted().mapRect(QRectF(0, 0, device.width(), device.height()));
return context.hasClipping() ? bounds.intersected(context.clipBoundingRect()) : bounds;
}
QTransform BrushRaster::pixelToDocument(const LayerTransform &transform, int width, int height)
{
const QPointF center = transform.center();
QTransform map;
map.translate(center.x(), center.y());
map.rotateRadians(transform.radians());
map.scale(transform.size.width() / width * (transform.flipX ? -1 : 1),
transform.size.height() / height * (transform.flipY ? -1 : 1));
map.translate(-width / 2.0, -height / 2.0);
return map;
}
// Source-over of the colour at coverage × alpha, bytewise.
void BrushRaster::fill(const QColor &color, const QImage &coverage, const QRectF &rect, double alpha, QImage &context,
QPainter::CompositionMode mode, const QImage &clip)
{
if (coverage.format() != QImage::Format_Grayscale8 || coverage.size() != rect.size().toSize() || rect.topLeft() != rect.topLeft().toPoint())
throw std::logic_error("coverage must be gray and 1:1 with its rect");
if (!clip.isNull() && (clip.format() != QImage::Format_Grayscale8 || clip.size() != coverage.size()))
throw std::logic_error("a clip must be gray and sized as the coverage");
// Erasing takes coverage out; a mask never erases.
const bool erasing = mode == QPainter::CompositionMode_DestinationOut;
if ((!erasing && mode != QPainter::CompositionMode_SourceOver) || (erasing && context.format() == QImage::Format_Grayscale8))
throw std::logic_error("a brush fills source-over, or erases pixels");
const int left = int(rect.left()), top = int(rect.top());
const bool gray = context.format() == QImage::Format_Grayscale8;
if (!gray && context.format() != QImage::Format_RGBA8888_Premultiplied)
throw std::logic_error("a brush context is premultiplied RGBA or gray");
// What each channel moves towards; erasing moves towards nothing.
const double target[4] = {erasing ? 0 : color.redF() * 255, erasing ? 0 : color.greenF() * 255, erasing ? 0 : color.blueF() * 255,
erasing ? 0 : 255.0};
const int channels = gray ? 1 : 4;
for (int y = 0; y < coverage.height(); ++y) {
const uchar *mask = coverage.constScanLine(y);
// A clip scales the coverage, rounded once, as CoreGraphics composites.
const uchar *within = clip.isNull() ? nullptr : clip.constScanLine(y);
uchar *row = context.scanLine(top + y) + left * channels;
for (int x = 0; x < coverage.width(); ++x) {
const double k = mask[x] / 255.0 * alpha * (within ? within[x] / 255.0 : 1.0);
if (k <= 0)
continue;
// Values are never negative: adding a half rounds them.
uchar *pixel = row + x * channels;
for (int c = 0; c < channels; ++c)
pixel[c] = uchar(pixel[c] * (1 - k) + target[c] * k + 0.5);
}
}
}
QImage BrushRaster::shiftedByFraction(const QImage &image, double fx, double fy)
{
QImage shifted = BrushRaster::context(image.width() + 1, image.height() + 1, false);
// Weights in 1/65536: the pixel, left, above, above left.
const qint64 here = std::lround((1 - fx) * (1 - fy) * 65536), left = std::lround(fx * (1 - fy) * 65536);
const qint64 above = std::lround((1 - fx) * fy * 65536), corner = 65536 - here - left - above;
uchar *const out = shifted.bits();
const uchar *const in = image.constBits();
const qsizetype outStride = shifted.bytesPerLine(), inStride = image.bytesPerLine();
const int width = image.width(), height = image.height();
std::vector<int> rows(size_t(shifted.height()));
std::iota(rows.begin(), rows.end(), 0);
PoolMap::blocking(rows, [&](int y) {
const uchar *row = y < height ? in + y * inStride : nullptr, *up = y > 0 ? in + (y - 1) * inStride : nullptr;
uchar *line = out + y * outStride;
for (int x = 0; x <= width; ++x) {
for (int c = 0; c < 4; ++c) {
const qint64 sum = (row && x < width ? here * row[x * 4 + c] : 0) + (row && x > 0 ? left * row[(x - 1) * 4 + c] : 0)
+ (up && x < width ? above * up[x * 4 + c] : 0) + (up && x > 0 ? corner * up[(x - 1) * 4 + c] : 0);
line[x * 4 + c] = uchar((sum + 32768) >> 16);
}
}
});
return shifted;
}
+190
View File
@@ -0,0 +1,190 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include "Document/Gradient.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include "Rendering/BrushCoverage.h"
#include <QImage>
#include <QPainter>
#include <array>
#include <functional>
#include <map>
#include <set>
struct BrushPatch {
QRectF rect;
QImage image;
};
// Swift's three ways of healing a spot, in its order.
enum class SpotHealingMode { contentAware, createTexture, proximityMatch };
inline constexpr std::array allSpotHealingModes{SpotHealingMode::contentAware, SpotHealingMode::createTexture, SpotHealingMode::proximityMatch};
QString rawValue(SpotHealingMode mode);
// Tip, colour, and what the stroke does with them.
struct BrushSettings {
double diameter = 40;
double hardness = 1;
double red = 0;
double green = 0;
double blue = 0;
// Caps the whole stroke: overlapping dabs never exceed it.
double opacity = 1;
// Erase clears the layer's pixels instead of painting on them.
bool erasing = false;
// Spot healing takes nearby pixels instead of the colour.
bool healing = false;
SpotHealingMode healingMode = SpotHealingMode::contentAware;
};
// Shared raster drawing; coverage is copied without color conversion.
namespace BrushRaster {
QImage context(int width, int height, bool mask);
// 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.
void fill(const QColor &color, const QImage &coverage, const QRectF &rect, double alpha, QImage &context,
QPainter::CompositionMode mode = QPainter::CompositionMode_SourceOver, const QImage &clip = QImage());
// Gray mask bytes read as alpha, without a copy.
QImage alphaView(const QImage &mask);
// CoreGraphics boundingBoxOfClipPath: the clip within the device.
QRectF visibleRect(const QPainter &context);
// Premultiplied pixels shifted right and down by fractions, bilinear.
QImage shiftedByFraction(const QImage &image, double fx, double fy);
QTransform pixelToDocument(const LayerTransform &transform, int width, int height);
}
// A stroke's pixels as a painted layer, placed and bounded.
struct PaintSnapshot {
ImportedImage asset;
LayerTransform transform;
QRectF bounds;
};
// Swift's BrushCommit actor: the stroke's tiles made one image.
namespace BrushCommit {
struct Input {
int width;
int height;
std::optional<ImportedImage> source;
std::vector<BrushPatch> patches;
bool mask;
QString name;
QRectF sourceRect;
};
struct Output {
ImportedImage asset;
QRectF pixelBounds;
};
ImportedImage expandMask(const ImportedImage &asset, const Input &input, const QRectF &crop);
Output render(const Input &input);
}
// Only touched 256 px tiles hold pixels; snapshots copy those.
class BrushStroke {
public:
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, QSizeF canvas);
const ImageLayer layer;
const bool isMask;
const BrushSettings settings;
const QRectF canvas;
const int width;
const int height;
const QRectF sourceRect;
const QTransform pixelToDocument;
const LayerTransform paintTransform;
// Swift's Int: the budget left may run below zero.
qint64 pixelLimit = 100'000'000;
// Limits every edit to the document selection; nil when none.
std::optional<SelectionClip> selectionClip;
// Clone Stamp: a document-sized image, and each point's offset.
struct Clone {
QImage image;
QSizeF offset;
};
std::optional<Clone> clone;
// Blur: the clone is the layer softened, in place.
bool isBlur = false;
// The clone replaces what is under the tip, clearing too.
bool replacesWithClone = false;
// The undo name when the stroke's kind says none.
std::optional<QString> editName;
static constexpr int tileSize = 256;
// Soft-tip deposition rate, shared with the continuous integral.
static double spacingFraction(double hardness);
// A sample; the newest piece shows as a straight tail.
void append(QPointF point);
// Replaces the tail with the curve's last piece; repeatable.
void flush();
std::vector<BrushPatch> patches() const;
const std::optional<QRectF> &dirtyDocumentRect() const { return m_dirtyDocumentRect; }
// The selection, or the canvas, filled with one colour.
void fill(const QColor &color);
// Swift's fillGradient: across the canvas, linear or radial.
void fillGradient(GradientShape shape, QPointF start, QPointF end, const std::array<QColor, 2> &colors, double opacity);
// Pixels inside the selection made transparent, where pixels are.
void clearPixels();
// Cuts the selected pixels from the source; false with none.
bool liftSelection();
// The tiles anew: a hole, and the pixels `offset` away.
void moveLifted(QSizeF offset, bool duplicate);
// A placed mask as the stroke leaves it (LayerMask.cpp).
std::optional<QImage> placedMaskPreview(const LayerTransform &placement) const;
QRectF committedBounds() const;
LayerTransform committedTransform() const;
LayerTransform transform(const QRectF &bounds) const;
// Spot Healing's end: the painted spot rebuilt from around it.
void heal();
PaintSnapshot paintSnapshot() const;
BrushCommit::Input commitInput() const;
private:
struct Tile {
QRectF rect;
QImage context;
std::optional<QImage> image;
QImage base;
};
struct Lifted {
QImage image;
QRectF rect;
};
// The grid the stroke paints in, made before the members.
struct Grid {
int width;
int height;
QRectF sourceRect;
QTransform pixelToDocument;
LayerTransform paintTransform;
};
static Grid grid(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas);
BrushStroke(const ImageLayer &layer, bool mask, const BrushSettings &settings, const QRectF &canvas, const Grid &grid);
std::vector<BrushSegment> continuousCurve(QPointF start, QPointF end, QPointF before, QPointF after) const;
std::set<qint64> continuousKeys(const std::vector<BrushSegment> &segments) const;
void renderContinuous(const std::vector<BrushSegment> &settled, const std::vector<BrushSegment> &tail);
void publish(const std::set<qint64> &changed);
QImage selectionCoverage(const Tile &tile) const;
QImage clonePixels(const Tile &tile) const;
void allocateTile(qint64 key, qint64 x, qint64 y);
// `draw` in document coordinates over every tile the canvas covers.
void paintCanvas(bool withinSource, const std::function<void(QPainter &)> &draw);
// The pixels the stroke starts from: mask's or layer's.
const std::optional<ImportedImage> m_source;
const QColor m_paintColor;
std::optional<QRectF> m_allocatedBounds;
std::vector<QPointF> m_samples;
std::optional<QRectF> m_dirtyDocumentRect;
// Tile keys, row × columns + column, pass int.
std::map<qint64, Tile> m_tiles;
std::map<qint64, BrushCoverage::Tile> m_coverageTiles;
std::set<qint64> m_tailKeys;
// Per-tile gray coverage; the tile is recomposed from it.
std::map<qint64, QImage> m_coverage;
// Selected pixels cut from the source, in layer pixels.
std::optional<Lifted> m_lifted;
std::set<qint64> m_moveTiles;
};
+75
View File
@@ -0,0 +1,75 @@
#include "Document/CanvasSize.h"
#include <cmath>
QString rawValue(CanvasUnit unit)
{
switch (unit) {
case CanvasUnit::pixels:
return QStringLiteral("Pixels");
case CanvasUnit::percent:
return QStringLiteral("Percent");
case CanvasUnit::inches:
return QStringLiteral("Inches");
case CanvasUnit::centimeters:
return QStringLiteral("Centimeters");
}
Q_UNREACHABLE();
}
CanvasSizeDraft::CanvasSizeDraft(qint64 width, qint64 height, double resolution)
: originalWidth(width), originalHeight(height), resolution(resolution), width(double(width)), height(double(height))
{
}
bool CanvasSizeDraft::valid() const
{
// NaN and infinity fail both range tests.
return std::round(width) >= 1 && std::round(width) <= 30'000 && std::round(height) >= 1 && std::round(height) <= 30'000;
}
double CanvasSizeDraft::displayed(bool widthAxis) const
{
const double original = double(widthAxis ? originalWidth : originalHeight);
const double pixels = (widthAxis ? width : height) - (relative ? original : 0);
switch (unit) {
case CanvasUnit::pixels:
return pixels;
case CanvasUnit::percent:
return pixels / original * 100;
case CanvasUnit::inches:
return pixels / resolution;
case CanvasUnit::centimeters:
return pixels / resolution * 2.54;
}
Q_UNREACHABLE();
}
void CanvasSizeDraft::set(double value, bool widthAxis)
{
const double original = double(widthAxis ? originalWidth : originalHeight);
double pixels = value;
if (unit == CanvasUnit::percent)
pixels = value / 100 * original;
else if (unit == CanvasUnit::inches)
pixels = value * resolution;
else if (unit == CanvasUnit::centimeters)
pixels = value / 2.54 * resolution;
const double total = pixels + (relative ? original : 0);
if (widthAxis) {
width = total;
if (locked)
height = total * double(originalHeight) / double(originalWidth);
} else {
height = total;
if (locked)
width = total * double(originalWidth) / double(originalHeight);
}
}
QPointF CanvasSizeOptions::offset(qint64 fromWidth, qint64 oldHeight) const
{
if (contentOffset)
return *contentOffset;
// Floor: growth lands right and bottom, cuts left and top.
return QPointF(std::floor(double(width - fromWidth) * double(anchor % 3) / 2), std::floor(double(height - oldHeight) * double(anchor / 3) / 2));
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <QPointF>
#include <QString>
#include <QtGlobal>
#include <optional>
enum class CanvasUnit { pixels, percent, inches, centimeters };
// The name users see.
QString rawValue(CanvasUnit unit);
// What the canvas size sheet edits, in final pixels.
struct CanvasSizeDraft {
CanvasSizeDraft(qint64 width, qint64 height, double resolution);
qint64 originalWidth;
qint64 originalHeight;
double resolution;
double width;
double height;
bool relative = false;
bool locked = false;
CanvasUnit unit = CanvasUnit::pixels;
bool valid() const;
double displayed(bool widthAxis) const;
void set(double value, bool widthAxis);
};
struct CanvasExtensionColor {
double red;
double green;
double blue;
};
struct CanvasSizeOptions {
qint64 width;
qint64 height;
// Row-major, top-left through bottom-right.
qint64 anchor = 4;
std::optional<CanvasExtensionColor> fill = std::nullopt;
// Crop supplies its own translation of the content.
std::optional<QPointF> contentOffset = std::nullopt;
QPointF offset(qint64 fromWidth, qint64 oldHeight) const;
};
+10
View File
@@ -0,0 +1,10 @@
// Swift's CloneStamp extension: where Clone Stamp copies from.
public:
// Alt-click: the source; a new source starts a new alignment.
void setCloneSource(QPointF point);
// The whole-pixel offset a stroke from `point` copies with.
std::optional<QSizeF> cloneStrokeOffset(QPointF point) const;
// The source for a brush at `point`: the canvas's crosshair.
std::optional<QPointF> cloneSamplePoint(QPointF point) const;
// What a stroke copies from, document-sized, taken as it starts.
std::optional<QImage> cloneSample(const CanvasDocument &document) const;
+61
View File
@@ -0,0 +1,61 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath>
// Swift's CloneStamp extension: the source, its offset, the sample.
void EditorSession::setCloneSource(QPointF point)
{
if (!std::isfinite(point.x()) || !std::isfinite(point.y()))
return;
m_cloneSource = point;
m_cloneOffset = std::nullopt;
notify();
}
void EditorSession::setCloneSettings(const CloneSettings &settings)
{
m_cloneSettings = settings;
notify();
}
// Shared by stroke and preview: what a click stamps.
std::optional<QSizeF> EditorSession::cloneStrokeOffset(QPointF point) const
{
if (!m_cloneSource)
return std::nullopt;
if (m_cloneSettings.aligned && m_cloneOffset)
return m_cloneOffset;
return QSizeF(std::round(m_cloneSource->x() - point.x()), std::round(m_cloneSource->y() - point.y()));
}
// The source itself until a stroke fixes the offset.
std::optional<QPointF> EditorSession::cloneSamplePoint(QPointF point) const
{
if (!m_cloneSource)
return std::nullopt;
if (!m_cloneOffset || (!m_cloneSettings.aligned && !m_brushStroke))
return m_cloneSource;
return point + QPointF(m_cloneOffset->width(), m_cloneOffset->height());
}
std::optional<QImage> EditorSession::cloneSample(const CanvasDocument &document) const
{
try {
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
if (m_cloneSettings.sampleAllLayers) {
drawLiveComposite(document, painter);
} else if (const std::optional<ImageLayer> layer = activeLayer(); layer && layer->asset) {
const LayerTransform transform = displayedTransform(*layer);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
}
painter.end();
return context;
} catch (const ExportError &error) {
qCWarning(lcApp) << "Clone Stamp cannot sample the canvas:" << error.what();
return std::nullopt;
}
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Clone Stamp's options-bar settings.
struct CloneSettings {
// The source follows the brush, keeping its offset between strokes.
bool aligned = true;
// Every visible layer as shown, not the active one alone.
bool sampleAllLayers = false;
friend bool operator==(const CloneSettings &, const CloneSettings &) = default;
};
+32
View File
@@ -0,0 +1,32 @@
// Swift's ColorPalette extension: foreground and background colours.
public:
// The brush's own colour, as Swift's computed property.
PaletteColor foregroundColor() const;
void setForegroundColor(const PaletteColor &color);
bool canEditPalette() const;
// A mask's palette is black and white: its paint.
PaletteColor paletteColor(bool background) const;
void setPaletteColor(const PaletteColor &color, bool background);
void swapPaletteColors();
void resetPaletteColors();
void openColorPicker(bool background);
// The Type bar's swatch: open text's colour, else the foreground.
PaletteColor typeColor() const;
void openTextColorPicker();
void closeColorPicker(bool commit);
// The picker on an end of the open Gradient Map.
void openGradientMapColorPicker(bool highlights);
// The Gradient Map follows the picker's working colour.
void previewGradientMapColor();
// The picker on an effect of the open panel.
void openEffectColorPicker(LayerEffectKind kind);
// The effect follows the picker's working colour.
void previewEffectColor();
// Swift writes the picker's hsb in place; here, the session.
void setColorPickerHSB(const PickerHSB &hsb);
void sampleIntoColorPicker(QPointF point);
// The visible layers' sRGB at a pixel; none where clear.
std::optional<PaletteColor> sampleCompositeColor(QPointF point) const;
private:
void setGradientMapColor(const PaletteColor &color, bool highlights);
+338
View File
@@ -0,0 +1,338 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
#include <algorithm>
#include <cmath>
PaletteColor PaletteColor::quantized() const
{
const auto snap = [](double value) { return std::round(value * 255) / 255; };
return {snap(red), snap(green), snap(blue)};
}
QString PaletteColor::hex() const
{
const auto byte = [](double value) { return int(std::round(value * 255)); };
return QString::asprintf("%02X%02X%02X", byte(red), byte(green), byte(blue));
}
std::optional<PaletteColor> PaletteColor::fromHex(const QString &hex)
{
// Swift trims spaces and tabs, not line breaks.
const auto blank = [](QChar character) { return character.category() == QChar::Separator_Space || character == u'\t'; };
qsizetype start = 0, end = hex.size();
while (start < end && blank(hex.at(start)))
++start;
while (end > start && blank(hex.at(end - 1)))
--end;
QString text = hex.mid(start, end - start);
if (text.startsWith(u'#'))
text.remove(0, 1);
if (text.size() == 3)
text = QString(text.at(0)) + text.at(0) + text.at(1) + text.at(1) + text.at(2) + text.at(2);
if (text.size() != 6)
return std::nullopt;
// Swift's UInt32(_:radix:): a sign may lead; minus holds zero alone.
const bool negative = text.front() == u'-';
uint value = 0;
for (const QChar character : QStringView(text).mid(negative || text.front() == u'+' ? 1 : 0)) {
const char16_t unit = character.unicode();
const int digit = unit >= u'0' && unit <= u'9' ? unit - u'0' : unit >= u'a' && unit <= u'f' ? unit - u'a' + 10 : unit >= u'A' && unit <= u'F' ? unit - u'A' + 10 : -1;
if (digit < 0)
return std::nullopt;
value = value * 16 + uint(digit);
}
if (negative && value != 0)
return std::nullopt;
return PaletteColor{double((value >> 16) & 0xFF) / 255, double((value >> 8) & 0xFF) / 255, double(value & 0xFF) / 255};
}
PickerHSB::PickerHSB(const PaletteColor &color) : hue(0), saturation(0), brightness(0)
{
setRGB(color);
}
PaletteColor PickerHSB::rgb() const
{
const double h = std::fmod(std::fmod(hue, 360) + 360, 360) / 60;
const double c = brightness * saturation;
const double x = c * (1 - std::abs(std::fmod(h, 2) - 1));
const double m = brightness - c;
switch (int(h)) {
case 0:
return {c + m, x + m, m};
case 1:
return {x + m, c + m, m};
case 2:
return {m, c + m, x + m};
case 3:
return {m, x + m, c + m};
case 4:
return {x + m, m, c + m};
default:
return {c + m, m, x + m};
}
}
void PickerHSB::setRGB(const PaletteColor &color)
{
const double high = std::max({color.red, color.green, color.blue});
const double low = std::min({color.red, color.green, color.blue});
const double delta = high - low;
brightness = high;
if (high > 0)
saturation = delta / high;
if (delta <= 0)
return;
double h;
if (high == color.red)
h = (color.green - color.blue) / delta;
else if (high == color.green)
h = (color.blue - color.red) / delta + 2;
else
h = (color.red - color.green) / delta + 4;
h *= 60;
hue = h < 0 ? h + 360 : h;
}
QString ColorPickerTarget::title() const
{
if (kind == Kind::text)
return QStringLiteral("Color Picker (Text Color)");
if (kind == Kind::gradientMap)
return highlights ? QStringLiteral("Color Picker (Gradient Map Highlights)") : QStringLiteral("Color Picker (Gradient Map Shadows)");
if (kind == Kind::effect)
return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect));
return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)");
}
// Swift's ColorPalette extension: the palette and its picker.
PaletteColor EditorSession::foregroundColor() const
{
return {m_brushSettings.red, m_brushSettings.green, m_brushSettings.blue};
}
void EditorSession::setForegroundColor(const PaletteColor &color)
{
BrushSettings settings = m_brushSettings;
settings.red = color.red;
settings.green = color.green;
settings.blue = color.blue;
setBrushSettings(settings);
}
void EditorSession::setBackgroundColor(const PaletteColor &color)
{
m_backgroundColor = color;
refreshGradient();
notify();
}
bool EditorSession::canEditPalette() const
{
return !m_isProjectBusy && !m_brushStroke;
}
PaletteColor EditorSession::paletteColor(bool background) const
{
if (m_isMaskSelected)
return (background ? !m_maskPaintWhite : m_maskPaintWhite) ? PaletteColor::white() : PaletteColor::black();
return background ? m_backgroundColor : foregroundColor();
}
void EditorSession::setPaletteColor(const PaletteColor &color, bool background)
{
if (!canEditPalette())
return;
if (m_isMaskSelected) {
const bool white = color == PaletteColor::white();
setMaskPaintWhite(background ? !white : white);
} else if (background) {
setBackgroundColor(color);
} else {
setForegroundColor(color);
// Open text follows the swatch; drafts live under Type.
if (m_textDraft) {
changeTextStyle([&color](LayerTextStyle &style) {
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
}
}
}
void EditorSession::swapPaletteColors()
{
if (!canEditPalette())
return;
if (m_isMaskSelected) {
setMaskPaintWhite(!m_maskPaintWhite);
} else {
const PaletteColor old = foregroundColor();
setPaletteColor(m_backgroundColor, false);
setBackgroundColor(old);
}
}
void EditorSession::resetPaletteColors()
{
if (!canEditPalette())
return;
if (m_isMaskSelected) {
setMaskPaintWhite(false);
} else {
setPaletteColor(PaletteColor::black(), false);
setBackgroundColor(PaletteColor::white());
}
}
void EditorSession::openColorPicker(bool background)
{
if (!canEditPalette() || m_isMaskSelected)
return;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::palette, background}, paletteColor(background));
notify();
}
PaletteColor EditorSession::typeColor() const
{
if (!m_textDraft)
return foregroundColor();
return {m_textDraft->style.red, m_textDraft->style.green, m_textDraft->style.blue};
}
void EditorSession::openTextColorPicker()
{
if (!canEditPalette() || m_colorPicker || m_tool != NavigationTool::type)
return;
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::text, false, draftID}, typeColor());
notify();
}
void EditorSession::closeColorPicker(bool commit)
{
if (!m_colorPicker)
return;
// Closed first, so each signal sees it gone.
const ColorPickerState picker = *std::exchange(m_colorPicker, std::nullopt);
const PaletteColor color = picker.color();
const std::optional<QUuid> draftID = m_textDraft ? std::optional(m_textDraft->id) : std::nullopt;
if (commit && picker.target.kind == ColorPickerTarget::Kind::palette && !m_isMaskSelected) {
setPaletteColor(color, picker.target.background);
} else if (commit && picker.target.kind == ColorPickerTarget::Kind::text && m_tool == NavigationTool::type && draftID == picker.target.draftID) {
if (draftID) {
changeTextStyle([&color](LayerTextStyle &style) {
style.red = color.red;
style.green = color.green;
style.blue = color.blue;
});
} else {
m_textDefaults.red = color.red;
m_textDefaults.green = color.green;
m_textDefaults.blue = color.blue;
}
// The text colour is the foreground: the swatch follows.
if (!m_isMaskSelected)
setForegroundColor(color);
} else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) {
// The end previewed the working colour; Cancel puts it back.
setGradientMapColor(commit ? color : picker.original, picker.target.highlights);
} else if (picker.target.kind == ColorPickerTarget::Kind::effect) {
const PaletteColor chosen = commit ? color : picker.original;
changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); });
}
notify();
}
void EditorSession::openGradientMapColorPicker(bool highlights)
{
// A commit keeps the project busy: no `committing` term.
if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::gradientMap)
return;
const AdjustmentColor value = highlights ? m_filterEdit->settings.gradientMap.highlights : m_filterEdit->settings.gradientMap.shadows;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::gradientMap, false, std::nullopt, highlights}, PaletteColor{value.red, value.green, value.blue});
notify();
}
void EditorSession::openEffectColorPicker(LayerEffectKind kind)
{
if (!canEditPalette() || m_colorPicker || !m_effectsEditing)
return;
m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::effect, false, std::nullopt, false, kind}, editingEffects().color(kind).value_or(PaletteColor::black()));
notify();
}
void EditorSession::previewEffectColor()
{
if (!m_colorPicker || m_colorPicker->target.kind != ColorPickerTarget::Kind::effect)
return;
const PaletteColor color = m_colorPicker->color();
const LayerEffectKind kind = m_colorPicker->target.effect;
changeEffects([&](LayerEffects &effects) { effects.setColor(color, kind); });
}
void EditorSession::previewGradientMapColor()
{
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap)
setGradientMapColor(m_colorPicker->color(), m_colorPicker->target.highlights);
}
void EditorSession::setGradientMapColor(const PaletteColor &color, bool highlights)
{
// An adjustment's editor can close under its picker (9.5c).
if (!m_filterEdit || m_filterEdit->kind != FilterKind::gradientMap)
return;
const FilterEdit &edit = *m_filterEdit;
FilterSettings settings = edit.settings;
(highlights ? settings.gradientMap.highlights : settings.gradientMap.shadows) = AdjustmentColor(color);
if (settings == edit.settings)
return;
updateFilter(settings, edit.preview);
}
void EditorSession::setColorPickerHSB(const PickerHSB &hsb)
{
if (!m_colorPicker || m_colorPicker->hsb == hsb)
return;
m_colorPicker->hsb = hsb;
notify();
}
void EditorSession::sampleIntoColorPicker(QPointF point)
{
if (!m_colorPicker)
return;
const std::optional<PaletteColor> color = sampleCompositeColor(point);
if (!color)
return;
PickerHSB hsb = m_colorPicker->hsb;
hsb.setRGB(*color);
setColorPickerHSB(hsb);
}
std::optional<PaletteColor> EditorSession::sampleCompositeColor(QPointF point) const
{
if (!m_document || !(point.x() >= 0 && point.y() >= 0 && point.x() < m_document->width && point.y() < m_document->height))
return std::nullopt;
try {
// The pixel under the point, drawn as the canvas shows.
QImage pixel = BrushRaster::context(1, 1, false);
QPainter painter(&pixel);
painter.translate(-std::floor(point.x()), -std::floor(point.y()));
drawLiveComposite(*m_document, painter);
painter.end();
const uchar *bytes = pixel.constBits();
const int alpha = bytes[3];
if (alpha == 0)
return std::nullopt;
const auto channel = [alpha](int value) { return std::round(double(std::min(alpha, value)) / alpha * 255) / 255; };
return PaletteColor{channel(bytes[0]), channel(bytes[1]), channel(bytes[2])};
} catch (const ExportError &error) {
qCWarning(lcApp) << "the colour under the pointer cannot be read:" << error.what();
return std::nullopt;
}
}
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <QColor>
#include <QString>
#include <QUuid>
#include <optional>
// Swift's PaletteColor: an sRGB colour without alpha.
struct PaletteColor {
double red;
double green;
double blue;
static constexpr PaletteColor black() { return {0, 0, 0}; }
static constexpr PaletteColor white() { return {1, 1, 1}; }
QColor color(double alpha = 1) const { return QColor::fromRgbF(float(red), float(green), float(blue), float(alpha)); }
// The 8-bit values painting and export store.
PaletteColor quantized() const;
QString hex() const;
// Swift's init?(hex:): RRGGBB or RGB, with or without #.
static std::optional<PaletteColor> fromHex(const QString &text);
friend bool operator==(const PaletteColor &, const PaletteColor &) = default;
};
// The picker's own truth, so hue survives grays and black.
struct PickerHSB {
PickerHSB(double hue, double saturation, double brightness) : hue(hue), saturation(saturation), brightness(brightness) {}
explicit PickerHSB(const PaletteColor &color);
double hue;
double saturation;
double brightness;
PaletteColor rgb() const;
// Grays keep the hue, black the saturation, as Photoshop's.
void setRGB(const PaletteColor &color);
friend bool operator==(const PickerHSB &, const PickerHSB &) = default;
};
enum class LayerEffectKind;
// The picker's targets: palette, text, effect, Gradient Map end.
struct ColorPickerTarget {
enum class Kind { palette, text, gradientMap, effect };
Kind kind;
bool background = false;
std::optional<QUuid> draftID = std::nullopt;
// A Gradient Map's end: highlights, else shadows.
bool highlights = false;
// An effect's colour: which effect.
LayerEffectKind effect{};
QString title() const;
friend bool operator==(const ColorPickerTarget &, const ColorPickerTarget &) = default;
};
// The open picker's working colour: nothing is written until OK.
struct ColorPickerState {
ColorPickerState(const ColorPickerTarget &target, const PaletteColor &original) : target(target), original(original), hsb(original) {}
ColorPickerTarget target;
PaletteColor original;
PickerHSB hsb;
// Swift's object identity: a new picker opens its panel anew.
QUuid id = QUuid::createUuid();
PaletteColor color() const { return hsb.rgb().quantized(); }
};
+33
View File
@@ -0,0 +1,33 @@
#include "Document/ContentFill.h"
#include "Document/BrushStroke.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
// The kernel shares this file's name: angle brackets skip here.
extern "C" {
#include <ContentFill.h>
}
ContentFillError::ContentFillError(Kind kind)
: std::runtime_error("Not enough unselected, opaque image pixels to synthesize a fill. Use a smaller selection with some surrounding image."), kind(kind)
{
}
QImage ContentFill::run(const FilterJob &job)
{
if (!job.selection)
throw ContentFillError(ContentFillError::Kind::noSource);
const int width = job.image.width(), height = job.image.height();
QImage pixels = BrushRaster::context(width, height, false);
QPainter painter(&pixels);
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
painter.end();
// White through the selection: the pixels to make.
const QImage mask = PixelAdjust::coverage(*job.selection, width, height, job.mapping);
const int result = content_fill(pixels.bits(), size_t(pixels.bytesPerLine()), mask.constBits(), size_t(mask.bytesPerLine()), width, height);
if (result == 0)
throw ContentFillError(ContentFillError::Kind::noSource);
if (result != 1)
throw ExportError(ExportError::Kind::render);
return pixels;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "Document/Filters.h"
#include <stdexcept>
// Swift's ContentFill.Failure.
class ContentFillError : public std::runtime_error {
public:
enum class Kind { noSource };
explicit ContentFillError(Kind kind);
const Kind kind;
};
// Content-Aware Fill: the selection made from the pixels around it.
namespace ContentFill {
QImage run(const FilterJob &job);
}
+15
View File
@@ -0,0 +1,15 @@
// Swift's Crop extension: snapping, the frame, the crop itself.
public:
SnapGuides transformSnapTargets(const QSet<QUuid> &moving) const;
LayerTransform snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance);
SnapGuides cropSnapTargets() const;
// The Crop tool's frame: the canvas until one is drawn.
std::optional<QRectF> visibleCropRect() const;
std::optional<double> cropRatio() const;
void cancelCrop();
void changeCropRatio();
// Off the UI thread; `done` follows the step or refusal.
void commitCrop(std::function<void()> done = {});
private:
void finishCropCommit();
+293
View File
@@ -0,0 +1,293 @@
#include "Document/Crop.h"
#include "Document/Distort.h"
#include "Document/EditorSession.h"
#include "IO/CanvasResizer.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include <QtConcurrent>
#include <cmath>
#include <new>
QRectF CropGeometry::snapped(const QRectF &rect)
{
const QRectF standard = rect.normalized();
const double x = std::round(standard.left()), y = std::round(standard.top());
return QRectF(x, y, std::max(1.0, std::round(standard.right()) - x), std::max(1.0, std::round(standard.bottom()) - y));
}
// Swift's isFinite terms are the ranges' own: NaN passes none.
bool CropGeometry::valid(const QRectF &rect)
{
const QRectF standard = rect.normalized();
return standard.width() >= 1 && standard.width() <= 30'000 && standard.height() >= 1 && standard.height() <= 30'000
&& std::abs(standard.left()) <= 1'000'000 && std::abs(standard.top()) <= 1'000'000;
}
QRectF CropGeometry::create(QPointF start, QPointF end, std::optional<double> ratio, bool symmetric)
{
double dx = end.x() - start.x(), dy = end.y() - start.y();
if (ratio) {
if (std::abs(dx) > std::abs(dy) * *ratio)
dy = (dy < 0 ? -1 : 1) * std::abs(dx) / *ratio;
else
dx = (dx < 0 ? -1 : 1) * std::abs(dy) * *ratio;
}
if (symmetric)
return snapped(QRectF(start.x() - std::abs(dx), start.y() - std::abs(dy), std::abs(dx) * 2, std::abs(dy) * 2));
return snapped(QRectF(std::min(start.x(), start.x() + dx), std::min(start.y(), start.y() + dy), std::abs(dx), std::abs(dy)));
}
QRectF CropDrag::updated(QPointF point, std::optional<double> ratio, bool symmetric) const
{
switch (mode.kind) {
case Kind::create: return CropGeometry::create(start, point, ratio, symmetric);
case Kind::move: return CropGeometry::snapped(original.translated(point - start));
case Kind::resize: {
const TransformDrag drag{.original = LayerTransform{.origin = original.topLeft(), .size = original.size()},
.start = start,
.mode = {TransformDrag::Kind::resize, mode.index},
.originalCorners = std::nullopt};
const LayerTransform next = drag.updated(point, ratio.has_value(), false, symmetric);
return CropGeometry::snapped(QRectF(next.origin, next.size));
}
}
throw std::logic_error("unknown crop drag");
}
namespace {
std::optional<double> nearest(double value, const std::vector<double> &targets, double tolerance)
{
std::optional<double> best;
for (const double target : targets) {
if (std::abs(target - value) > tolerance || (best && std::abs(*best - value) <= std::abs(target - value)))
continue;
best = target;
}
return best;
}
// The smallest shift that lands an edge on a target.
double shift(std::initializer_list<double> edges, const std::vector<double> &targets, double tolerance)
{
std::optional<double> best;
for (const double edge : edges) {
const std::optional<double> target = nearest(edge, targets, tolerance);
if (target && (!best || std::abs(*target - edge) < std::abs(*best)))
best = *target - edge;
}
return best.value_or(0);
}
}
// Moves snap their closest edges; the rest, the dragged side's.
QRectF CropSnap::apply(QRectF rect, const CropDrag &drag, QPointF point, std::optional<double> ratio, bool symmetric) const
{
if (!(tolerance > 0))
return rect;
bool horizontal = true, vertical = true;
switch (drag.mode.kind) {
case CropDrag::Kind::move: return rect.translated(shift({rect.left(), rect.right()}, xs, tolerance), shift({rect.top(), rect.bottom()}, ys, tolerance));
// A fixed ratio snaps moves alone, so it stays exact.
case CropDrag::Kind::create:
if (ratio)
return rect;
break;
case CropDrag::Kind::resize:
if (ratio)
return rect;
horizontal = LayerTransform::handles[size_t(drag.mode.index)].x() != 0.5;
vertical = LayerTransform::handles[size_t(drag.mode.index)].y() != 0.5;
break;
}
QRectF result = rect;
if (horizontal) {
if (std::abs(point.x() - result.left()) <= std::abs(point.x() - result.right())) {
if (const std::optional<double> x = nearest(result.left(), xs, tolerance); x && *x < result.right())
result = QRectF(*x, result.top(), result.right() - *x, result.height());
} else if (const std::optional<double> x = nearest(result.right(), xs, tolerance); x && *x > result.left()) {
result.setWidth(*x - result.left());
}
}
if (vertical) {
if (std::abs(point.y() - result.top()) <= std::abs(point.y() - result.bottom())) {
if (const std::optional<double> y = nearest(result.top(), ys, tolerance); y && *y < result.bottom())
result = QRectF(result.left(), *y, result.width(), result.bottom() - *y);
} else if (const std::optional<double> y = nearest(result.bottom(), ys, tolerance); y && *y > result.top()) {
result.setHeight(*y - result.top());
}
}
if (symmetric) {
// The snapped edge sets the half; its opposite mirrors it.
const QPointF centre = drag.mode.kind == CropDrag::Kind::create ? drag.start : drag.original.center();
if (horizontal) {
const double half = point.x() >= centre.x() ? result.right() - centre.x() : centre.x() - result.left();
if (half >= 0.5)
result = QRectF(centre.x() - half, result.top(), half * 2, result.height());
}
if (vertical) {
const double half = point.y() >= centre.y() ? result.bottom() - centre.y() : centre.y() - result.top();
if (half >= 0.5)
result = QRectF(result.left(), centre.y() - half, result.width(), half * 2);
}
}
return result;
}
namespace {
QRectF boxAround(const LayerTransform &transform)
{
const Corners corners = DistortWarp::corners(transform);
QPointF low = corners[0], high = corners[0];
for (const QPointF corner : corners) {
low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())};
high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())};
}
return QRectF(low, high);
}
}
// The canvas's edges and middle, and every other visible layer's.
SnapGuides EditorSession::transformSnapTargets(const QSet<QUuid> &moving) const
{
SnapGuides targets;
if (!m_document)
return targets;
const QSizeF size = m_document->size();
targets.xs = {0, size.width() / 2, size.width()};
targets.ys = {0, size.height() / 2, size.height()};
for (const ImageLayer &layer : m_document->renderLayers()) {
if (!layer.asset || moving.contains(layer.id))
continue;
const QRectF box = boxAround(displayedTransform(layer));
targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.center().x()), std::round(box.right())});
targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.center().y()), std::round(box.bottom())});
}
return targets;
}
// `draft` nudged onto a nearby edge or middle, within `tolerance`.
LayerTransform EditorSession::snappedMove(const LayerTransform &draft, const QSet<QUuid> &moving, double tolerance)
{
const SnapGuides targets = transformSnapTargets(moving);
const TransformSnap::Offset snap = TransformSnap::offset(boxAround(draft), targets.xs, targets.ys, tolerance);
snapGuides = {snap.x ? std::vector<double>{*snap.x} : std::vector<double>{}, snap.y ? std::vector<double>{*snap.y} : std::vector<double>{}};
if (snap.offset.isNull())
return draft;
LayerTransform snapped = draft;
snapped.origin += QPointF(snap.offset.width(), snap.offset.height());
return snapped;
}
// The canvas's edges and every visible layer's upright bounds.
SnapGuides EditorSession::cropSnapTargets() const
{
SnapGuides targets;
if (!m_document)
return targets;
const QSizeF size = m_document->size();
targets.xs = {0, size.width()};
targets.ys = {0, size.height()};
for (const ImageLayer &layer : m_document->renderLayers()) {
if (!layer.asset)
continue;
const QRectF box = boxAround(displayedTransform(layer));
targets.xs.insert(targets.xs.end(), {std::round(box.left()), std::round(box.right())});
targets.ys.insert(targets.ys.end(), {std::round(box.top()), std::round(box.bottom())});
}
return targets;
}
std::optional<QRectF> EditorSession::visibleCropRect() const
{
if (m_tool != NavigationTool::crop || !m_document)
return std::nullopt;
return m_cropRect ? m_cropRect : QRectF(QPointF(0, 0), m_document->size());
}
std::optional<double> EditorSession::cropRatio() const
{
if (m_cropRatioChoice == QStringLiteral("Original"))
return m_document ? std::optional(double(m_document->width) / double(m_document->height)) : std::nullopt;
if (m_cropRatioChoice == QStringLiteral("1:1"))
return 1;
if (m_cropRatioChoice == QStringLiteral("4:3"))
return 4.0 / 3;
if (m_cropRatioChoice == QStringLiteral("16:9"))
return 16.0 / 9;
return std::nullopt;
}
void EditorSession::cancelCrop()
{
setCropRect(std::nullopt);
}
// The frame takes the ratio about its middle, width kept.
void EditorSession::changeCropRatio()
{
const std::optional<QRectF> rect = visibleCropRect();
const std::optional<double> ratio = cropRatio();
if (!rect || !ratio)
return;
const double height = rect->width() / *ratio;
const QRectF next = CropGeometry::snapped(QRectF(rect->left(), rect->center().y() - height / 2, rect->width(), height));
if (CropGeometry::valid(next))
setCropRect(next);
}
// The canvas cut to the frame off the UI thread.
void EditorSession::commitCrop(std::function<void()> done)
{
bool started = false;
if (canStartProjectOperation() && m_cropRect && CropGeometry::valid(*m_cropRect)) {
// Swift reads a CGRect's width and minX standardised.
const QRectF rect = m_cropRect->normalized();
const CanvasSizeOptions options{.width = qint64(rect.width()), .height = qint64(rect.height()), .contentOffset = QPointF(-rect.left(), -rect.top())};
// Taken before anything changes: running out changes nothing.
try {
std::optional<ProjectSnapshot> snapshot = projectSnapshot();
if (snapshot) {
m_cropCommit.setFuture(QtConcurrent::run([snapshot = std::move(*snapshot), options]() -> Cropped {
try {
return Cropped{CanvasResizer::resize(snapshot, options), std::nullopt};
} catch (const ProjectError &error) {
return Cropped{std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return Cropped{std::nullopt, QString::fromUtf8(error.what())};
}
}));
started = true;
}
} catch (const std::bad_alloc &) {
setCropError(QString::fromUtf8(ExportError(ExportError::Kind::render).what()));
}
}
if (!started) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
m_committingCrop = std::move(done);
setIsProjectBusy(true);
}
void EditorSession::finishCropCommit()
{
const std::function<void()> done = std::exchange(m_committingCrop, {});
try {
// Moved out: a large project's copy could fail.
const Cropped result = m_cropCommit.future().takeResult();
if (result.snapshot) {
applyDocumentSize(*result.snapshot, QStringLiteral("Crop"));
// Only a crop that landed lets its frame go.
m_cropRect.reset();
} else {
setCropError(result.failure);
}
} catch (const std::bad_alloc &) {
setCropError(QString::fromUtf8(ExportError(ExportError::Kind::render).what()));
}
setIsProjectBusy(false);
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "Document/LayerTransform.h"
#include <QRectF>
#include <optional>
#include <vector>
// Swift's CropGeometry: frames on whole pixels, within Swift's bounds.
namespace CropGeometry {
QRectF snapped(const QRectF &rect);
bool valid(const QRectF &rect);
// From `start` to `end`, or out from `start` when symmetric.
QRectF create(QPointF start, QPointF end, std::optional<double> ratio, bool symmetric = false);
}
// Swift's CropDrag: a frame being made, moved or resized.
struct CropDrag {
enum class Kind { create, move, resize };
struct Mode {
Kind kind;
int index = 0;
};
QPointF start;
QRectF original;
Mode mode;
// Symmetric (Alt) keeps the frame's middle still.
QRectF updated(QPointF point, std::optional<double> ratio, bool symmetric = false) const;
};
// Swift's CropSnap: dragged edges drawn to nearby edges.
struct CropSnap {
std::vector<double> xs;
std::vector<double> ys;
// In document pixels.
double tolerance;
QRectF apply(QRectF rect, const CropDrag &drag, QPointF point, std::optional<double> ratio, bool symmetric = false) const;
};
+67
View File
@@ -0,0 +1,67 @@
#include "Document/Curves.h"
#include "Document/BrushStroke.h"
#include "IO/ProjectStore.h"
#include <QPainter>
#include <algorithm>
#include <array>
#include <cmath>
extern "C" {
#include "LevelsPixels.h"
}
bool CurvesSettings::isValid() const
{
// Fixed ends and rising x keep x in range.
return channels.size() == 4 && std::all_of(channels.begin(), channels.end(), [](const std::vector<CurvePoint> &points) {
return points.size() >= 2 && points.size() <= 32 && points.front().x == 0 && points.back().x == 255
&& std::all_of(points.begin(), points.end(), [](const CurvePoint &point) { return point.y >= 0 && point.y <= 255; })
&& std::adjacent_find(points.begin(), points.end(), [](const CurvePoint &a, const CurvePoint &b) { return !(a.x < b.x); }) == points.end();
});
}
double CurvesSettings::value(double x, size_t channel) const
{
const std::vector<CurvePoint> &p = channels.at(channel);
// Swift's lastIndex(where:), else the first segment.
size_t last = 0;
for (size_t index = 0; index < p.size(); ++index) {
if (p[index].x <= x)
last = index;
}
const size_t i = std::min(p.size() - 2, last);
std::vector<double> d;
for (size_t index = 0; index + 1 < p.size(); ++index)
d.push_back((p[index + 1].y - p[index].y) / (p[index + 1].x - p[index].x));
const auto slope = [&](size_t j) {
if (j == 0)
return d.front();
if (j == p.size() - 1)
return d.back();
if (d[j - 1] * d[j] <= 0)
return 0.0;
return 2 / (1 / d[j - 1] + 1 / d[j]);
};
const double h = p[i + 1].x - p[i].x, t = std::min(1.0, std::max(0.0, (x - p[i].x) / h));
const double y = (2 * t * t * t - 3 * t * t + 1) * p[i].y + (t * t * t - 2 * t * t + t) * h * slope(i)
+ (-2 * t * t * t + 3 * t * t) * p[i + 1].y + (t * t * t - t * t) * h * slope(i + 1);
return std::min(255.0, std::max(0.0, y));
}
QImage CurvesSettings::apply(const QImage &image) const
{
if (!isValid())
throw ProjectError(ProjectError::Kind::invalid);
QImage context = BrushRaster::context(image.width(), image.height(), false);
{
QPainter painter(&context);
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
}
// Each colour's curve, then the composite's.
std::array<float, 3 * 256> tables;
for (size_t channel = 1; channel <= 3; ++channel) {
for (size_t x = 0; x < 256; ++x)
tables[(channel - 1) * 256 + x] = float(value(value(double(x), channel), 0) / 255);
}
levels_apply(context.bits(), size_t(image.width()) * size_t(image.height()), tables.data());
return context;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "Document/Levels.h"
#include <QImage>
#include <vector>
struct CurvePoint {
double x;
double y;
friend bool operator==(const CurvePoint &, const CurvePoint &) = default;
};
// Swift's CurvesSettings: a curve a channel, the composite first.
struct CurvesSettings {
LevelsChannel channel = LevelsChannel::rgb;
std::vector<std::vector<CurvePoint>> channels = std::vector<std::vector<CurvePoint>>(4, {{0, 0}, {255, 255}});
bool isValid() const;
// Shape-preserving cubic Hermite: no overshoot between handles.
double value(double x, size_t channel) const;
// Throws ProjectError when invalid, ExportError when out of memory.
QImage apply(const QImage &image) const;
friend bool operator==(const CurvesSettings &, const CurvesSettings &) = default;
};
+14
View File
@@ -0,0 +1,14 @@
// Swift's Distort extension: four corners dragged apart.
public:
void beginDistort();
void previewCorners(const Corners &corners);
std::optional<DistortPreview> distortPreview(const ImageLayer &layer) const;
// A layer's effects warped with the pending distortion.
std::optional<DistortWarp::Warped> distortedEffects(const ImageLayer &layer, const QImage &image, double inset) const;
// For a known target: the commit's, once the edit ends.
std::optional<DistortWarp::Warped> distortedEffects(const ImageLayer &layer, const QImage &image, double inset, const DistortTarget &target) const;
private:
std::optional<DistortTarget> distortTarget(const ImageLayer &layer, const TransformEdit &edit, const Corners &shape) const;
void commitDistort(const TransformEdit &edit, const Corners &shape);
void distort(int index, const LayerTransform &transform, const Corners &corners);
+482
View File
@@ -0,0 +1,482 @@
#include "Document/Distort.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include <QPainterPath>
#include <QPolygonF>
#include <QRegion>
#include <cmath>
#include <functional>
extern "C" {
#include "BrushPixels.h"
}
namespace {
// Swift divides by w whatever its sign; Qt 6.11 clips.
QPointF project(const QTransform &map, QPointF point)
{
const double w = map.m13() * point.x() + map.m23() * point.y() + map.m33();
return QPointF((map.m11() * point.x() + map.m21() * point.y() + map.m31()) / w, (map.m12() * point.x() + map.m22() * point.y() + map.m32()) / w);
}
double area(QPointF a, QPointF b, QPointF c)
{
return (b.x() - a.x()) * (c.y() - a.y()) - (b.y() - a.y()) * (c.x() - a.x());
}
using Triangle = std::array<QPointF, 3>;
// The affine map taking three points to three others.
std::optional<QTransform> affine(const Triangle &source, const Triangle &target)
{
const QPointF u = source[1] - source[0], v = source[2] - source[0];
const QPointF uu = target[1] - target[0], vv = target[2] - target[0];
const double det = u.x() * v.y() - v.x() * u.y();
if (std::abs(det) <= 1e-9)
return std::nullopt;
const double a = (uu.x() * v.y() - vv.x() * u.y()) / det, c = (vv.x() * u.x() - uu.x() * v.x()) / det;
const double b = (uu.y() * v.y() - vv.y() * u.y()) / det, d = (vv.y() * u.x() - uu.y() * v.x()) / det;
return QTransform(a, b, c, d, target[0].x() - (a * source[0].x() + c * source[0].y()), target[0].y() - (b * source[0].x() + d * source[0].y()));
}
struct Target {
QPointF topLeft, topRight, bottomRight, bottomLeft;
};
// A flipped layer's pixels go to the opposite corners.
Target imageCorners(const Corners &corners, bool flipX, bool flipY)
{
const auto corner = [&](int x, int y) {
const int u = flipX ? 1 - x : x, v = flipY ? 1 - y : y;
return corners[size_t(std::array<int, 4>{0, 1, 3, 2}[size_t(v * 2 + u)])];
};
return {corner(0, 0), corner(1, 0), corner(1, 1), corner(0, 1)};
}
QRectF shapeBounds(const Corners &corners)
{
QPointF low = corners[0], high = corners[0];
for (const QPointF corner : corners) {
low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())};
high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())};
}
return QRectF(QPointF(std::floor(low.x()), std::floor(low.y())), QPointF(std::ceil(high.x()), std::ceil(high.y())));
}
// Pixels whose centres a triangle holds, left bound inclusive.
QRegion triangleRegion(const Triangle &triangle)
{
QRegion region;
const double top = std::min({triangle[0].y(), triangle[1].y(), triangle[2].y()});
const double bottom = std::max({triangle[0].y(), triangle[1].y(), triangle[2].y()});
for (int row = int(std::ceil(top - 0.5)); row + 0.5 < bottom; ++row) {
const double y = row + 0.5;
std::optional<double> left, right;
for (size_t index = 0; index < 3; ++index) {
// Ends ordered by y: a shared edge gives one x.
const QPointF a = std::min(triangle[index], triangle[(index + 1) % 3], [](QPointF p, QPointF q) { return p.y() < q.y(); });
const QPointF b = a == triangle[index] ? triangle[(index + 1) % 3] : triangle[index];
// An edge holds its lower end, not its upper one.
if (a.y() == b.y() || y < a.y() || y >= b.y())
continue;
const double x = a.x() + (b.x() - a.x()) * (y - a.y()) / (b.y() - a.y());
left = std::min(left.value_or(x), x);
right = std::max(right.value_or(x), x);
}
if (!left)
continue;
const int first = int(std::ceil(*left - 0.5)), last = int(std::ceil(*right - 0.5)) - 1;
if (last >= first)
region += QRect(first, row, last - first + 1, 1);
}
return region;
}
// A folded shape: each half by its own affine map.
void warpFolded(const QImage &image, const Target &target, const std::function<QPointF(QPointF)> &place, QPainter &painter)
{
const QRectF source(0, 0, image.width(), image.height());
const std::array<std::pair<Triangle, Triangle>, 2> halves = {
std::pair{Triangle{source.topLeft(), source.topRight(), source.bottomRight()}, Triangle{place(target.topLeft), place(target.topRight), place(target.bottomRight)}},
std::pair{Triangle{source.topLeft(), source.bottomRight(), source.bottomLeft()}, Triangle{place(target.topLeft), place(target.bottomRight), place(target.bottomLeft)}}};
for (const auto &[from, to] : halves) {
const std::optional<QTransform> map = affine(from, to);
if (!map)
continue;
painter.save();
// A hard region along the diagonal: the halves meet exactly.
painter.setClipRegion(triangleRegion(to));
painter.setTransform(*map);
BrushRaster::draw(image, source, painter);
painter.restore();
}
}
}
Corners DistortWarp::corners(const LayerTransform &transform)
{
return {transform.point({0, 0}), transform.point({1, 0}), transform.point({1, 1}), transform.point({0, 1})};
}
bool DistortWarp::isUsable(const Corners &corners)
{
for (const QPointF corner : corners) {
if (!std::isfinite(corner.x()) || !std::isfinite(corner.y()) || std::abs(corner.x()) > 1'000'000 || std::abs(corner.y()) > 1'000'000)
return false;
}
// Both halves need area, or one has nothing to draw.
return std::abs(area(corners[0], corners[1], corners[2])) > 0.01 && std::abs(area(corners[0], corners[2], corners[3])) > 0.01;
}
bool DistortWarp::isConvex(const Corners &corners)
{
if (!isUsable(corners))
return false;
double sign = 0;
for (size_t index = 0; index < 4; ++index) {
const QPointF a = corners[index], b = corners[(index + 1) % 4], c = corners[(index + 2) % 4];
const double cross = (b.x() - a.x()) * (c.y() - b.y()) - (b.y() - a.y()) * (c.x() - b.x());
if (std::abs(cross) <= 0.01)
return false;
if (sign == 0)
sign = cross < 0 ? -1 : 1;
else if ((cross < 0) != (sign < 0))
return false;
}
return true;
}
QTransform DistortWarp::homography(const Corners &c)
{
const double sx = c[0].x() - c[1].x() + c[2].x() - c[3].x(), sy = c[0].y() - c[1].y() + c[2].y() - c[3].y();
double g = 0, h = 0;
if (std::abs(sx) > 1e-9 || std::abs(sy) > 1e-9) {
const double dx1 = c[1].x() - c[2].x(), dx2 = c[3].x() - c[2].x(), dy1 = c[1].y() - c[2].y(), dy2 = c[3].y() - c[2].y();
const double den = dx1 * dy2 - dx2 * dy1;
if (std::abs(den) > 1e-12) {
g = (sx * dy2 - dx2 * sy) / den;
h = (dx1 * sy - sx * dy1) / den;
}
}
const double a = c[1].x() - c[0].x() + g * c[1].x(), b = c[3].x() - c[0].x() + h * c[3].x();
const double d = c[1].y() - c[0].y() + g * c[1].y(), e = c[3].y() - c[0].y() + h * c[3].y();
// Qt divides by the third row, as Swift by w.
return QTransform(a, d, g, b, e, h, c[0].x(), c[0].y(), 1);
}
DistortWarp::Warped DistortWarp::warp(const QImage &image, const LayerTransform &transform, const Corners &corners, bool isMask,
std::optional<double> limit)
{
if (!isUsable(corners))
throw ProjectError(ProjectError::Kind::invalid);
const QRectF bounds = shapeBounds(corners);
if (bounds.width() < 1 || bounds.height() < 1 || bounds.width() > 30'000 || bounds.height() > 30'000 || bounds.width() * bounds.height() > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
const LayerTransform placed{.origin = bounds.topLeft(), .size = bounds.size(), .sampling = transform.sampling};
// A uniform 1 × 1 mask already covers any shape.
if (isMask && image.width() == 1 && image.height() == 1)
return {image, placed};
const double factor = limit ? std::min(1.0, *limit / std::max(bounds.width(), bounds.height())) : 1;
const int width = std::max(1, int(std::ceil(bounds.width() * factor))), height = std::max(1, int(std::ceil(bounds.height() * factor)));
const Target target = imageCorners(corners, transform.flipX, transform.flipY);
const auto place = [&](QPointF point) { return QPointF((point.x() - bounds.left()) * factor, (point.y() - bounds.top()) * factor); };
QImage surface = BrushRaster::context(width, height, isMask);
QPainter painter(&surface);
painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
if (!isConvex(corners)) {
warpFolded(image, target, place, painter);
return {surface, placed};
}
const QRectF source(0, 0, image.width(), image.height());
QTransform map;
if (!QTransform::quadToQuad(QPolygonF({source.topLeft(), source.topRight(), source.bottomRight(), source.bottomLeft()}),
QPolygonF({place(target.topLeft), place(target.topRight), place(target.bottomRight), place(target.bottomLeft)}), map))
throw ExportError(ExportError::Kind::render);
painter.setTransform(map);
painter.drawImage(source, image);
return {surface, placed};
}
DistortWarp::Trimmed DistortWarp::warpTrimmed(const QImage &image, const LayerTransform &transform, const Corners &corners)
{
const Warped warped = warp(image, transform, corners, false);
const QRect full = warped.image.rect();
size_t edges[4] = {0, 0, 0, 0};
brush_alpha_bounds(warped.image.constBits(), size_t(full.width()), size_t(full.height()), size_t(warped.image.bytesPerLine()), edges);
const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1]));
// Nothing visible, or nothing to trim: the warp itself.
if (crop.width() < 1 || crop.height() < 1 || crop == full)
return {warped.image, warped.transform, full};
LayerTransform placed = warped.transform;
placed.origin += crop.topLeft();
placed.size = crop.size();
return {warped.image.copy(crop), placed, crop};
}
Corners DistortWarp::carried(const LayerTransform &placement, const LayerTransform &transform, const Corners &corners)
{
QTransform toDocument;
toDocument.translate(transform.center().x(), transform.center().y());
toDocument.rotateRadians(transform.radians());
toDocument.scale(transform.size.width(), transform.size.height());
toDocument.translate(-0.5, -0.5);
const QTransform toUnit = toDocument.inverted();
const QTransform map = homography(corners);
const Corners own = DistortWarp::corners(placement);
Corners result;
for (size_t index = 0; index < 4; ++index)
result[index] = project(map, toUnit.map(own[index]));
return result;
}
DistortWarp::Warped DistortWarp::warpMask(const QImage &image, const LayerTransform &transform, const Corners &corners, double background,
std::optional<double> limit)
{
const Warped warped = warp(image, transform, corners, true, limit);
if (background <= 0 || warped.image.cacheKey() == image.cacheKey())
return warped;
const int width = warped.image.width(), height = warped.image.height();
QImage surface = BrushRaster::context(width, height, true);
surface.fill(qRound(background * 255));
QPainter painter(&surface);
painter.setRenderHint(QPainter::Antialiasing, true);
const double sx = width / warped.transform.size.width(), sy = height / warped.transform.size.height();
QPainterPath shape;
for (size_t index = 0; index < 4; ++index) {
const QPointF point((corners[index].x() - warped.transform.origin.x()) * sx, (corners[index].y() - warped.transform.origin.y()) * sy);
if (index == 0)
shape.moveTo(point);
else
shape.lineTo(point);
}
shape.closeSubpath();
painter.setClipPath(shape);
BrushRaster::draw(warped.image, QRectF(0, 0, width, height), painter);
return {surface, warped.transform};
}
// Ctrl-drag on a handle: the corners move freely; Apply resamples.
void EditorSession::beginDistort()
{
if (!m_transformEdit || m_transformEdit->corners || !m_transformEdit->draft.isValid())
return;
m_transformEdit->corners = DistortWarp::corners(m_transformEdit->draft);
m_transformEdit->persistent = true;
notify();
}
// A twisted or collapsed shape is ignored.
void EditorSession::previewCorners(const Corners &corners)
{
if (!m_transformEdit || !m_transformEdit->corners || !DistortWarp::isUsable(corners))
return;
m_transformEdit->corners = corners;
notify();
}
// A group's layers take the box's perspective each.
std::optional<DistortTarget> EditorSession::distortTarget(const ImageLayer &layer, const TransformEdit &edit, const Corners &shape) const
{
if (!edit.group)
return edit.layerID == layer.id ? std::optional(DistortTarget{edit.draft, shape}) : std::nullopt;
const auto original = edit.group->originals.constFind(layer.id);
if (original == edit.group->originals.constEnd())
return std::nullopt;
const LayerTransform transform = original->following(edit.group->box, edit.draft);
const Corners corners = DistortWarp::carried(transform, edit.draft, shape);
return DistortWarp::isUsable(corners) ? std::optional(DistortTarget{transform, corners}) : std::nullopt;
}
// `image` is the layer with effects round it, mask applied.
std::optional<DistortWarp::Warped> EditorSession::distortedEffects(const ImageLayer &layer, const QImage &image, double inset) const
{
if (!m_transformEdit || m_transformEdit->mask || !m_transformEdit->corners)
return std::nullopt;
const std::optional<DistortTarget> target = distortTarget(layer, *m_transformEdit, *m_transformEdit->corners);
if (!target)
return std::nullopt;
return distortedEffects(layer, image, inset, *target);
}
std::optional<DistortWarp::Warped> EditorSession::distortedEffects(const ImageLayer &layer, const QImage &image, double inset,
const DistortTarget &target) const
{
// The grown box takes the same perspective as the layer's.
const LayerTransform grown = LayerEffectsRenderer::placed(target.transform, image, inset);
const Corners carried = DistortWarp::carried(grown, target.transform, target.corners);
if (m_distortEffectsCache.contains(layer.id)) {
const DistortEffectsCache &cache = m_distortEffectsCache.at(layer.id);
if (cache.corners == carried && cache.image.cacheKey() == image.cacheKey())
return cache.result;
}
std::optional<DistortWarp::Warped> result;
// Swift's try?: effects that cannot warp are left off.
try {
result = DistortWarp::warp(image, grown, carried, false, 2048);
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
m_distortEffectsCache.insert_or_assign(layer.id, DistortEffectsCache{carried, image, result});
return result;
}
// The layer warped into the pending distortion, at preview size.
std::optional<DistortPreview> EditorSession::distortPreview(const ImageLayer &layer) const
{
if (!m_transformEdit || m_transformEdit->mask || !m_transformEdit->corners || !layer.asset)
return std::nullopt;
const std::optional<DistortTarget> target = distortTarget(layer, *m_transformEdit, *m_transformEdit->corners);
if (!target)
return std::nullopt;
const std::optional<QImage> mask = layer.mask ? layer.mask->enabledImage() : std::nullopt;
const std::optional<ImageIdentity> maskID = mask ? std::optional(layer.mask->asset.identity()) : std::nullopt;
const auto cached = m_distortPreviewCache.find(layer.id);
if (cached != m_distortPreviewCache.end() && cached->second.corners == target->corners && cached->second.draft == target->transform
&& cached->second.image == layer.asset->identity() && cached->second.mask == maskID)
return cached->second.result;
std::optional<DistortPreview> result;
try {
const DistortWarp::Warped warped = DistortWarp::warp(layer.asset->image(), target->transform, target->corners, false, 2048);
std::optional<QImage> warpedMask;
const std::optional<LayerMask> &owned = layer.mask;
if (owned && !owned->placement && owned->isLinked) {
// Swift's try?: a mask that cannot warp draws none.
try {
if (mask)
warpedMask = DistortWarp::warp(*mask, target->transform, target->corners, true, 2048).image;
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
} else if (owned) {
std::optional<QImage> carriedMask;
if (owned->isLinked && owned->placement) {
// A linked mask placed apart takes the same perspective.
const LayerTransform placement = owned->placement->following(layer.transform, target->transform);
const Corners carried = DistortWarp::carried(placement, target->transform, target->corners);
try {
if (DistortWarp::isConvex(carried)) {
const DistortWarp::Warped moved = DistortWarp::warpMask(owned->asset.image(), placement, carried, LayerMask::background(owned->asset.thumbnail), 2048);
carriedMask = LayerMask(ImportedImage(moved.image, owned->asset.thumbnail, owned->asset.name), owned->isEnabled)
.clipImage(moved.transform, warped.transform, warped.image.width(), warped.image.height(), 2048);
}
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
}
// Otherwise the mask stays where it is on the document.
warpedMask = carriedMask ? carriedMask : owned->clipImage(owned->placement.value_or(layer.transform), warped.transform, warped.image.width(), warped.image.height(), 2048);
}
result = DistortPreview{warped.image, warpedMask, warped.transform};
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
m_distortPreviewCache.insert_or_assign(layer.id, DistortPreviewCache{target->corners, target->transform, layer.asset->identity(), maskID, result});
return result;
}
// Apply: each layer resampled into its shape, one step.
void EditorSession::commitDistort(const TransformEdit &edit, const Corners &shape)
{
m_distortPreviewCache.clear();
std::vector<QUuid> ids;
if (edit.group) {
for (auto original = edit.group->originals.constBegin(); original != edit.group->originals.constEnd(); ++original)
ids.push_back(original.key());
} else {
ids.push_back(edit.layerID);
}
beginEdit(edit.group ? QStringLiteral("Distort Layers") : QStringLiteral("Distort"));
for (const QUuid &id : ids) {
const int index = indexOf(m_document->layers, id);
if (index < 0)
continue;
const std::optional<DistortTarget> target = distortTarget(m_document->layers[index], edit, shape);
if (!target)
continue;
// Warped effects stay on until the new pixels' effects land.
const std::optional<EffectsPreviewCache::Result> effects = effectsPreviews.rendered(id);
const std::optional<DistortWarp::Warped> warpedEffects =
effects ? distortedEffects(m_document->layers[index], effects->image, effects->inset, *target) : std::nullopt;
try {
distort(index, target->transform, target->corners);
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
// Placed where warped: the commit also crops the layer.
if (warpedEffects)
effectsPreviews.seed(id, warpedEffects->image, warpedEffects->transform);
}
endEdit();
m_distortEffectsCache.clear();
}
// The layer at `index` resampled onto `corners`.
void EditorSession::distort(int index, const LayerTransform &transform, const Corners &corners)
{
const ImageLayer layer = m_document->layers[index];
if (!layer.asset)
return;
const DistortWarp::Trimmed warped = DistortWarp::warpTrimmed(layer.asset->image(), transform, corners);
const ImportedImage asset(warped.image, PixelAdjust::thumbnail(warped.image), layer.name);
std::optional<LayerMask> mask = layer.mask;
if (layer.mask && !layer.mask->placement && layer.mask->isLinked) {
const DistortWarp::Warped warpedMask = DistortWarp::warp(layer.mask->asset.image(), transform, corners, true);
// A uniform mask passes through; others crop with the pixels.
const bool uniform = warpedMask.image.cacheKey() == layer.mask->asset.image().cacheKey();
mask = layer.mask->replacing(uniform ? layer.mask->asset : LayerMask::assetFrom(warpedMask.image.copy(warped.crop)));
} else if (layer.mask && layer.mask->isLinked && layer.mask->placement
&& DistortWarp::isConvex(DistortWarp::carried(layer.mask->placement->following(layer.transform, transform), transform, corners))) {
// A linked mask placed apart takes the same perspective.
const LayerTransform placement = layer.mask->placement->following(layer.transform, transform);
const DistortWarp::Warped moved = DistortWarp::warpMask(layer.mask->asset.image(), placement, DistortWarp::carried(placement, transform, corners),
LayerMask::background(layer.mask->asset.thumbnail));
const bool uniform = moved.image.cacheKey() == layer.mask->asset.image().cacheKey();
mask = LayerMask(uniform ? layer.mask->asset : LayerMask::assetFrom(moved.image), layer.mask->isEnabled, moved.transform, true);
} else if (layer.mask) {
// An unlinked mask keeps its place on the document.
mask->placement = layer.mask->placement.value_or(layer.transform);
}
m_document->layers[index].asset = asset;
m_document->layers[index].transform = warped.transform;
m_document->layers[index].mask = mask;
}
// The selection's outline carried through the distortion.
std::optional<QPainterPath> DistortWarp::mapPath(const QPainterPath &path, const QTransform &pixelToDocument, QSizeF pixelSize,
const LayerTransform &transform, const Corners &corners)
{
if (!isConvex(corners) || pixelSize.width() <= 0 || pixelSize.height() <= 0)
return std::nullopt;
const QTransform toPixels = pixelToDocument.inverted();
const QTransform map = homography(corners);
const auto carry = [&](QPointF point) {
const QPointF pixel = toPixels.map(point);
double u = pixel.x() / pixelSize.width(), v = pixel.y() / pixelSize.height();
if (transform.flipX)
u = 1 - u;
if (transform.flipY)
v = 1 - v;
return project(map, QPointF(u, v));
};
QPainterPath result;
result.setFillRule(path.fillRule());
for (int i = 0; i < path.elementCount(); ++i) {
const QPainterPath::Element element = path.elementAt(i);
if (element.isMoveTo()) {
result.moveTo(carry(element));
} else if (element.isLineTo()) {
result.lineTo(carry(element));
} else {
// A curve is its first control, then two data elements.
result.cubicTo(carry(element), carry(path.elementAt(i + 1)), carry(path.elementAt(i + 2)));
i += 2;
}
}
return result;
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include "Document/LayerTransform.h"
#include "IO/ImageImporter.h"
#include <QImage>
#include <QPainterPath>
#include <QRect>
#include <optional>
// Free distortion: the layer's four corners move on their own.
namespace DistortWarp {
struct Warped {
QImage image;
LayerTransform transform;
};
struct Trimmed {
QImage image;
LayerTransform transform;
// In the warp's pixels, for cropping a mask to match.
QRect crop;
};
// Clockwise from the top left, in document pixels.
Corners corners(const LayerTransform &transform);
// Four finite corners with some area to both halves.
bool isUsable(const Corners &corners);
// A shape a perspective can take, wound either way.
bool isConvex(const Corners &corners);
// The perspective taking the unit square onto `corners`.
QTransform homography(const Corners &corners);
// `image` under `transform`, resampled so its corners land on `corners`.
Warped warp(const QImage &image, const LayerTransform &transform, const Corners &corners, bool isMask,
std::optional<double> limit = std::nullopt);
// A full warp cropped to its visible pixels.
Trimmed warpTrimmed(const QImage &image, const LayerTransform &transform, const Corners &corners);
// Where `placement`'s corners land under `transform`'s perspective.
Corners carried(const LayerTransform &placement, const LayerTransform &transform, const Corners &corners);
// A path through the distortion; nil when the shape folds.
std::optional<QPainterPath> mapPath(const QPainterPath &path, const QTransform &pixelToDocument, QSizeF pixelSize,
const LayerTransform &transform, const Corners &corners);
// A mask warped like `warp`, its background outside the shape.
Warped warpMask(const QImage &image, const LayerTransform &transform, const Corners &corners, double background,
std::optional<double> limit = std::nullopt);
}
// Where a distortion takes a layer: transform and corners.
struct DistortTarget {
LayerTransform transform;
Corners corners;
};
// The layer warped for the canvas, its mask alongside.
struct DistortPreview {
QImage image;
std::optional<QImage> mask;
LayerTransform transform;
};
// Effects last warped for a distortion, kept while corners rest.
struct DistortEffectsCache {
Corners corners;
QImage image;
std::optional<DistortWarp::Warped> result;
};
// The canvas's last warped preview, kept while nothing changed.
struct DistortPreviewCache {
Corners corners;
LayerTransform draft;
ImageIdentity image;
std::optional<ImageIdentity> mask;
std::optional<DistortPreview> result;
};
+131
View File
@@ -0,0 +1,131 @@
#include "Document/DocumentHistory.h"
#include <algorithm>
#include <set>
#include <utility>
DocumentHistory::DocumentHistory(int entryLimit, qint64 retainedByteLimit)
: entryLimit(std::max(0, entryLimit)), retainedByteLimit(std::max<qint64>(0, retainedByteLimit))
{
}
void DocumentHistory::reset()
{
m_past.clear();
m_future.clear();
m_pending.reset();
m_depth = 0;
m_revision = QUuid::createUuid();
m_savedRevision = m_revision;
}
// A closed step changes these three; pending state is spent.
void DocumentHistory::adopt(DocumentHistory &&staged) noexcept
{
m_past = std::move(staged.m_past);
m_future = std::move(staged.m_future);
m_revision = staged.m_revision;
}
void DocumentHistory::begin(const QString &name, const std::optional<CanvasDocument> &document,
std::optional<QUuid> selection)
{
if (m_depth == 0) {
m_pending = Snapshot{document, selection, m_revision};
m_pendingName = name;
}
m_depth += 1;
}
void DocumentHistory::end(const std::optional<CanvasDocument> &document, std::optional<QUuid> selection)
{
if (m_depth == 0)
return;
m_depth -= 1;
if (m_depth != 0 || !m_pending)
return;
const Snapshot before = *std::exchange(m_pending, std::nullopt);
// Selecting, navigating and no-op edits keep redo history.
if (before.document == document)
return;
m_revision = QUuid::createUuid();
m_past.push_back({m_pendingName, before, Snapshot{document, selection, m_revision}});
m_future.clear();
trim(document);
}
std::optional<DocumentHistory::Snapshot> DocumentHistory::undo()
{
if (!canUndo())
return std::nullopt;
Entry entry = std::move(m_past.back());
m_past.pop_back();
m_future.push_back(entry);
m_revision = entry.before.revision;
trim(entry.before.document);
return entry.before;
}
std::optional<DocumentHistory::Snapshot> DocumentHistory::redo()
{
if (!canRedo())
return std::nullopt;
Entry entry = std::move(m_future.back());
m_future.pop_back();
m_past.push_back(entry);
m_revision = entry.after.revision;
trim(entry.after.document);
return entry.after;
}
qint64 DocumentHistory::retainedBytes(const std::optional<CanvasDocument> &current) const
{
const auto assets = [](const ImageLayer &layer) {
std::vector<const ImportedImage *> result;
if (layer.asset)
result.push_back(&*layer.asset);
if (layer.mask)
result.push_back(&layer.mask->asset);
return result;
};
std::set<ImageIdentity> seen;
if (current) {
for (const ImageLayer &layer : current->layers) {
for (const ImportedImage *asset : assets(layer)) {
seen.insert(asset->identity());
seen.insert({nullptr, asset->thumbnail.cacheKey()});
}
}
}
qint64 bytes = 0;
const auto count = [&](const Snapshot &snapshot) {
if (!snapshot.document)
return;
for (const ImageLayer &layer : snapshot.document->layers) {
for (const ImportedImage *asset : assets(layer)) {
if (seen.insert(asset->identity()).second)
bytes += asset->byteCount();
if (seen.insert({nullptr, asset->thumbnail.cacheKey()}).second)
bytes += asset->thumbnail.sizeInBytes();
}
}
};
for (const std::vector<Entry> *entries : {&m_past, &m_future}) {
for (const Entry &entry : *entries) {
count(entry.before);
count(entry.after);
}
}
return bytes;
}
void DocumentHistory::trim(const std::optional<CanvasDocument> &current)
{
while (int(m_past.size() + m_future.size()) > entryLimit || retainedBytes(current) > retainedByteLimit) {
if (!m_past.empty())
m_past.erase(m_past.begin());
else if (!m_future.empty())
m_future.erase(m_future.begin());
else
break;
}
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include <QString>
#include <QUuid>
#include <optional>
#include <vector>
// Snapshots share image data; layer edits copy no pixels.
class DocumentHistory {
public:
struct Snapshot {
std::optional<CanvasDocument> document;
std::optional<QUuid> activeLayerID;
QUuid revision;
};
const int entryLimit;
const qint64 retainedByteLimit;
explicit DocumentHistory(int entryLimit = 100, qint64 retainedByteLimit = 256LL * 1024 * 1024);
bool canUndo() const { return m_depth == 0 && !m_past.empty(); }
bool canRedo() const { return m_depth == 0 && !m_future.empty(); }
QString undoName() const { return m_past.empty() ? QString() : m_past.back().name; }
QString redoName() const { return m_future.empty() ? QString() : m_future.back().name; }
bool isModified() const { return m_revision != m_savedRevision; }
int undoCount() const { return int(m_past.size()); }
void markSaved() { m_savedRevision = m_revision; }
void reset();
// Takes the steps a staged copy of this history recorded.
void adopt(DocumentHistory &&staged) noexcept;
void begin(const QString &name, const std::optional<CanvasDocument> &document, std::optional<QUuid> selection);
void end(const std::optional<CanvasDocument> &document, std::optional<QUuid> selection);
std::optional<Snapshot> undo();
std::optional<Snapshot> redo();
qint64 retainedBytes(const std::optional<CanvasDocument> &current) const;
private:
struct Entry {
QString name;
Snapshot before;
Snapshot after;
};
void trim(const std::optional<CanvasDocument> &current);
std::vector<Entry> m_past;
std::vector<Entry> m_future;
QUuid m_revision = QUuid::createUuid();
QUuid m_savedRevision = m_revision;
std::optional<Snapshot> m_pending;
QString m_pendingName = QStringLiteral("Edit");
int m_depth = 0;
};
+407
View File
@@ -0,0 +1,407 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include "Rendering/RasterSnapshot.h"
#include <QtConcurrent>
#include <chrono>
#include <cmath>
// Swift's EditorSession+Brush: the stroke from press to commit.
namespace {
bool sameAsset(const std::optional<ImportedImage> &a, const std::optional<ImportedImage> &b)
{
return a.has_value() == b.has_value() && (!a || a->identity() == b->identity());
}
bool sameMask(const std::optional<LayerMask> &a, const std::optional<LayerMask> &b)
{
return a.has_value() == b.has_value() && (!a || a->asset.identity() == b->asset.identity());
}
double uptime()
{
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
}
bool EditorSession::canPaint() const
{
const std::optional<ImageLayer> layer = activeLayer();
const std::optional<DocumentSelection> current = selection();
// A folder has no pixels: only its mask paints.
return canEditLayers() && m_selectedLayerIDs.size() == 1 && layer && (!layer->isGroup || m_isMaskSelected) && !(current && current->isEmpty())
&& m_document->effectiveVisibleIDs().contains(layer->id) && (!m_isMaskSelected || (layer->mask && layer->mask->isEnabled))
&& (m_isMaskSelected || !layer->adjustment);
}
// A tiled raster edit of pixels or mask, within budget.
std::unique_ptr<BrushStroke> EditorSession::makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings) const
{
if (!m_document)
throw ProjectError(ProjectError::Kind::tooLarge);
auto stroke = std::make_unique<BrushStroke>(layer, m_isMaskSelected, settings, m_document->size());
qint64 used = 0;
for (const ImageLayer &other : m_document->layers) {
if (other.id == layer.id)
continue;
const std::optional<ImportedImage> &image = m_isMaskSelected ? (other.mask ? std::optional(other.mask->asset) : std::nullopt) : other.asset;
if (image)
used += qint64(image->size().width()) * image->size().height();
}
stroke->pixelLimit = 100'000'000 - used;
const std::optional<DocumentSelection> current = selection();
if (current)
stroke->selectionClip = current->clip(m_document->size());
if (!m_isMaskSelected && layer.mask) {
qint64 maskPixels = 0;
for (const ImageLayer &other : m_document->layers) {
if (other.id != layer.id && other.mask)
maskPixels += qint64(other.mask->asset.size().width()) * other.mask->asset.size().height();
}
stroke->pixelLimit = std::min<qint64>(stroke->pixelLimit, 100'000'000 - maskPixels);
}
return stroke;
}
void EditorSession::beginBrush(QPointF point)
{
if (m_tool == NavigationTool::blur && m_blurMode != BlurToolMode::blur) {
beginWarp(point);
return;
}
// Spot Healing and Clone Stamp rework pixels, never a mask.
const bool paintsMasks = m_tool == NavigationTool::brush || m_tool == NavigationTool::blur;
if ((!paintsMasks && (!isBrushTool(m_tool) || m_isMaskSelected)) || !canPaint())
return;
const ImageLayer layer = activeLayer().value();
std::optional<BrushStroke::Clone> clone;
if (m_tool == NavigationTool::cloneStamp) {
const std::optional<QSizeF> offset = cloneStrokeOffset(point);
if (!offset) {
setBrushError(QStringLiteral("Alt-click where Clone Stamp should copy from first."));
return;
}
const std::optional<QImage> image = cloneSample(m_document.value());
if (!image)
return;
m_cloneOffset = offset;
clone = BrushStroke::Clone{*image, *offset};
}
// Blur paints a softened copy of the layer, in place.
if (m_tool == NavigationTool::blur) {
const std::optional<QImage> image = blurSample(m_document.value(), m_isMaskSelected);
if (!image)
return;
clone = BrushStroke::Clone{*image, QSizeF(0, 0)};
}
finishOpacityEdit();
try {
BrushSettings settings = m_brushSettings;
settings.healing = m_tool == NavigationTool::spotHealing;
settings.erasing = m_tool == NavigationTool::brush && m_brushMode == BrushToolMode::erase && !m_isMaskSelected;
settings.healingMode = m_spotHealingMode;
if (m_isMaskSelected) {
settings.red = m_maskPaintWhite ? 1 : 0;
settings.green = settings.red;
settings.blue = settings.red;
}
m_brushStroke = makeRasterEdit(layer, settings);
m_brushStroke->clone = clone;
m_brushStroke->isBlur = m_tool == NavigationTool::blur;
m_brushStroke->append(point);
m_lastBrushPoint = LastBrushPoint{point, layer.id, m_isMaskSelected};
++m_brushRevision;
resumeFileRequests();
notify();
} catch (const ProjectError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
}
}
void EditorSession::continueBrush(QPointF point)
{
if (m_warpStroke) {
m_warpStroke->append(point);
m_lastBrushPoint->point = point;
++m_brushRevision;
notify();
return;
}
if (!m_brushStroke)
return;
try {
m_brushStroke->append(point);
m_lastBrushPoint->point = point;
++m_brushRevision;
notify();
} catch (const ProjectError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
cancelBrush();
setBrushError(QString::fromUtf8(error.what()));
}
}
// Where a Shift-click's line starts, on the same target.
std::optional<QPointF> EditorSession::shiftLineStart() const
{
if (!m_lastBrushPoint || m_lastBrushPoint->layerID != m_activeLayerID || m_lastBrushPoint->mask != m_isMaskSelected)
return std::nullopt;
return m_lastBrushPoint->point;
}
void EditorSession::cancelBrush()
{
m_warpStroke.reset();
m_brushStroke.reset();
++m_brushRevision;
resumeFileRequests();
notify();
}
// Mouse-up's own call, before the next event arrives.
bool EditorSession::finishBrushImmediately()
{
if (m_warpStroke) {
if (m_isProjectBusy)
return false;
finishWarp();
return true;
}
if (!m_brushStroke)
return true;
if (m_isProjectBusy)
return false;
try {
m_brushStroke->flush();
if (m_brushStroke->settings.healing)
m_brushStroke->heal();
if (!m_brushStroke->patches().empty())
commitPaintSnapshot(*m_brushStroke);
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
cancelBrush();
return true;
}
void EditorSession::finishBrush()
{
finishBrushImmediately();
}
// Immutable tiles land at once, undo entry included.
void EditorSession::commitPaintSnapshot(const BrushStroke &stroke)
{
const PaintSnapshot result = stroke.paintSnapshot();
const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1;
if (!result.transform.isValid() || index < 0)
return;
const ImageLayer current = m_document->layers[index];
if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform)
return;
std::optional<LayerMask> mask = current.mask;
if (!stroke.isMask && mask && !mask->placement && result.bounds != stroke.sourceRect) {
// The mask grows with the layer, revealing past it.
const std::shared_ptr<const RasterSnapshot> raster = RasterSnapshot::replacing(mask->asset, stroke.sourceRect, {}, result.bounds, true);
mask = mask->replacing(ImportedImage(raster, raster->thumbnail(), mask->asset.name));
}
beginEdit(stroke.editName.value_or(stroke.isMask ? QStringLiteral("Paint Mask")
: stroke.settings.erasing ? QStringLiteral("Erase")
: stroke.isBlur ? QStringLiteral("Blur")
: stroke.clone ? QStringLiteral("Clone Stamp")
: stroke.settings.healing ? QStringLiteral("Spot Healing")
: QStringLiteral("Brush Stroke")));
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = result.transform;
layer.isGroup = false;
layer.mask = mask;
// Swift rebuilds the layer; effects stay, shape and text go.
layer.shape = std::nullopt;
layer.text = std::nullopt;
}
endEdit();
}
// A raster edit assembled off the UI thread; one step.
void EditorSession::commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply, std::function<void()> done)
{
// The caller resumes from the event loop, like await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (!stroke->committedTransform().isValid()) {
setBrushError(QString::fromUtf8(ProjectError(ProjectError::Kind::tooLarge).what()));
finish();
return;
}
setIsProjectBusy(true);
const BrushCommit::Input input = stroke->commitInput();
m_committingRaster = CommittingRaster{std::move(stroke), name, std::move(alsoApply), std::move(done)};
m_rasterCommit.setFuture(QtConcurrent::run([input]() -> RasterMade {
try {
return RasterMade{BrushCommit::render(input), std::nullopt};
} catch (const ExportError &error) {
return RasterMade{std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishRasterCommit()
{
const RasterMade made = m_rasterCommit.result();
const CommittingRaster pending = std::move(m_committingRaster.value());
m_committingRaster = std::nullopt;
const auto finish = [&] {
setIsProjectBusy(false);
++m_brushRevision;
notify();
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
};
if (made.failure) {
setBrushError(made.failure);
finish();
return;
}
const BrushStroke &stroke = *pending.stroke;
const BrushCommit::Output &result = *made.output;
try {
const LayerTransform transform = stroke.transform(result.pixelBounds.translated(stroke.committedBounds().topLeft()));
if (!transform.isValid())
throw ProjectError(ProjectError::Kind::tooLarge);
std::optional<LayerMask> mask = stroke.layer.mask;
if (!stroke.isMask && mask && !mask->placement)
mask = mask->replacing(BrushCommit::expandMask(mask->asset, stroke.commitInput(), result.pixelBounds));
// Never over content that changed underneath it.
const int index = m_document ? indexOf(m_document->layers, stroke.layer.id) : -1;
if (index < 0) {
finish();
return;
}
const ImageLayer current = m_document->layers[index];
if (!sameAsset(current.asset, stroke.layer.asset) || current.transform != stroke.layer.transform || !sameMask(current.mask, stroke.layer.mask)) {
finish();
return;
}
beginEdit(pending.name);
ImageLayer &layer = m_document->layers[index];
if (stroke.isMask) {
layer.mask = current.mask ? current.mask->replacing(result.asset) : LayerMask(result.asset);
} else {
layer.asset = result.asset;
layer.transform = transform;
layer.isGroup = false;
// Swift rebuilds the layer; effects stay, shape and text go.
layer.shape = std::nullopt;
layer.text = std::nullopt;
if (mask && current.mask)
mask->isEnabled = current.mask->isEnabled;
layer.mask = mask;
}
if (pending.alsoApply)
pending.alsoApply();
endEdit();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
finish();
}
bool EditorSession::usesOpacityKeys() const
{
return isBrushTool(m_tool) || m_tool == NavigationTool::gradient || m_tool == NavigationTool::move;
}
void EditorSession::typeOpacityDigit(int digit, std::optional<double> time)
{
if (!usesOpacityKeys() || m_brushStroke || m_isProjectBusy || digit < 0 || digit > 9)
return;
const double now = time.value_or(uptime());
int percent = digit == 0 ? 100 : digit * 10;
if (m_pendingOpacityDigit && now - m_pendingOpacityDigit->time < 0.6) {
percent = std::max(1, m_pendingOpacityDigit->digit * 10 + digit);
m_pendingOpacityDigit = std::nullopt;
} else {
m_pendingOpacityDigit = PendingOpacityDigit{digit, now};
}
const double value = percent / 100.0;
if (isBrushTool(m_tool)) {
m_brushSettings.opacity = value;
notify();
} else if (m_tool == NavigationTool::gradient) {
setGradientSettings(GradientSettings{m_gradientSettings.shape, m_gradientSettings.style, m_gradientSettings.reversed, value});
} else {
setSelectedLayersOpacity(value);
}
}
// Shift-[ and Shift-]: hardness in Photoshop's quarters.
void EditorSession::changeBrushHardness(bool increase)
{
if (m_brushStroke)
return;
const double quarter = m_brushSettings.hardness * 4;
const double step = increase ? std::floor(quarter + 0.001) + 1 : std::ceil(quarter - 0.001) - 1;
m_brushSettings.hardness = std::min(4.0, std::max(0.0, step)) / 4;
notify();
}
void EditorSession::changeBrushSize(bool increase)
{
if (m_brushStroke)
return;
// A fifth, at least a pixel: 2 would stay 2.
const double current = m_brushSettings.diameter;
const double stepped = increase ? std::max(current + 1, std::round(current * 1.2)) : std::min(current - 1, std::round(current / 1.2));
m_brushSettings.diameter = std::min(2000.0, std::max(1.0, stepped));
notify();
}
void EditorSession::setBrushSettings(const BrushSettings &settings)
{
m_brushSettings = settings;
refreshGradient();
notify();
}
void EditorSession::setBrushMode(BrushToolMode mode)
{
m_brushMode = mode;
notify();
}
void EditorSession::setBlurMode(BlurToolMode mode)
{
m_blurMode = mode;
notify();
}
void EditorSession::setSpotHealingMode(SpotHealingMode mode)
{
m_spotHealingMode = mode;
notify();
}
void EditorSession::setMaskPaintWhite(bool white)
{
m_maskPaintWhite = white;
refreshGradient();
notify();
}
+24
View File
@@ -0,0 +1,24 @@
// Swift's EditorSession+Brush extension: a stroke from press to commit.
public:
bool canPaint() const;
std::unique_ptr<BrushStroke> makeRasterEdit(const ImageLayer &layer, const BrushSettings &settings = BrushSettings()) const;
void beginBrush(QPointF point);
void continueBrush(QPointF point);
// Where a Shift-click's line starts; nil on another target.
std::optional<QPointF> shiftLineStart() const;
void cancelBrush();
// Mouse-up's own call; false while busy.
bool finishBrushImmediately();
void finishBrush();
void commitPaintSnapshot(const BrushStroke &stroke);
// Off the UI thread; `done` follows the step or refusal.
void commitRasterEdit(std::shared_ptr<const BrushStroke> stroke, const QString &name, std::function<void()> alsoApply = {},
std::function<void()> done = {});
bool usesOpacityKeys() const;
// Digits set 10%…100%; two quick ones, an exact value.
void typeOpacityDigit(int digit, std::optional<double> time = std::nullopt);
void changeBrushHardness(bool increase);
void changeBrushSize(bool increase);
private:
void finishRasterCommit();
+124
View File
@@ -0,0 +1,124 @@
#include "Document/EditorSession.h"
#include "Logging.h"
#include <QtConcurrent>
bool EditorSession::canStartProjectOperation() const
{
return !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument && !m_showsImporter
&& !m_renamingLayerID && !m_importError && !m_levels && !m_adjustmentEditingID;
}
void EditorSession::waitForFileRequest(std::function<void()> ready)
{
if (canStartProjectOperation())
ready();
else
m_fileRequestWaiters.push_back(std::move(ready));
}
void EditorSession::resumeFileRequests()
{
if (!canStartProjectOperation())
return;
// Each waiter looks again when its turn comes.
for (const std::function<void()> &waiter : std::exchange(m_fileRequestWaiters, {}))
QMetaObject::invokeMethod(this, [this, waiter] { waitForFileRequest(waiter); }, Qt::QueuedConnection);
}
void EditorSession::waitForProjectAccess(std::function<void()> ready)
{
if (!m_isProjectBusy)
ready();
else
m_projectWaiters.push_back(std::move(ready));
}
void EditorSession::updateBusyIndicator()
{
// Quick operations never dim the controls.
if (m_isProjectBusy) {
if (!m_busyTimer.isActive() && !m_showsBusy)
m_busyTimer.start();
} else {
m_busyTimer.stop();
m_showsBusy = false;
}
}
void EditorSession::importImages(const QList<QUrl> &urls, std::optional<QPointF> point, std::function<void()> done)
{
if (urls.isEmpty()) {
if (done)
done();
return;
}
if (m_brushStroke)
finishBrush();
cancelCrop();
commitTransform();
waitForProjectAccess([this, urls, point, done] {
m_pendingImports.push_back({urls, point, done});
if (!m_isImporting) {
setIsImporting(true);
drainImports();
}
});
}
void EditorSession::drainImports()
{
if (m_pendingImports.empty()) {
setIsImporting(false);
if (!m_importFailures.isEmpty())
setImportError(std::exchange(m_importFailures, {}).join("\n\n"));
return;
}
beginEdit(QStringLiteral("Import Images"));
// Without a canvas the first image makes it.
m_importPoint = m_document ? m_pendingImports.front().point : std::nullopt;
m_importIndex = 0;
decodeNext();
}
void EditorSession::decodeNext()
{
const ImportRequest &request = m_pendingImports.front();
if (m_importIndex == request.urls.size()) {
endEdit();
qCInfo(lcApp) << "imported a request of" << request.urls.size() << "files";
// The caller goes on once the queue's state is settled.
if (request.done)
QMetaObject::invokeMethod(this, request.done, Qt::QueuedConnection);
m_pendingImports.pop_front();
drainImports();
return;
}
qint64 usedPixels = 0;
if (m_document) {
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
usedPixels += qint64(layer.asset->size().width()) * layer.asset->size().height();
}
}
const QUrl url = request.urls[m_importIndex];
m_decoder.setFuture(QtConcurrent::run([url, remaining = 100'000'000 - usedPixels]() -> Decoded {
try {
if (!url.isLocalFile())
throw ImageImportError(ImageImportError::Kind::unsupported);
return {ImageImporter::decode(url.toLocalFile(), remaining), QString()};
} catch (const ImageImportError &error) {
return {std::nullopt, url.fileName() + ": " + error.what()};
}
}));
}
void EditorSession::finishDecode()
{
const Decoded decoded = m_decoder.result();
if (decoded.asset)
insert(*decoded.asset, m_importPoint);
else
m_importFailures << decoded.failure;
m_importIndex += 1;
decodeNext();
}
+136
View File
@@ -0,0 +1,136 @@
#pragma once
#include "Document/BrushStroke.h"
#include "Document/Filters.h"
#include "Document/HueSaturation.h"
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include "IO/ProjectStore.h"
#include <QImage>
#include <QPainterPath>
#include <QSet>
#include <QUrl>
#include <functional>
#include <map>
#include <optional>
// The private structs of EditorSession, its private base of types.
struct SessionJobs {
// A brush family's tip while another family is in use.
struct ParkedTip {
double diameter;
double hardness;
double opacity;
};
struct ImportRequest {
QList<QUrl> urls;
std::optional<QPointF> point;
std::function<void()> done;
};
struct Decoded {
std::optional<ImportedImage> asset;
QString failure;
};
struct Baked {
std::map<QUuid, ImportedImage> images;
std::optional<QString> failure;
};
// What an invert waits for and writes back.
struct Inverting {
QUuid layerID;
bool mask;
std::optional<ImageIdentity> image;
std::optional<ImageIdentity> maskImage;
std::function<void()> done;
};
struct Inverted {
std::optional<QImage> image;
std::optional<QString> failure;
};
// What a wand click waits for and applies.
struct Wanding {
SelectionMode mode;
QUuid documentID;
std::function<void()> done;
};
struct Wanded {
std::optional<QPainterPath> path;
std::optional<QString> failure;
};
// The last stroke's end, for a Shift-click's line.
struct LastBrushPoint {
QPointF point;
QUuid layerID;
bool mask;
};
struct PendingOpacityDigit {
int digit;
double time;
};
// A raster edit's result on the way to the layer.
struct RasterMade {
std::optional<BrushCommit::Output> output;
std::optional<QString> failure;
};
struct CommittingRaster {
std::shared_ptr<const BrushStroke> stroke;
QString name;
std::function<void()> alsoApply;
std::function<void()> done;
};
// A filter's preview, and its result for the layer.
struct Filtered {
std::optional<QImage> image;
std::optional<QString> failure;
// The job's settings, which the preview was made with.
FilterSettings settings;
};
struct FilterMade {
std::optional<ImportedImage> asset;
std::optional<LayerTransform> transform;
std::optional<QString> failure;
};
// The crop's resized project, or why it failed.
struct Cropped {
std::optional<ProjectSnapshot> snapshot;
std::optional<QString> failure;
};
// Levels' result for the layer.
struct Leveled {
std::optional<ImportedImage> asset;
std::optional<QString> failure;
};
// A commit's caller, waiting for the preview or the result.
struct Committing {
QUuid editID;
std::function<void()> done;
};
// Hue/Saturation's pixels, or why they failed.
struct Adjusted {
std::optional<AdjustedPixels> pixels;
std::optional<QString> failure;
};
// The layers beneath an adjustment, drawn, or why not.
struct AdjustmentInput {
std::optional<ImportedImage> asset;
std::optional<QString> failure;
};
// A Hue/Saturation commit keeps its edit and its caller.
struct CommittingHue {
HueSaturationEdit edit;
std::function<void()> done;
};
// A merge worked out once: what goes, what it becomes.
struct MergePlan {
std::vector<QUuid> ids;
QSet<QUuid> removed;
QString name;
std::optional<QUuid> parent;
QUuid anchor;
QString action;
};
struct TransformDuplicate {
std::vector<QUuid> copies;
QSet<QUuid> source;
QUuid primary;
};
};
+181
View File
@@ -0,0 +1,181 @@
#include "Document/EditorSession.h"
#include <algorithm>
#include <map>
void EditorSession::addBlankLayer()
{
if (!canEditLayers())
return;
const std::vector<ImageLayer> &existing = m_document->layers;
int number = 1;
const auto taken = [&](const QString &name) {
return std::any_of(existing.begin(), existing.end(), [&](const ImageLayer &layer) { return layer.name == name; });
};
while (taken(QStringLiteral("Layer %1").arg(number)))
++number;
ImageLayer layer(QStringLiteral("Layer %1").arg(number), m_document->size());
const std::optional<ImageLayer> active = activeLayer();
const bool intoFolder = active && active->isGroup;
layer.parentID = intoFolder ? m_activeLayerID : active ? active->parentID : std::nullopt;
if (layer.parentID)
m_collapsedGroupIDs.remove(*layer.parentID);
const int activeIndex = indexOf(existing, m_activeLayerID);
int insertion = activeIndex >= 0 ? activeIndex + 1 : int(existing.size());
// In a folder the layer goes above its topmost contents.
if (intoFolder) {
std::map<QUuid, std::optional<QUuid>> parents;
for (const ImageLayer &each : existing)
parents[each.id] = each.parentID;
const auto isInside = [&](QUuid id) {
std::optional<QUuid> parent = parents[id];
for (int steps = 0; parent && steps < 64; ++steps) {
if (*parent == *m_activeLayerID)
return true;
parent = parents.count(*parent) ? parents[*parent] : std::nullopt;
}
return false;
};
for (int index = int(existing.size()) - 1; index >= 0; --index) {
if (isInside(existing[index].id)) {
insertion = std::max(insertion, index + 1);
break;
}
}
}
beginEdit(QStringLiteral("New Blank Layer"));
m_document->layers.insert(m_document->layers.begin() + insertion, layer);
setActiveLayerID(layer.id);
endEdit();
}
void EditorSession::deleteLayer(QUuid id)
{
if (!canEditLayers() || indexOf(m_document->layers, id) < 0)
return;
if (!deleteWithLiveMaskChoice({id}))
finishDeletingLayer(id, {});
}
void EditorSession::deleteActiveLayer()
{
if (m_activeLayerID)
deleteLayer(*m_activeLayerID);
}
void EditorSession::deleteSelectedLayers()
{
if (!canEditLayers())
return;
// Captured first: deleting moves the active layer.
std::vector<QUuid> ids;
for (const ImageLayer &layer : m_document->layers) {
if (m_selectedLayerIDs.contains(layer.id))
ids.push_back(layer.id);
}
if (ids.size() <= 1)
deleteActiveLayer();
else if (!deleteWithLiveMaskChoice(ids))
finishDeletingLayers(ids, {});
}
void EditorSession::renameLayer(QUuid id, const QString &name)
{
const QString trimmed = name.trimmed();
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (m_isProjectBusy || m_isImporting || trimmed.isEmpty() || index < 0)
return;
beginEdit(QStringLiteral("Rename Layer"));
m_document->layers[index].name = trimmed;
endEdit();
}
void EditorSession::toggleLayerVisibility(QUuid id)
{
const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1;
if (index < 0)
return;
beginEdit(m_document->layers[index].isVisible ? QStringLiteral("Hide Layer") : QStringLiteral("Show Layer"));
m_document->layers[index].isVisible = !m_document->layers[index].isVisible;
endEdit();
}
std::optional<bool> EditorSession::beginVisibilitySwipe(QUuid id)
{
const int index = canEditLayers() ? indexOf(m_document->layers, id) : -1;
if (index < 0)
return std::nullopt;
const bool visible = !m_document->layers[index].isVisible;
beginEdit(visible ? QStringLiteral("Show Layer") : QStringLiteral("Hide Layer"));
setVisibilityInSwipe(id, visible);
return visible;
}
void EditorSession::setVisibilityInSwipe(QUuid id, bool visible)
{
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (index < 0 || m_document->layers[index].isVisible == visible)
return;
m_document->layers[index].isVisible = visible;
notify();
}
void EditorSession::endVisibilitySwipe()
{
endEdit();
}
void EditorSession::reorderLayers(const std::set<int> &offsets, int destination)
{
if (!canEditLayers())
return;
std::vector<ImageLayer> listed(m_document->layers.rbegin(), m_document->layers.rend());
const int count = int(listed.size());
const bool inRange = std::all_of(offsets.begin(), offsets.end(), [&](int offset) { return offset >= 0 && offset < count; });
if (!inRange || destination < 0 || destination > count)
return;
// SwiftUI's move: the picked rows land before `destination`.
std::vector<ImageLayer> moved, kept;
int before = 0;
for (int index = 0; index < count; ++index) {
const bool picked = offsets.count(index) > 0;
(picked ? moved : kept).push_back(listed[index]);
before += picked && index < destination;
}
kept.insert(kept.begin() + (destination - before), moved.begin(), moved.end());
beginEdit(QStringLiteral("Reorder Layers"));
m_document->layers.assign(kept.rbegin(), kept.rend());
endEdit();
}
bool EditorSession::canMoveActiveLayer(int offset) const
{
const std::optional<ImageLayer> active = canEditLayers() ? activeLayer() : std::nullopt;
if (!active)
return false;
int position = -1, siblings = 0;
for (const ImageLayer &layer : m_document->layers) {
if (layer.parentID != active->parentID)
continue;
if (layer.id == active->id)
position = siblings;
++siblings;
}
return position >= 0 && position + offset >= 0 && position + offset < siblings;
}
void EditorSession::moveActiveLayer(int offset)
{
if (!canMoveActiveLayer(offset))
return;
const ImageLayer active = *activeLayer();
std::vector<int> siblings;
for (int index = 0; index < int(m_document->layers.size()); ++index) {
if (m_document->layers[index].parentID == active.parentID)
siblings.push_back(index);
}
const int from = indexOf(m_document->layers, active.id);
const auto position = std::find(siblings.begin(), siblings.end(), from) - siblings.begin();
beginEdit(QStringLiteral("Reorder Layers"));
std::swap(m_document->layers[from], m_document->layers[siblings[position + offset]]);
endEdit();
}
+126
View File
@@ -0,0 +1,126 @@
#include "Document/EditorSession+Model.h"
#include "IO/ProjectStore.h"
#include <algorithm>
ImageLayer::ImageLayer(ImportedImage imported, QPointF origin)
: id(QUuid::createUuid()), asset(std::move(imported)),
transform{.origin = origin, .size = QSizeF(asset->size())}, name(asset->name)
{
}
int indexOf(const std::vector<ImageLayer> &layers, std::optional<QUuid> id)
{
const auto found = std::find_if(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return id && layer.id == *id; });
return found == layers.end() ? -1 : int(found - layers.begin());
}
ImageLayer::ImageLayer(QString name, QSizeF blankSize)
: id(QUuid::createUuid()), transform{.origin = QPointF(0, 0), .size = blankSize}, name(std::move(name))
{
}
ImageLayer::ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedImage> asset, std::optional<LayerMask> mask)
: id(record.id), asset(std::move(asset)), transform(record.transform), name(record.name), isVisible(record.isVisible),
parentID(record.parentID), isGroup(record.isGroup == true), opacity(record.opacity.value_or(1)),
blendMode(record.blendMode.value_or(LayerBlendMode::normal)), maskSourceID(record.maskSourceID), mask(std::move(mask)),
adjustment(record.adjustment), effects(record.effects)
{
}
bool operator==(const ImageLayer &lhs, const ImageLayer &rhs)
{
const bool sameImage = lhs.asset.has_value() == rhs.asset.has_value()
&& (!lhs.asset || lhs.asset->identity() == rhs.asset->identity());
return lhs.id == rhs.id && lhs.name == rhs.name && lhs.isVisible == rhs.isVisible
&& lhs.transform == rhs.transform && sameImage && lhs.parentID == rhs.parentID
&& lhs.isGroup == rhs.isGroup && lhs.opacity == rhs.opacity && lhs.blendMode == rhs.blendMode
&& lhs.mask == rhs.mask && lhs.maskSourceID == rhs.maskSourceID && lhs.adjustment == rhs.adjustment && lhs.shape == rhs.shape
&& lhs.text == rhs.text && lhs.effects == rhs.effects;
}
CanvasDocument::CanvasDocument(int width, int height)
: id(QUuid::createUuid()), width(width), height(height)
{
}
CanvasDocument::CanvasDocument(const ProjectSnapshot &snapshot)
: id(snapshot.manifest.documentID), width(int(snapshot.manifest.width)), height(int(snapshot.manifest.height)),
resolution(snapshot.manifest.resolution.value_or(72))
{
for (const ProjectLayerRecord &record : snapshot.manifest.layers) {
const auto image = snapshot.images.find(record.id);
layers.push_back(ImageLayer(record, image == snapshot.images.end() ? std::nullopt : std::optional(image->second),
snapshot.mask(record)));
}
}
namespace {
// Swift's CharacterSet.whitespaces: category Zs and tab.
bool isWhitespace(QChar character)
{
return character == u'\t' || character.category() == QChar::Separator_Space;
}
// Swift's Int(String): optional sign, then ASCII digits only.
std::optional<int> wholeNumber(QStringView text)
{
qsizetype index = text.startsWith(u'+') || text.startsWith(u'-') ? 1 : 0;
if (index == text.size())
return std::nullopt;
for (; index < text.size(); ++index) {
if (text[index] < u'0' || text[index] > u'9')
return std::nullopt;
}
bool fits = false;
const int number = text.toInt(&fits);
return fits ? std::optional<int>(number) : std::nullopt;
}
}
std::optional<int> CanvasDocument::validDimension(const QString &value)
{
qsizetype first = 0, last = value.size();
while (first < last && isWhitespace(value[first]))
++first;
while (last > first && isWhitespace(value[last - 1]))
--last;
const std::optional<int> number = wholeNumber(QStringView(value).mid(first, last - first));
if (!number || *number < 1 || *number > 30'000)
return std::nullopt;
return number;
}
QString label(NavigationTool tool)
{
switch (tool) {
case NavigationTool::move: return QStringLiteral("Move / Transform (V)");
case NavigationTool::marquee: return QStringLiteral("Marquee (M)");
case NavigationTool::lasso: return QStringLiteral("Lasso (L)");
case NavigationTool::wand: return QStringLiteral("Magic Wand (W)");
case NavigationTool::crop: return QStringLiteral("Crop (C)");
case NavigationTool::brush: return QStringLiteral("Brush (B) · Eraser (E)");
case NavigationTool::spotHealing: return QStringLiteral("Spot Healing Brush (J)");
case NavigationTool::cloneStamp: return QStringLiteral("Clone Stamp (S) · Alt-click sets the source");
case NavigationTool::blur: return QStringLiteral("Smear (R)");
case NavigationTool::gradient: return QStringLiteral("Gradient (G)");
case NavigationTool::shape: return QStringLiteral("Shape (U) · Shift-U switches Rectangle/Ellipse");
case NavigationTool::type: return QStringLiteral("Type (T)");
case NavigationTool::eyedropper: return QStringLiteral("Eyedropper (I)");
case NavigationTool::hand: return QStringLiteral("Hand (H)");
case NavigationTool::zoom: return QStringLiteral("Zoom (Z)");
// No tool sits in no rail: nothing to name.
case NavigationTool::idle: break;
}
throw std::logic_error("no tool has no label");
}
bool isBrushTool(NavigationTool tool)
{
return tool == NavigationTool::brush || tool == NavigationTool::spotHealing
|| tool == NavigationTool::cloneStamp || tool == NavigationTool::blur;
}
bool isSelectionTool(NavigationTool tool)
{
return tool == NavigationTool::marquee || tool == NavigationTool::lasso || tool == NavigationTool::wand;
}
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include "Document/LayerAdjustment.h"
#include "Document/LayerAppearance.h"
#include "Document/LayerGroups.h"
#include "Document/LayerMask.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
#include "Document/ShapeTool.h"
#include "Document/TypeTool.h"
#include "IO/ImageImporter.h"
#include <QString>
#include <QUuid>
#include <optional>
#include <vector>
struct ImageLayer {
QUuid id;
std::optional<ImportedImage> asset;
LayerTransform transform;
QString name;
bool isVisible = true;
std::optional<QUuid> parentID;
bool isGroup = false;
double opacity = 1;
LayerBlendMode blendMode = LayerBlendMode::normal;
std::optional<QUuid> maskSourceID;
std::optional<LayerMask> mask;
std::optional<LayerAdjustment> adjustment;
// Set on layers the Shape tool made; see `liveShape`.
std::optional<LayerShape> shape;
// A stroke, shadows or overlay drawn round the pixels.
std::optional<LayerEffects> effects;
std::optional<LayerText> text;
ImageLayer(ImportedImage imported, QPointF origin);
ImageLayer(QString name, QSizeF blankSize);
// A layer as a project records it.
ImageLayer(const ProjectLayerRecord &record, std::optional<ImportedImage> asset, std::optional<LayerMask> mask);
QPointF origin() const { return transform.origin; }
QSizeF size() const { return transform.size; }
ProjectLayerRecord hierarchyRecord() const;
LayerTransform maskTransform() const;
// The shape this layer still is: none once repainted otherwise.
std::optional<LayerShape> liveShape() const;
// The text this layer still is: none once repainted otherwise.
std::optional<LayerText> liveText() const;
friend bool operator==(const ImageLayer &lhs, const ImageLayer &rhs);
};
// The layer's place in the list, or -1.
int indexOf(const std::vector<ImageLayer> &layers, std::optional<QUuid> id);
struct ProjectSnapshot;
struct CanvasDocument {
QUuid id;
int width;
int height;
double resolution = 72;
std::vector<ImageLayer> layers; // Bottom to top.
// Part of the document so undo covers it; never saved.
std::optional<DocumentSelection> selection;
CanvasDocument(int width, int height);
// The canvas a project holds, each layer as recorded.
explicit CanvasDocument(const ProjectSnapshot &snapshot);
QSizeF size() const { return QSizeF(width, height); }
static std::optional<int> validDimension(const QString &value);
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
QSet<QUuid> effectiveVisibleIDs() const;
std::vector<ImageLayer> renderLayers() const;
friend bool operator==(const CanvasDocument &lhs, const CanvasDocument &rhs) = default;
};
enum class NavigationTool {
move, marquee, lasso, wand, crop, brush, spotHealing, cloneStamp, blur,
gradient, shape, type, eyedropper, hand, zoom, idle
};
// The rail's tooltip: the tool's name and its key.
QString label(NavigationTool tool);
bool isBrushTool(NavigationTool tool);
bool isSelectionTool(NavigationTool tool);
+71
View File
@@ -0,0 +1,71 @@
#include "Document/EditorSession.h"
#include "Logging.h"
std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
{
if (!m_document)
return std::nullopt;
ProjectSnapshot snapshot{.manifest = {.resolution = m_document->resolution, .documentID = m_document->id, .width = m_document->width,
.height = m_document->height, .activeLayerID = m_activeLayerID, .layers = {}},
.images = {}};
for (const ImageLayer &layer : m_document->layers) {
if (layer.asset)
snapshot.images.insert({layer.id, *layer.asset});
if (layer.mask)
snapshot.masks.insert({layer.id, layer.mask->asset});
snapshot.manifest.layers.push_back(layer.hierarchyRecord());
}
return snapshot;
}
void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path)
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
m_cropRect.reset();
m_transformEdit = std::nullopt;
m_document = CanvasDocument(snapshot);
// Install loads shapes and text; Swift's resize rebuild does not.
for (size_t index = 0; index < m_document->layers.size(); ++index) {
ImageLayer &layer = m_document->layers[index];
layer.shape = LayerShape::loaded(snapshot.manifest.layers[index].shape, layer.asset);
layer.text = LayerText::loaded(snapshot.manifest.layers[index].text, layer.asset);
}
setActiveLayerID(snapshot.manifest.activeLayerID);
m_projectPath = path;
m_renamingLayerID = std::nullopt;
history.reset();
viewport.fit(m_document->size());
qCInfo(lcApp).noquote() << "installed a project of" << int(m_document->layers.size()) << "layers from" << path;
resumeFileRequests();
notify();
}
void EditorSession::setProjectPath(std::optional<QString> path)
{
m_projectPath = std::move(path);
notify();
}
void EditorSession::clearProject()
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
m_cropRect.reset();
m_transformEdit = std::nullopt;
m_document = std::nullopt;
setActiveLayerID(std::nullopt);
m_renamingLayerID = std::nullopt;
m_projectPath = std::nullopt;
history.reset();
resumeFileRequests();
notify();
}
void EditorSession::createNewProject(int width, int height)
{
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000)
return;
clearProject();
createDocument(width, height, true);
}
+7
View File
@@ -0,0 +1,7 @@
// Swift's EditorSession+Projects extension: snapshots in and out.
public:
std::optional<ProjectSnapshot> projectSnapshot() const;
// Only a package that loaded and validated whole is installed.
void installProject(const ProjectSnapshot &snapshot, const QString &path);
void clearProject();
void createNewProject(int width, int height);
+296
View File
@@ -0,0 +1,296 @@
#include "Document/Distort.h"
#include "Document/EditorSession.h"
#include <algorithm>
bool EditorSession::canTransform() const
{
if (!canEditLayers())
return false;
if (transformsAsGroup())
return !groupTransformMembers().empty();
const std::optional<ImageLayer> active = activeLayer();
return active && active->asset && m_document->effectiveVisibleIDs().contains(active->id);
}
bool EditorSession::transformsAsGroup() const
{
const std::optional<ImageLayer> active = activeLayer();
return m_selectedLayerIDs.size() > 1 || (m_selectedLayerIDs.size() == 1 && active && active->isGroup);
}
std::vector<ImageLayer> EditorSession::groupTransformMembers() const
{
if (!transformsAsGroup() || !m_document)
return {};
QHash<QUuid, std::optional<QUuid>> parents;
for (const ImageLayer &layer : m_document->layers)
parents.insert(layer.id, layer.parentID);
const QSet<QUuid> visible = m_document->effectiveVisibleIDs();
std::vector<ImageLayer> members;
for (const ImageLayer &layer : m_document->layers) {
if (!layer.asset || !visible.contains(layer.id))
continue;
// Selected itself, or inside a selected folder.
std::optional<QUuid> current = layer.id;
for (int step = 0; step < 64 && current; ++step) {
if (m_selectedLayerIDs.contains(*current)) {
members.push_back(layer);
break;
}
current = parents.value(*current);
}
}
return members;
}
std::optional<LayerTransform> EditorSession::groupTransformBox() const
{
const std::vector<ImageLayer> members = groupTransformMembers();
if (members.empty())
return std::nullopt;
QPointF low = DistortWarp::corners(members.front().transform)[0], high = low;
for (const ImageLayer &member : members) {
for (const QPointF corner : DistortWarp::corners(member.transform)) {
low = {std::min(low.x(), corner.x()), std::min(low.y(), corner.y())};
high = {std::max(high.x(), corner.x()), std::max(high.y(), corner.y())};
}
}
// Rounding can leave a unit layer's box under one pixel.
return LayerTransform{.origin = low, .size = {std::max(1.0, high.x() - low.x()), std::max(1.0, high.y() - low.y())}};
}
void EditorSession::beginTransform(bool persistent)
{
cancelCrop();
const std::optional<ImageLayer> layer = activeLayer();
if (!canTransform() || !layer)
return;
m_tool = NavigationTool::move;
if (transformsAsGroup()) {
const std::optional<LayerTransform> box = groupTransformBox();
if (!box)
return;
QHash<QUuid, LayerTransform> originals;
for (const ImageLayer &member : groupTransformMembers())
originals.insert(member.id, member.transform);
m_transformEdit = TransformEdit{layer->id, *box, TransformGroup{*box, originals}, false, persistent, std::nullopt, nullptr};
} else {
// Linked, layer and mask move together as the layer.
const bool maskAlone = transformTargetsMask();
m_transformEdit = TransformEdit{layer->id, maskAlone ? layer->maskTransform() : layer->transform, std::nullopt, maskAlone, persistent, std::nullopt, nullptr};
}
notify();
}
void EditorSession::setTransformAutoSelect(bool picks)
{
m_transformAutoSelect = picks;
notify();
}
void EditorSession::setShowsTransformControls(bool shows)
{
m_showsTransformControls = shows;
notify();
}
void EditorSession::setLocksTransformRatio(bool locks)
{
m_locksTransformRatio = locks;
notify();
}
// A folder has no pixels to copy: it just moves.
void EditorSession::beginDuplicateTransform()
{
if (m_transformDuplicate || !m_activeLayerID)
return;
const QUuid primary = *m_activeLayerID;
commitTransform();
if (!canTransform())
return;
const QSet<QUuid> selection = m_selectedLayerIDs;
// Bottom to top: the copies keep their order.
std::vector<QUuid> targets;
for (const ImageLayer &layer : m_document->layers) {
if (selection.contains(layer.id) && !layer.isGroup)
targets.push_back(layer.id);
}
if (targets.empty())
return;
beginEdit(targets.size() > 1 ? QStringLiteral("Duplicate Layers") : QStringLiteral("Duplicate Layer"));
std::vector<QUuid> copies;
for (const QUuid &id : targets) {
selectLayer(id);
duplicateActiveLayer();
copies.push_back(m_activeLayerID.value());
}
m_transformDuplicate = TransformDuplicate{copies, selection, primary};
selectLayers(QSet<QUuid>(copies.begin(), copies.end()), copies.back());
beginTransform(false);
}
// The duplicate's step closes with its transform.
void EditorSession::endDuplicateTransform()
{
if (!m_transformDuplicate)
return;
m_transformDuplicate = std::nullopt;
endEdit();
}
void EditorSession::previewTransform(const LayerTransform &value)
{
if (!value.isValid() || !m_transformEdit)
return;
m_transformEdit->draft = value;
notify();
}
void EditorSession::commitTransform()
{
snapGuides = {};
// Whatever ends a transform ends these two as well.
if (m_blendPreview) {
m_blendPreview = std::nullopt;
notify();
}
finishOpacityEdit();
if (!m_transformEdit)
return;
if (m_transformEdit->floating) {
// Unchanged: restored exactly, so soft edges keep no seam.
const TransformEdit edit = *std::exchange(m_transformEdit, std::nullopt);
if (edit.draft == edit.floating->original && !edit.corners)
cancelFloatingTransform(*edit.floating);
else
mergeFloatingTransform(edit, *edit.floating);
return;
}
if (m_transformEdit->mask) {
commitMaskTransform();
return;
}
if (m_transformEdit->corners) {
const TransformEdit edit = *m_transformEdit;
m_transformEdit = std::nullopt;
commitDistort(edit, *edit.corners);
endDuplicateTransform();
return;
}
// One transaction: an edit that moves nothing records nothing.
beginEdit(m_transformEdit->group ? QStringLiteral("Transform Layers") : QStringLiteral("Transform Layer"));
for (int index = 0; index < int(m_document->layers.size()); ++index) {
ImageLayer &layer = m_document->layers[size_t(index)];
// A layer the box squeezes under a pixel stays put.
const std::optional<LayerTransform> moved = pendingTransform(layer);
if (!moved || !moved->isValid())
continue;
// A linked mask goes along; an unlinked one stays.
if (layer.mask)
layer.mask->placement = layer.mask->placementMovingLayer(layer.transform, *moved);
layer.transform = *moved;
redrawShape(index);
}
m_transformEdit = std::nullopt;
endEdit();
endDuplicateTransform();
}
void EditorSession::cancelTransform()
{
snapGuides = {};
if (!m_transformEdit)
return;
const std::shared_ptr<const FloatingTransform> floating = std::exchange(m_transformEdit, std::nullopt)->floating;
if (m_transformDuplicate) {
// The copies go and the old selection returns.
const TransformDuplicate duplicate = *m_transformDuplicate;
std::erase_if(m_document->layers, [&](const ImageLayer &layer) {
return std::find(duplicate.copies.begin(), duplicate.copies.end(), layer.id) != duplicate.copies.end();
});
selectLayers(duplicate.source, duplicate.primary);
endDuplicateTransform();
}
if (floating)
cancelFloatingTransform(*floating);
notify();
}
std::optional<QSizeF> EditorSession::transformPixelSize() const
{
if (m_transformEdit && m_transformEdit->group)
return m_transformEdit->group->box.size;
if (!m_transformEdit && transformsAsGroup()) {
const std::optional<LayerTransform> box = groupTransformBox();
return box ? std::optional(box->size) : std::nullopt;
}
// A mask has no pixel size to scale against.
if (transformTargetsMask())
return std::nullopt;
if (m_transformEdit && m_transformEdit->floating)
return m_transformEdit->floating->pixelSize;
const std::optional<ImageLayer> active = activeLayer();
return active && active->asset ? std::optional(QSizeF(active->asset->size())) : std::nullopt;
}
bool EditorSession::transformTargetsMask() const
{
if (m_transformEdit)
return m_transformEdit->mask;
const std::optional<ImageLayer> active = activeLayer();
return m_isMaskSelected && active && active->mask && !active->mask->isLinked;
}
LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const
{
if (const std::optional<LayerTransform> pending = pendingTransform(layer))
return *pending;
// Content-Aware Fill past the edge previews on the grown layer.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->grownTransform;
return layer.transform;
}
LayerTransform EditorSession::editedTransform(const ImageLayer &layer) const
{
if (m_transformEdit && m_transformEdit->layerID == layer.id)
return m_transformEdit->draft;
// Before an edit the handles sit on the group's box.
if (!m_transformEdit && layer.id == m_activeLayerID && transformsAsGroup())
return groupTransformBox().value_or(layer.transform);
return layer.id == m_activeLayerID && transformTargetsMask() ? layer.maskTransform() : layer.transform;
}
std::optional<LayerTransform> EditorSession::pendingTransform(const ImageLayer &layer) const
{
// An edit of the mask alone moves no layer.
if (!m_transformEdit || m_transformEdit->mask)
return std::nullopt;
if (m_transformEdit->group) {
const TransformGroup &group = *m_transformEdit->group;
const auto original = group.originals.constFind(layer.id);
return original == group.originals.constEnd() ? std::nullopt : std::optional(original->following(group.box, m_transformEdit->draft));
}
return m_transformEdit->layerID == layer.id ? std::optional(m_transformEdit->draft) : std::nullopt;
}
void EditorSession::nudgeLayer(double dx, double dy)
{
const bool alreadyEditing = m_transformEdit.has_value();
if (!alreadyEditing)
beginTransform(false);
if (!m_transformEdit)
return;
LayerTransform value = m_transformEdit->draft;
value.origin += QPointF(dx, dy);
previewTransform(value);
if (const std::optional<Corners> corners = m_transformEdit->corners) {
Corners moved = *corners;
for (QPointF &corner : moved)
corner += QPointF(dx, dy);
previewCorners(moved);
}
if (!alreadyEditing)
commitTransform();
}
+347
View File
@@ -0,0 +1,347 @@
#include "Document/EditorSession.h"
#include "Logging.h"
#include <cmath>
EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(this)
{
// A coarse timer may fire a twentieth early.
m_busyTimer.setTimerType(Qt::PreciseTimer);
m_busyTimer.setSingleShot(true);
m_busyTimer.setInterval(busyIndicatorDelay);
connect(&m_busyTimer, &QTimer::timeout, this, [this] {
m_showsBusy = true;
notify();
});
connect(&m_decoder, &QFutureWatcher<Decoded>::finished, this, &EditorSession::finishDecode);
connect(&m_baker, &QFutureWatcher<Baked>::finished, this, &EditorSession::finishBake);
connect(&m_inverter, &QFutureWatcher<Inverted>::finished, this, &EditorSession::finishInvert);
connect(&m_wand, &QFutureWatcher<Wanded>::finished, this, &EditorSession::finishWand);
connect(&m_filterPreview, &QFutureWatcher<Filtered>::finished, this, &EditorSession::finishFilterPreview);
connect(&m_filterCommit, &QFutureWatcher<FilterMade>::finished, this, &EditorSession::finishFilterCommit);
connect(&m_cropCommit, &QFutureWatcher<Cropped>::finished, this, &EditorSession::finishCropCommit);
connect(&m_levelsHistogram, &QFutureWatcher<std::optional<LevelsHistogram>>::finished, this, &EditorSession::finishLevelsHistogram);
connect(&m_levelsPreview, &QFutureWatcher<std::optional<QImage>>::finished, this, &EditorSession::finishLevelsPreview);
connect(&m_levelsCommit, &QFutureWatcher<Leveled>::finished, this, &EditorSession::finishLevelsCommit);
connect(&m_hueSaturationTask, &QFutureWatcher<Adjusted>::finished, this, &EditorSession::finishHueSaturationPreview);
connect(&m_hueSaturationCommit, &QFutureWatcher<Adjusted>::finished, this, &EditorSession::finishHueSaturationCommit);
connect(&m_rasterCommit, &QFutureWatcher<RasterMade>::finished, this, &EditorSession::finishRasterCommit);
}
void EditorSession::setActiveLayerID(std::optional<QUuid> id)
{
// Another layer: its pixels are the target again.
if (id != m_activeLayerID)
m_isMaskSelected = false;
m_activeLayerID = id;
m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>();
notify();
}
std::optional<ImageLayer> EditorSession::activeLayer() const
{
if (!m_document || !m_activeLayerID)
return std::nullopt;
const auto found = std::find_if(m_document->layers.begin(), m_document->layers.end(),
[&](const ImageLayer &layer) { return layer.id == *m_activeLayerID; });
return found == m_document->layers.end() ? std::nullopt : std::optional(*found);
}
void EditorSession::setIsProjectBusy(bool busy)
{
m_isProjectBusy = busy;
// Freed waiters each look again when their turn comes.
if (!busy) {
for (const std::function<void()> &waiter : std::exchange(m_projectWaiters, {}))
QMetaObject::invokeMethod(this, [this, waiter] { waitForProjectAccess(waiter); }, Qt::QueuedConnection);
}
resumeFileRequests();
updateBusyIndicator();
notify();
}
void EditorSession::setShowsNewDocument(bool shows)
{
m_showsNewDocument = shows;
resumeFileRequests();
notify();
}
void EditorSession::setShowsImporter(bool shows)
{
m_showsImporter = shows;
resumeFileRequests();
notify();
}
void EditorSession::setIsImporting(bool importing)
{
m_isImporting = importing;
resumeFileRequests();
notify();
}
void EditorSession::setImportError(std::optional<QString> error)
{
m_importError = std::move(error);
resumeFileRequests();
notify();
}
void EditorSession::setBrushError(std::optional<QString> error)
{
m_brushError = std::move(error);
notify();
}
void EditorSession::setRenamingLayerID(std::optional<QUuid> id)
{
m_renamingLayerID = id;
resumeFileRequests();
notify();
}
void EditorSession::setAdjustmentEditingID(std::optional<QUuid> id)
{
m_adjustmentEditingID = id;
resumeFileRequests();
notify();
}
void EditorSession::selectLayer(std::optional<QUuid> id)
{
dropEffectSelection();
if (id != m_activeLayerID && !finishText())
return;
if (m_brushStroke || m_warpStroke || m_levels)
return;
if (id != m_activeLayerID) {
commitTransform();
resolveGradient();
}
setActiveLayerID(id);
}
void EditorSession::selectTool(NavigationTool value)
{
if (m_tool != value && !finishText())
return;
if (m_isProjectBusy || m_brushStroke || m_warpStroke || m_levels)
return;
if (m_tool != value) {
commitTransform();
cancelCrop();
resolveGradient();
cancelLasso();
cancelShape();
}
// Each family's tip parks on leaving; sessions begin in 0.
const int from = tipFamily(m_tool), to = tipFamily(value);
if (from != to) {
const ParkedTip parked = m_parkedBrushTips.at(to);
m_parkedBrushTips[from] = ParkedTip{m_brushSettings.diameter, m_brushSettings.hardness, m_brushSettings.opacity};
m_brushSettings.diameter = parked.diameter;
m_brushSettings.hardness = parked.hardness;
m_brushSettings.opacity = parked.opacity;
}
m_tool = value;
// The Crop tool opens on the whole canvas, freely.
if (value == NavigationTool::crop && !m_cropRect && m_document) {
m_cropRatioChoice = QStringLiteral("Free");
m_cropRect = QRectF(QPointF(0, 0), m_document->size());
}
notify();
}
int EditorSession::tipFamily(NavigationTool tool)
{
return tool == NavigationTool::cloneStamp ? 1 : tool == NavigationTool::blur ? 2 : 0;
}
// Tab: the tool's next mode; others arrive with their tools.
void EditorSession::cycleToolMode()
{
if (m_isProjectBusy || m_brushStroke || m_warpStroke)
return;
if (m_tool == NavigationTool::marquee)
toggleMarqueeKind();
else if (m_tool == NavigationTool::lasso)
toggleLassoKind();
else if (m_tool == NavigationTool::shape)
toggleShapeKind();
else if (m_tool == NavigationTool::brush)
setBrushMode(m_brushMode == BrushToolMode::paint ? BrushToolMode::erase : BrushToolMode::paint);
else if (m_tool == NavigationTool::gradient)
setGradientSettings(GradientSettings{allGradientShapes[(size_t(m_gradientSettings.shape) + 1) % allGradientShapes.size()], m_gradientSettings.style,
m_gradientSettings.reversed, m_gradientSettings.opacity});
else if (m_tool == NavigationTool::blur)
setBlurMode(allBlurToolModes[(size_t(m_blurMode) + 1) % allBlurToolModes.size()]);
else if (m_tool == NavigationTool::spotHealing)
setSpotHealingMode(allSpotHealingModes[(size_t(m_spotHealingMode) + 1) % allSpotHealingModes.size()]);
else if (m_tool == NavigationTool::cloneStamp)
setCloneSettings(CloneSettings{m_cloneSettings.aligned, !m_cloneSettings.sampleAllLayers});
}
bool EditorSession::canUseHistory() const
{
return !m_textDraft && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_warpStroke && !m_showsNewDocument && !m_showsImporter
&& !m_renamingLayerID && !m_importError && !m_transformEdit && !m_levels;
}
void EditorSession::undo()
{
// As Photoshop, the first Undo discards a pending gradient.
if (m_gradientEdit) {
cancelGradient();
return;
}
if (!canUndo())
return;
if (const std::optional<DocumentHistory::Snapshot> snapshot = history.undo())
restore(*snapshot);
}
void EditorSession::redo()
{
if (!canRedo())
return;
if (const std::optional<DocumentHistory::Snapshot> snapshot = history.redo())
restore(*snapshot);
}
void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
{
cancelCrop();
cancelGradient();
const auto canvas = [](const std::optional<CanvasDocument> &document) { return document ? std::optional(document->id) : std::nullopt; };
const bool changedCanvas = canvas(m_document) != canvas(snapshot.document);
// The mask stays the target while layer and mask remain.
const bool keepsMaskTarget = m_isMaskSelected && m_activeLayerID == snapshot.activeLayerID;
m_document = snapshot.document;
setActiveLayerID(snapshot.activeLayerID);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = keepsMaskTarget && active && active->mask;
if (changedCanvas && m_document)
viewport.fit(m_document->size());
qCInfo(lcApp) << "restored a history snapshot of" << (m_document ? int(m_document->layers.size()) : 0) << "layers";
notify();
}
void EditorSession::beginEdit(const QString &name)
{
history.begin(name, m_document, m_activeLayerID);
// An open transaction closes undo and redo: observers must know.
notify();
}
void EditorSession::endEdit()
{
history.end(m_document, m_activeLayerID);
notify();
}
bool EditorSession::canEditLayers() const
{
return !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting && !m_showsNewDocument && !m_showsImporter
&& !m_renamingLayerID && !m_transformEdit && !m_cropRect && !m_gradientEdit && !m_filterEdit && !m_pixelMove && !m_levels
&& !m_hueSaturation && !m_adjustmentEditingID;
}
void EditorSession::insert(const ImportedImage &asset, std::optional<QPointF> centeredAt)
{
beginEdit(QStringLiteral("Import Image"));
if (!m_document) {
m_document = CanvasDocument(asset.size().width(), asset.size().height());
viewport.fit(m_document->size());
}
const QPointF center = centeredAt.value_or(QPointF(m_document->width / 2.0, m_document->height / 2.0));
ImageLayer layer(asset, QPointF(std::floor(center.x() - asset.size().width() / 2.0), std::floor(center.y() - asset.size().height() / 2.0)));
// Beside the active layer, or inside the active folder.
const std::optional<ImageLayer> active = activeLayer();
layer.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt;
if (layer.parentID)
m_collapsedGroupIDs.remove(*layer.parentID);
m_document->layers.push_back(layer);
setActiveLayerID(layer.id);
endEdit();
}
void EditorSession::createDocument(int width, int height, bool emptyLayer)
{
if (m_isProjectBusy || m_isImporting || width < 1 || width > 30'000 || height < 1 || height > 30'000)
return;
commitTransform();
beginEdit(QStringLiteral("New Canvas"));
CanvasDocument document(width, height);
// File > New starts with a selected blank "Layer 1".
std::optional<QUuid> selected;
if (emptyLayer) {
document.layers.push_back(ImageLayer(QStringLiteral("Layer 1"), document.size()));
selected = document.layers.back().id;
}
m_document = document;
setActiveLayerID(selected);
m_renamingLayerID = std::nullopt;
viewport.fit(m_document->size());
m_showsNewDocument = false;
// A closed sheet or an ended rename frees file requests.
resumeFileRequests();
qCInfo(lcApp) << "new canvas" << width << "x" << height;
endEdit();
}
void EditorSession::fit()
{
if (!m_document)
return;
const CanvasViewport before = viewport;
viewport.fit(m_document->size());
if (!(viewport == before))
notify();
}
void EditorSession::zoom(double value, std::optional<QPointF> anchor)
{
if (!m_document)
return;
// The viewport refuses a zoom that is no number.
const CanvasViewport before = viewport;
viewport.setZoom(value, anchor.value_or(viewport.center()), m_document->size());
if (!(viewport == before))
notify();
}
void EditorSession::setShowsPixelGrid(bool shows)
{
m_showsPixelGrid = shows;
notify();
}
void EditorSession::setCropRect(std::optional<QRectF> rect)
{
if (std::exchange(m_cropRect, rect) != rect)
notify();
}
void EditorSession::setCropRatioChoice(const QString &choice)
{
if (std::exchange(m_cropRatioChoice, choice) != choice)
notify();
}
void EditorSession::setCropError(std::optional<QString> error)
{
if (std::exchange(m_cropError, error) != error)
notify();
}
void EditorSession::setShowsSampleRing(bool shows)
{
m_showsSampleRing = shows;
notify();
}
void EditorSession::requestCanvasFocus()
{
++m_canvasFocusRequest;
notify();
}
+437
View File
@@ -0,0 +1,437 @@
#pragma once
#include "Document/BrushStroke.h"
#include "Document/CloneStamp.h"
#include "Document/ColorPalette.h"
#include "Document/Gradient.h"
#include "Document/Distort.h"
#include "Document/Filters.h"
#include "Document/LevelsAutomatic.h"
#include "Document/FloatingSelection.h"
#include "Document/MagicWand.h"
#include "Document/SelectionClipboard.h"
#include "Document/SelectionEdits.h"
#include "Document/SmudgeLiquify.h"
#include "Document/DocumentHistory.h"
#include "Document/EditorSession+Jobs.h"
#include "Document/EditorSession+Model.h"
#include "IO/ProjectStore.h"
#include "Rendering/CanvasViewport.h"
#include "Rendering/EffectsPreviewCache.h"
#include <QFutureWatcher>
#include <QObject>
#include <QSet>
#include <QTimer>
#include <QUrl>
#include <deque>
#include <map>
#include <functional>
#include <set>
class QPainter;
// The one editor state; `changed` stands in for Swift's observation.
class EditorSession : public QObject, private SessionJobs {
Q_OBJECT
public:
explicit EditorSession(QObject *parent = nullptr);
// The canvas edits this in place, then calls notify().
CanvasViewport viewport;
DocumentHistory history;
// Swift's @ObservationIgnored tuple: the canvas draws it unannounced.
SnapGuides snapGuides;
// The canvas's effects previews, rendered off the UI thread.
EffectsPreviewCache effectsPreviews;
const std::optional<CanvasDocument> &document() const { return m_document; }
NavigationTool tool() const { return m_tool; }
const QSet<QUuid> &collapsedGroupIDs() const { return m_collapsedGroupIDs; }
const QSet<QUuid> &selectedLayerIDs() const { return m_selectedLayerIDs; }
std::optional<QUuid> activeLayerID() const { return m_activeLayerID; }
void setActiveLayerID(std::optional<QUuid> id);
std::optional<ImageLayer> activeLayer() const;
// Blocks overlapping edits at once.
bool isProjectBusy() const { return m_isProjectBusy; }
void setIsProjectBusy(bool busy);
// Busy this long, in milliseconds, before the controls dim.
static constexpr int busyIndicatorDelay = 250;
bool showsBusy() const { return m_showsBusy; }
bool showsNewDocument() const { return m_showsNewDocument; }
void setShowsNewDocument(bool shows);
bool showsImporter() const { return m_showsImporter; }
void setShowsImporter(bool shows);
bool isImporting() const { return m_isImporting; }
void setIsImporting(bool importing);
std::optional<QString> importError() const { return m_importError; }
void setImportError(std::optional<QString> error);
std::optional<QUuid> renamingLayerID() const { return m_renamingLayerID; }
void setRenamingLayerID(std::optional<QUuid> id);
// The first tab's first canvas sheet skips the clipboard.
bool skipsInitialClipboardCanvasSize = false;
// Where the project lives on disk, once opened or saved.
const std::optional<QString> &projectPath() const { return m_projectPath; }
void setProjectPath(std::optional<QString> path);
bool canStartProjectOperation() const;
// `ready` runs at once, or when the session allows.
void waitForFileRequest(std::function<void()> ready);
void waitForProjectAccess(std::function<void()> ready);
void selectLayer(std::optional<QUuid> id);
void selectTool(NavigationTool value);
// Tab: the current tool's next mode.
void cycleToolMode();
// Whether the mask thumbnail, not the layer's, is the target.
bool isMaskSelected() const { return m_isMaskSelected; }
// A blend mode tried on by hovering; nothing is saved.
struct BlendPreview {
QUuid layerID;
LayerBlendMode mode;
friend bool operator==(const BlendPreview &, const BlendPreview &) = default;
};
const std::optional<BlendPreview> &blendPreview() const { return m_blendPreview; }
// A pending transform; layers change when it is committed.
const std::optional<TransformEdit> &transformEdit() const { return m_transformEdit; }
bool canTransform() const;
// Several layers, or a folder's contents, move in one box.
bool transformsAsGroup() const;
std::vector<ImageLayer> groupTransformMembers() const;
std::optional<LayerTransform> groupTransformBox() const;
void beginTransform(bool persistent = true);
// Alt-drag: the selection's copies move, one step.
void beginDuplicateTransform();
// The Move tool's settings (TransformInspector, Ctrl+H).
bool transformAutoSelect() const { return m_transformAutoSelect; }
void setTransformAutoSelect(bool picks);
bool showsTransformControls() const { return m_showsTransformControls; }
void setShowsTransformControls(bool shows);
bool locksTransformRatio() const { return m_locksTransformRatio; }
void setLocksTransformRatio(bool locks);
void previewTransform(const LayerTransform &value);
void commitTransform();
void cancelTransform();
std::optional<QSizeF> transformPixelSize() const;
LayerTransform displayedTransform(const ImageLayer &layer) const;
LayerTransform editedTransform(const ImageLayer &layer) const;
std::optional<LayerTransform> pendingTransform(const ImageLayer &layer) const;
// An unlinked mask, selected, transforms without its layer.
bool transformTargetsMask() const;
void nudgeLayer(double dx, double dy);
const std::optional<LassoDraft> &lassoDraft() const { return m_lassoDraft; }
LassoKind lassoKind() const { return m_lassoKind; }
void setLassoKind(LassoKind kind);
LassoKind marqueeKind() const { return m_marqueeKind; }
void setMarqueeKind(LassoKind kind);
// The options bar's mode; Shift adds, Alt subtracts.
SelectionMode selectionModeChoice() const { return m_selectionModeChoice; }
void setSelectionModeChoice(SelectionMode mode);
std::optional<SelectionMode> heldSelectionMode() const { return m_heldSelectionMode; }
bool selectionAntialiased() const { return m_selectionAntialiased; }
void setSelectionAntialiased(bool antialiased);
// Pixels Expand and Contract grow or shrink by.
int selectionExpandAmount() const { return m_selectionExpandAmount; }
void setSelectionExpandAmount(int amount);
int selectionContractAmount() const { return m_selectionContractAmount; }
void setSelectionContractAmount(int amount);
const std::optional<DocumentSelection> &selectionMoveOrigin() const { return m_selectionMoveOrigin; }
const WandSettings &wandSettings() const { return m_wandSettings; }
void setWandSettings(const WandSettings &settings);
const BrushStroke *brushStroke() const { return m_brushStroke.get(); }
const BrushSettings &brushSettings() const { return m_brushSettings; }
void setBrushSettings(const BrushSettings &settings);
BrushToolMode brushMode() const { return m_brushMode; }
void setBrushMode(BrushToolMode mode);
BlurToolMode blurMode() const { return m_blurMode; }
void setBlurMode(BlurToolMode mode);
// Smudge or Liquify under way; the canvas shows its copy.
const WarpStroke *warpStroke() const { return m_warpStroke.get(); }
SpotHealingMode spotHealingMode() const { return m_spotHealingMode; }
void setSpotHealingMode(SpotHealingMode mode);
bool maskPaintWhite() const { return m_maskPaintWhite; }
void setMaskPaintWhite(bool white);
const PaletteColor &backgroundColor() const { return m_backgroundColor; }
void setBackgroundColor(const PaletteColor &color);
// The open colour picker; the session notifies its changes.
const std::optional<ColorPickerState> &colorPicker() const { return m_colorPicker; }
const GradientSettings &gradientSettings() const { return m_gradientSettings; }
void setGradientSettings(const GradientSettings &settings);
// A gradient drawn and not yet applied.
const std::optional<GradientEdit> &gradientEdit() const { return m_gradientEdit; }
// Clone Stamp's Alt-clicked source, in document pixels, and options.
const std::optional<QPointF> &cloneSource() const { return m_cloneSource; }
const CloneSettings &cloneSettings() const { return m_cloneSettings; }
void setCloneSettings(const CloneSettings &settings);
// Counts every change to a stroke, so the canvas redraws.
int brushRevision() const { return m_brushRevision; }
// Selected pixels on the move, from a Ctrl-drag or Ctrl-arrow.
const PixelMove *pixelMove() const { return m_pixelMove.get(); }
// The one filter open; its sheet shows while it lasts.
const std::optional<FilterEdit> &filterEdit() const { return m_filterEdit; }
// The last committed filter's settings, where the next one starts.
const FilterSettings &filterSettings() const { return m_filterSettings; }
const std::optional<LevelsEdit> &levels() const { return m_levels; }
const std::optional<HueSaturationEdit> &hueSaturation() const { return m_hueSaturation; }
// The newest preview, waiting for the running one.
const std::optional<HueSaturationJob> &hueSaturationPending() const { return m_hueSaturationPending; }
std::optional<HueSampleMode> hueSampleMode() const { return m_hueSampleMode; }
bool hueTargeting() const { return m_hueTargeting; }
// The adjustment layer the colour editors write to.
std::optional<QUuid> adjustmentEditingID() const { return m_adjustmentEditingID; }
void setAdjustmentEditingID(std::optional<QUuid> id);
// Its settings as its editor opened, for Cancel.
const std::optional<LayerAdjustment> &adjustmentOriginal() const { return m_adjustmentOriginal; }
// The effect whose panel is open, bound to its layer.
const std::optional<LayerEffectSelection> &effectsEditing() const { return m_effectsEditing; }
void setEffectsEditing(std::optional<LayerEffectSelection> selection);
// Its layer's effects as the panel opened, for Cancel.
const std::optional<LayerEffects> &effectsEditingOriginal() const { return m_effectsEditingOriginal; }
void setEffectsEditingOriginal(std::optional<LayerEffects> original);
// The effect chosen in the list; see selectedEffect.
const std::optional<LayerEffectSelection> &effectSelection() const { return m_effectSelection; }
bool isModified() const { return history.isModified(); }
bool canUseHistory() const;
// Undo's first press discards a pending gradient.
bool canUndo() const { return canUseHistory() && (history.canUndo() || m_gradientEdit); }
bool canRedo() const { return canUseHistory() && history.canRedo(); }
void undo();
void redo();
// A transaction that nests; a whole gesture is one entry.
void beginEdit(const QString &name);
void endEdit();
bool canEditLayers() const;
void addBlankLayer();
void deleteLayer(QUuid id);
void deleteActiveLayer();
// Every selected layer, folders with their contents: one step.
void deleteSelectedLayers();
void renameLayer(QUuid id, const QString &name);
void toggleLayerVisibility(QUuid id);
// Pressing an eye, then dragging over others: one undo step.
std::optional<bool> beginVisibilitySwipe(QUuid id);
void setVisibilityInSwipe(QUuid id, bool visible);
void endVisibilitySwipe();
// The list runs top down; the document stores bottom up.
void reorderLayers(const std::set<int> &offsets, int destination);
bool canMoveActiveLayer(int offset) const;
void moveActiveLayer(int offset);
// Files decode off the UI thread; `done` follows this request.
void importImages(const QList<QUrl> &urls, std::optional<QPointF> point = std::nullopt, std::function<void()> done = {});
void insert(const ImportedImage &asset, std::optional<QPointF> centeredAt = std::nullopt);
void createDocument(int width, int height, bool emptyLayer = false);
void fit();
void zoom(double value, std::optional<QPointF> anchor = std::nullopt);
// The View menu's grid, shown from 800%.
bool showsPixelGrid() const { return m_showsPixelGrid; }
void setShowsPixelGrid(bool shows);
// The Eyedropper's ring: the sampled colour over the original.
bool showsSampleRing() const { return m_showsSampleRing; }
void setShowsSampleRing(bool shows);
// The Crop tool's frame in document pixels, once drawn.
const std::optional<QRectF> &cropRect() const { return m_cropRect; }
void setCropRect(std::optional<QRectF> rect);
// Swift's ratio names: Free, Original, 1:1, 4:3 and 16:9.
const QString &cropRatioChoice() const { return m_cropRatioChoice; }
void setCropRatioChoice(const QString &choice);
const std::optional<QString> &cropError() const { return m_cropError; }
void setCropError(std::optional<QString> error);
// Counted requests; the canvas takes focus on each new one.
int canvasFocusRequest() const { return m_canvasFocusRequest; }
void requestCanvasFocus();
// What Paste puts back in place, until another copy.
const std::optional<PixelClipboard> &pixelClipboard() const { return m_pixelClipboard; }
// What went wrong in a tool; the window shows it.
const std::optional<QString> &brushError() const { return m_brushError; }
void setBrushError(std::optional<QString> error);
void notify() { emit changed(); }
// Swift's extensions, each declared in its twin's header.
#include "Document/EditorSession+Projects.h"
#include "IO/ImageResizer+Session.h"
#include "Document/LayerMask+Session.h"
#include "Document/LayerAppearance+Session.h"
#include "Document/Crop+Session.h"
#include "Document/Distort+Session.h"
#include "Document/LayerMerge+Session.h"
#include "Document/LayerFlip+Session.h"
#include "Document/Selection+Session.h"
#include "Document/MagicWand+Session.h"
#include "Document/EditorSession+Brush.h"
#include "Document/CloneStamp+Session.h"
#include "Document/ColorPalette+Session.h"
#include "Document/Gradient+Session.h"
#include "Document/ShapeTool+Session.h"
#include "Document/TypeTool+Session.h"
#include "Document/SmudgeLiquify+Session.h"
#include "Document/BlurTool+Session.h"
#include "Document/SelectionEdits+Session.h"
#include "Document/SelectionClipboard+Session.h"
#include "Document/FloatingSelection+Session.h"
#include "Document/Filters+Session.h"
#include "Document/Levels+Session.h"
#include "Document/LevelsAutomatic+Session.h"
#include "Document/HueSaturation+Session.h"
#include "Document/MaskTracing+Session.h"
#include "Document/LayerGroups+Session.h"
#include "Document/LiveLayerMask+Session.h"
#include "Document/LayerAdjustment+Session.h"
#include "Document/AdjustmentEditing+Session.h"
#include "Document/LayerEffects+Session.h"
signals:
void changed();
private:
// Copies layers into this document in place, as Swift does.
friend class ProjectWorkspace;
void restore(const DocumentHistory::Snapshot &snapshot);
void resumeFileRequests();
void updateBusyIndicator();
void drainImports();
void decodeNext();
void finishDecode();
void endDuplicateTransform();
// Clone Stamp is family 1, the Smear 2, others 0.
static int tipFamily(NavigationTool tool);
std::optional<CanvasDocument> m_document;
NavigationTool m_tool = NavigationTool::move;
QSet<QUuid> m_collapsedGroupIDs;
QSet<QUuid> m_selectedLayerIDs;
// The canvas reads previews from a const session: mutable caches.
mutable std::map<QUuid, DistortPreviewCache> m_distortPreviewCache;
mutable std::map<QUuid, DistortEffectsCache> m_distortEffectsCache;
mutable std::optional<MaskDistortPreviewCache> m_maskDistortPreviewCache;
std::optional<TransformEdit> m_transformEdit;
std::optional<TransformDuplicate> m_transformDuplicate;
bool m_transformAutoSelect = false;
bool m_showsTransformControls = true;
bool m_locksTransformRatio = true;
std::optional<BlendPreview> m_blendPreview;
std::optional<QUuid> m_opacityEditLayerID;
bool m_isMaskSelected = false;
std::optional<QUuid> m_activeLayerID;
bool m_isProjectBusy = false;
bool m_showsNewDocument = false;
bool m_showsImporter = false;
bool m_isImporting = false;
std::optional<QString> m_importError;
std::optional<QUuid> m_renamingLayerID;
std::optional<QString> m_projectPath;
bool m_showsPixelGrid = true;
bool m_showsSampleRing = true;
std::optional<QRectF> m_cropRect;
QString m_cropRatioChoice = QStringLiteral("Free");
std::optional<QString> m_cropError;
QFutureWatcher<Cropped> m_cropCommit;
std::function<void()> m_committingCrop;
int m_canvasFocusRequest = 0;
bool m_showsBusy = false;
QTimer m_busyTimer;
std::vector<std::function<void()>> m_projectWaiters;
std::vector<std::function<void()>> m_fileRequestWaiters;
std::deque<ImportRequest> m_pendingImports;
// Where the request now decoding stands.
qsizetype m_importIndex = 0;
std::optional<QPointF> m_importPoint;
QStringList m_importFailures;
QFutureWatcher<Decoded> m_decoder;
std::optional<QString> m_brushError;
// The layers a bake under way will delete.
std::vector<QUuid> m_bakingIDs;
QFutureWatcher<Baked> m_baker;
std::optional<Inverting> m_inverting;
QFutureWatcher<Inverted> m_inverter;
WandSettings m_wandSettings;
std::optional<Wanding> m_wanding;
QFutureWatcher<Wanded> m_wand;
std::unique_ptr<BrushStroke> m_brushStroke;
std::unique_ptr<PixelMove> m_pixelMove;
BrushSettings m_brushSettings;
BrushToolMode m_brushMode = BrushToolMode::paint;
BlurToolMode m_blurMode = BlurToolMode::liquify;
std::unique_ptr<WarpStroke> m_warpStroke;
SpotHealingMode m_spotHealingMode = SpotHealingMode::contentAware;
bool m_maskPaintWhite = false;
PaletteColor m_backgroundColor = PaletteColor::white();
std::optional<ColorPickerState> m_colorPicker;
GradientSettings m_gradientSettings;
std::optional<GradientEdit> m_gradientEdit;
ShapeKind m_shapeKind = ShapeKind::rectangle;
double m_shapeCornerRadius = 0;
double m_shapeLineWidth = 4;
std::optional<ShapeDraft> m_shapeDraft;
mutable std::map<QUuid, ShapePreview> m_shapeTransformPreviewCache;
std::optional<TextDraft> m_textDraft;
LayerTextStyle m_textDefaults;
std::optional<QPointF> m_cloneSource;
CloneSettings m_cloneSettings;
// The offset aligned strokes keep, fixed as a stroke begins.
std::optional<QSizeF> m_cloneOffset;
// Clone Stamp and the Smear keep their own soft tips.
std::map<int, ParkedTip> m_parkedBrushTips{{1, {40, 0, 1}}, {2, {40, 0, 1}}};
std::optional<LastBrushPoint> m_lastBrushPoint;
int m_brushRevision = 0;
std::optional<PendingOpacityDigit> m_pendingOpacityDigit;
std::optional<CommittingRaster> m_committingRaster;
QFutureWatcher<RasterMade> m_rasterCommit;
std::optional<FilterEdit> m_filterEdit;
FilterSettings m_filterSettings;
QFutureWatcher<Filtered> m_filterPreview;
// Out until its finish runs: setFuture drops a pending signal.
bool m_filterRendering = false;
// Which edit's preview is being made; a cancelled edit's drops.
QUuid m_filterPreviewFor;
std::vector<Committing> m_filterCommitWaiting;
std::optional<Committing> m_committingFilter;
QFutureWatcher<FilterMade> m_filterCommit;
std::optional<LevelsEdit> m_levels;
// Which edit a running preview serves; others drop.
QUuid m_levelsPreviewFor;
QFutureWatcher<std::optional<QImage>> m_levelsPreview;
bool m_levelsRendering = false;
QFutureWatcher<std::optional<LevelsHistogram>> m_levelsHistogram;
std::optional<Committing> m_committingLevels;
QFutureWatcher<Leveled> m_levelsCommit;
std::optional<HueSaturationEdit> m_hueSaturation;
std::optional<HueSaturationJob> m_hueSaturationPending;
// Which edit a running preview serves; others drop.
QUuid m_hueSaturationPreviewFor;
QFutureWatcher<Adjusted> m_hueSaturationTask;
bool m_hueSaturationRendering = false;
std::optional<CommittingHue> m_committingHueSaturation;
QFutureWatcher<Adjusted> m_hueSaturationCommit;
std::optional<HueSampleMode> m_hueSampleMode;
bool m_hueTargeting = false;
std::optional<HueTargetDrag> m_hueTargetDrag;
std::optional<QUuid> m_adjustmentEditingID;
std::optional<LayerAdjustment> m_adjustmentOriginal;
std::optional<LayerEffectSelection> m_effectsEditing;
std::optional<LayerEffects> m_effectsEditingOriginal;
std::optional<LayerEffectSelection> m_effectSelection;
std::optional<PixelClipboard> m_pixelClipboard;
// Swift's pasteboard change count, counted from the first copy.
int m_clipboardChanges = 0;
bool m_watchingClipboard = false;
std::optional<LassoDraft> m_lassoDraft;
LassoKind m_lassoKind = LassoKind::freehand;
LassoKind m_marqueeKind = LassoKind::rectangle;
SelectionMode m_selectionModeChoice = SelectionMode::replace;
std::optional<SelectionMode> m_heldSelectionMode;
// The selection as a drag began; one step per drag.
std::optional<DocumentSelection> m_selectionMoveOrigin;
bool m_selectionAntialiased = true;
int m_selectionExpandAmount = 1;
int m_selectionContractAmount = 1;
};
+335
View File
@@ -0,0 +1,335 @@
#include "Document/BrushStroke.h"
#include "Document/ContentFill.h"
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "Document/SubjectRemoval.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include <QtConcurrent>
// Swift's session extension in Filters.swift.
// `canAdjustColors` already refuses an empty selection.
bool EditorSession::canContentAwareFill() const
{
return canAdjustColors() && selection().has_value() && !m_hueSaturation;
}
void EditorSession::beginFilter(FilterKind kind)
{
if (kind == FilterKind::contentAwareFill && !canContentAwareFill())
return;
// Swift beeps; `canAdjustColors` reads the open filter.
if (m_hueSaturation || !canAdjustColors()) {
qCWarning(lcApp) << "a filter cannot open here";
return;
}
// A pending gradient lands first, then the filter begins.
if (m_gradientEdit) {
commitGradient([this, kind] { beginFilter(kind); });
return;
}
commitTransform();
cancelCrop();
cancelLasso();
const ImageLayer layer = activeLayer().value();
const QSizeF size = m_document->size();
const std::optional<DocumentSelection> current = selection();
FilterSettings settings = m_filterSettings;
// Gradient Map starts from the palette, as Photoshop's.
if (kind == FilterKind::gradientMap)
settings.gradientMap = GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)};
try {
// Content-Aware Fill extends the layer over the selection's reach.
std::optional<QRectF> area;
if (kind == FilterKind::contentAwareFill && current) {
const QRectF box = current->path.boundingRect().intersected(QRectF(QPointF(0, 0), size));
if (!box.isEmpty())
area = box;
}
m_filterEdit.emplace(kind, layer, current ? std::optional(current->clip(size)) : std::nullopt, settings, area);
updateFilter(m_filterEdit->settings, true);
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
}
void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
{
if (!m_filterEdit || m_filterEdit->committing)
return;
FilterEdit &edit = *m_filterEdit;
edit.settings = settings.normalized();
edit.preview = preview;
// A bigger blur needs more room round the layer.
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
try {
edit.growForBlur();
edit.preparedPreview.reset();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
}
// An adjustment layer takes the settings; its pixels stay.
if (previewAdjustmentEditing(preview)) {
notify();
return;
}
// Automatic kinds render once.
if (isAutomatic(edit.kind) && edit.preparedPreview && edit.preparedSettings == edit.settings) {
++m_brushRevision;
notify();
return;
}
if (!preview) {
// A job behind another edit's run is Swift's running task.
if (m_filterRendering && m_filterPreviewFor == edit.id)
edit.pending.reset();
edit.preparedPreview.reset();
++m_brushRevision;
notify();
return;
}
edit.pending = edit.previewJob();
renderFilterPreview();
}
// Renders the newest job; one that arrives mid-render waits.
void EditorSession::renderFilterPreview()
{
if (!m_filterEdit || !m_filterEdit->pending)
return;
FilterEdit &edit = *m_filterEdit;
edit.preparing = true;
edit.previewError = std::nullopt;
// One worker at a time: a job waits its turn.
if (!m_filterRendering) {
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
m_filterPreviewFor = edit.id;
m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job]() -> Filtered {
try {
return Filtered{PixelFilter::run(job), std::nullopt, job.settings};
} catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
} catch (const SubjectRemovalError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
} catch (const ExportError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
}
}));
}
notify();
}
void EditorSession::finishFilterPreview()
{
m_filterRendering = false;
const Filtered result = m_filterPreview.result();
// An edit cancelled meanwhile takes nothing; a new one waited.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
FilterEdit &edit = *m_filterEdit;
edit.preparing = false;
edit.previewError = result.failure;
if (edit.preview || isAutomatic(edit.kind)) {
edit.preparedPreview = result.image;
edit.preparedSettings = result.settings;
++m_brushRevision;
}
notify();
}
renderFilterPreview();
// Each waiting commit looks again; a gone edit's just returns.
for (const Committing &waiter : std::exchange(m_filterCommitWaiting, {})) {
if (m_filterEdit && m_filterEdit->id == waiter.editID)
commitFilter(waiter.done);
else
QMetaObject::invokeMethod(this, waiter.done, Qt::QueuedConnection);
}
}
void EditorSession::cancelFilter()
{
// A Gradient Map colour being picked goes with the panel.
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap)
closeColorPicker(false);
if (finishAdjustmentEditing(false) || !m_filterEdit || m_filterEdit->committing)
return;
m_filterEdit.reset();
++m_brushRevision;
notify();
}
void EditorSession::commitFilter(std::function<void()> done)
{
// The caller resumes from the event loop, as after await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap)
closeColorPicker(true);
if (finishAdjustmentEditing(true) || !m_filterEdit || m_filterEdit->committing) {
finish();
return;
}
FilterEdit &edit = *m_filterEdit;
// An automatic filter commits what its preview made, once delivered.
if (isAutomatic(edit.kind) && edit.preparing) {
m_filterCommitWaiting.push_back(Committing{edit.id, done ? done : [] {}});
return;
}
if (isAutomatic(edit.kind) && (!edit.preparedPreview || edit.previewError)) {
finish();
return;
}
// Nothing to change closes as Cancel does, with no step.
if ((edit.kind == FilterKind::lensCorrection && edit.settings.distortion == 0)
|| (edit.kind == FilterKind::exposure && edit.settings.exposure == ExposureSettings())
|| (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0)) {
cancelFilter();
finish();
return;
}
edit.committing = true;
// Swift cancels the running preview: its result and queue drop.
edit.pending.reset();
m_filterPreviewFor = QUuid();
m_filterSettings = edit.settings;
setIsProjectBusy(true);
m_committingFilter = Committing{edit.id, std::move(done)};
// A mask hides the background, which can come back.
if (edit.kind == FilterKind::removeBackground) {
commitBackgroundMask(edit);
return;
}
const std::optional<QImage> cached = isAutomatic(edit.kind) && edit.preparedSettings == edit.settings ? edit.preparedPreview : std::nullopt;
const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur;
// A painted layer flattens in the worker, which catches failures.
m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = edit.settings,
selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached,
grown = edit.grownTransform, spreads]() -> FilterMade {
try {
QImage image = cached ? *cached : PixelFilter::run(FilterJob{kind, grownImage ? *grownImage : original.image(), settings, 1, selection, mapping, seed});
std::optional<LayerTransform> placed = grown;
// A blur is cut back to the pixels it left.
if (spreads && grown) {
const PixelFilter::Trimmed trimmed = PixelFilter::trimmed(image, *grown);
image = trimmed.image;
placed = trimmed.transform;
}
return FilterMade{ImportedImage(image, PixelAdjust::thumbnail(image), rawValue(kind)), placed, std::nullopt};
} catch (const ContentFillError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::commitBackgroundMask(const FilterEdit &edit)
{
// A mask in the layer's own grid is multiplied in.
const int index = indexOf(m_document->layers, edit.layerID);
std::optional<ImportedImage> existing;
if (index >= 0 && m_document->layers[index].mask) {
const LayerMask &owned = *m_document->layers[index].mask;
if (!owned.placement && owned.asset.size() == edit.original.size())
existing = owned.asset;
}
// Painted pixels and masks flatten in the worker.
m_filterCommit.setFuture(QtConcurrent::run([original = edit.original, existing, selection = edit.selection, mapping = edit.mapping,
settings = edit.settings]() -> FilterMade {
try {
const QImage source = original.image();
const std::optional<QImage> under = existing ? std::optional(existing->image()) : std::nullopt;
QImage mask = SubjectRemoval::subjectMask(source, under, settings);
// With a selection, only its part of the mask changes.
if (selection) {
QImage base = under ? *under : BrushRaster::context(source.width(), source.height(), true);
if (!under)
base.fill(255);
mask = PixelAdjust::blend(mask, base, *selection, mapping, true);
}
return FilterMade{LayerMask::assetFrom(mask), std::nullopt, std::nullopt};
} catch (const SubjectRemovalError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishFilterCommit()
{
const FilterMade made = m_filterCommit.result();
const FilterEdit edit = std::exchange(m_filterEdit, std::nullopt).value();
const Committing pending = std::exchange(m_committingFilter, std::nullopt).value();
// The preview stayed up till now: the canvas never flashed.
const auto finish = [&] {
++m_brushRevision;
setIsProjectBusy(false);
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
};
if (made.failure) {
setBrushError(made.failure);
finish();
return;
}
const int index = m_document ? indexOf(m_document->layers, edit.layerID) : -1;
if (index < 0) {
finish();
return;
}
const ImageLayer current = m_document->layers[index];
if (!current.asset || current.asset->identity() != edit.original.identity() || current.transform != edit.transform) {
finish();
return;
}
if (edit.kind == FilterKind::removeBackground) {
beginEdit(rawValue(edit.kind));
// Swift keeps the mask's switches; a placement would misplace it.
LayerMask mask = current.mask ? current.mask->replacing(*made.asset) : LayerMask(*made.asset);
mask.isEnabled = true;
mask.placement = std::nullopt;
m_document->layers[index].mask = mask;
m_isMaskSelected = true;
endEdit();
finish();
return;
}
try {
// The mask moves onto the new box, edge tone beyond.
std::optional<LayerMask> mask = current.mask;
if (edit.grownTransform && current.mask && !current.mask->placement
&& (current.mask->asset.size().width() > 1 || current.mask->asset.size().height() > 1)) {
LayerMask enabled = *current.mask;
enabled.isEnabled = true;
const QSize size = made.asset->size();
const std::optional<QImage> carried = enabled.clipImage(current.transform, made.transform.value(), size.width(), size.height());
if (!carried)
throw ExportError(ExportError::Kind::render);
mask = current.mask->replacing(LayerMask::assetFrom(*carried));
}
beginEdit(rawValue(edit.kind));
ImageLayer layer = current;
layer.asset = made.asset;
layer.transform = made.transform.value_or(current.transform);
layer.mask = mask;
// Swift rebuilds the layer without shape, effects and text.
layer.shape = std::nullopt;
layer.effects = std::nullopt;
layer.text = std::nullopt;
m_document->layers[index] = layer;
endEdit();
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
finish();
}
+16
View File
@@ -0,0 +1,16 @@
// Swift's Filters extension: one filter at a time.
public:
bool canContentAwareFill() const;
void beginFilter(FilterKind kind);
// The settings normalized; Preview off drops the queue and preview.
void updateFilter(const FilterSettings &settings, bool preview);
void cancelFilter();
// Off the UI thread; `done` follows the step or refusal.
void commitFilter(std::function<void()> done = {});
private:
void renderFilterPreview();
void finishFilterPreview();
void finishFilterCommit();
// Swift's commitBackgroundMask: the subject kept by a mask.
void commitBackgroundMask(const FilterEdit &edit);
+242
View File
@@ -0,0 +1,242 @@
#include "Document/Filters.h"
#include "Document/BrushStroke.h"
#include "Document/ContentFill.h"
#include "Document/EditorSession+Model.h"
#include "Document/PixelAdjust.h"
#include "Document/SubjectRemoval.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Rendering/RasterSnapshot.h"
#include <algorithm>
#include <cmath>
#include <numbers>
extern "C" {
#include "BrushPixels.h"
#include "LensPixels.h"
#include "NoisePixels.h"
}
QString rawValue(FilterKind kind)
{
switch (kind) {
case FilterKind::gaussianBlur: return QStringLiteral("Gaussian Blur");
case FilterKind::motionBlur: return QStringLiteral("Motion Blur");
case FilterKind::addNoise: return QStringLiteral("Add Noise");
case FilterKind::lensCorrection: return QStringLiteral("Lens Correction");
case FilterKind::removeBackground: return QStringLiteral("Remove Background");
case FilterKind::contentAwareFill: return QStringLiteral("Content-Aware Fill");
case FilterKind::curves: return QStringLiteral("Curves");
case FilterKind::exposure: return QStringLiteral("Exposure");
case FilterKind::gradientMap: return QStringLiteral("Gradient Map");
case FilterKind::grain: return QStringLiteral("Grain");
}
throw std::logic_error("unknown filter kind");
}
bool isAutomatic(FilterKind kind)
{
return kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground;
}
QString rawValue(BackgroundQuality quality)
{
return quality == BackgroundQuality::basic ? QStringLiteral("Basic") : QStringLiteral("Advanced");
}
bool isImageAdjustment(FilterKind kind)
{
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain;
}
FilterSettings FilterSettings::normalized() const
{
using ImageAdjustmentPixels::clamp;
FilterSettings result = *this;
result.radius = clamp(radius, 0.1, 250, 1);
result.angle = clamp(angle, -90, 90, 0);
result.distance = clamp(distance, 1, 2000, 10);
result.amount = clamp(amount, 0.1, 400, 10);
result.distortion = clamp(distortion, -100, 100, 0);
result.refineEdges = clamp(refineEdges, 0, 40, 12);
result.matteContrast = clamp(matteContrast, 0, 100, 25);
result.shiftEdge = clamp(shiftEdge, -10, 10, 0);
result.exposure = exposure.normalized();
result.gradientMap = gradientMap.normalized();
result.grain = grain.normalized();
return result;
}
PixelFilter::Trimmed PixelFilter::trimmed(const QImage &image, const LayerTransform &placed)
{
const QRectF full(image.rect());
// Bounds read premultiplied bytes; other formats get drawn.
QImage pixels = image;
if (pixels.format() != QImage::Format_RGBA8888_Premultiplied) {
pixels = BrushRaster::context(image.width(), image.height(), false);
QPainter painter(&pixels);
BrushRaster::draw(image, full, painter);
}
size_t edges[4] = {0, 0, 0, 0};
brush_alpha_bounds(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), edges);
const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1]));
if (crop.width() < 1 || crop.height() < 1 || QRectF(crop) == full)
return {image, placed};
LayerTransform result = placed;
result.size = {crop.width() * placed.size.width() / full.width(), crop.height() * placed.size.height() / full.height()};
const QPointF middle = BrushRaster::pixelToDocument(placed, image.width(), image.height()).map(QRectF(crop).center());
result.origin = {middle.x() - result.size.width() / 2, middle.y() - result.size.height() / 2};
// Swift's crop shares pixels; a copy may find no memory.
const QImage cropped = image.copy(crop);
if (cropped.isNull())
throw ExportError(ExportError::Kind::render);
return {cropped, result};
}
namespace {
// The image drawn into a fresh premultiplied context.
QImage drawn(const QImage &image)
{
QImage context = BrushRaster::context(image.width(), image.height(), false);
QPainter painter(&context);
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return context;
}
}
QImage PixelFilter::run(const FilterJob &job)
{
const FilterSettings settings = job.settings.normalized();
const int width = job.image.width(), height = job.image.height();
QImage image;
switch (job.kind) {
case FilterKind::curves: image = settings.curves.apply(job.image); break;
case FilterKind::exposure: image = settings.exposure.apply(job.image); break;
case FilterKind::gradientMap: image = settings.gradientMap.apply(job.image); break;
// Grain sits in layer pixels; the job's seed patterns it.
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
case FilterKind::contentAwareFill: image = ContentFill::run(job); break;
// Unclamped: a blur spreads into the room made for it.
case FilterKind::gaussianBlur: image = PixelAdjust::gaussianBlur(drawn(job.image), settings.radius * job.scale, false); break;
case FilterKind::motionBlur:
image = PixelAdjust::motionBlur(drawn(job.image), settings.distance * job.scale * motionRadiusPerPixel, settings.angle * std::numbers::pi / 180);
break;
case FilterKind::addNoise:
image = drawn(job.image);
noise_add(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0,
settings.monochromatic ? 1 : 0, job.seed);
break;
case FilterKind::lensCorrection: {
// Relative to the image's size, so previews bend alike.
const QImage source = drawn(job.image);
image = BrushRaster::context(width, height, false);
lens_distort(source.constBits(), image.bits(), size_t(width), size_t(height), size_t(source.bytesPerLine()), settings.distortion / 100 * lensStrength);
break;
}
}
if (!job.selection)
return image;
return PixelAdjust::blend(image, job.image, *job.selection, job.mapping, false);
}
namespace {
QRectF integral(const QRectF &rect)
{
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
}
}
FilterEdit::FilterEdit(FilterKind kind, const ImageLayer &layer, std::optional<SelectionClip> selection, const FilterSettings &settings,
std::optional<QRectF> area)
: kind(kind), layerID(layer.id), original(layer.asset ? *layer.asset : throw ProjectError(ProjectError::Kind::invalid)),
transform(layer.transform), selection(std::move(selection)), settings(settings.normalized())
{
prepare(original, transform);
if (area)
grow(integral(mapping.inverted().mapRect(*area)));
growForBlur();
}
double FilterEdit::blurMargin(FilterKind kind, const FilterSettings &settings)
{
switch (kind) {
case FilterKind::gaussianBlur: return settings.radius * 3 + 2;
case FilterKind::motionBlur: return settings.distance / 2 + 2;
default: return 0;
}
}
void FilterEdit::growForBlur()
{
const double margin = blurMargin(kind, settings);
if (!(margin > grownMargin))
return;
const QSize size = original.size();
grow(QRectF(-margin, -margin, size.width() + 2 * margin, size.height() + 2 * margin));
}
// The layer's pixels in a grid covering `extent` (layer pixels).
void FilterEdit::grow(const QRectF &extent)
{
const QSize size = original.size();
const QRectF bounds(0, 0, size.width(), size.height());
const QRectF target = integral(bounds.united(extent));
if (target == bounds)
return;
if (target.width() > 30'000 || target.height() > 30'000 || target.width() * target.height() > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
QImage grown = BrushRaster::context(int(target.width()), int(target.height()), false);
const QRectF inside = bounds.translated(-target.left(), -target.top());
QPainter painter(&grown);
if (original.raster)
original.raster->draw(inside, painter);
else
BrushRaster::draw(original.image(), inside, painter);
painter.end();
const QTransform toDocument = BrushRaster::pixelToDocument(transform, size.width(), size.height());
LayerTransform expanded = transform;
expanded.size = {target.width() * transform.size.width() / bounds.width(), target.height() * transform.size.height() / bounds.height()};
const QPointF middle = toDocument.map(target.center());
expanded.origin = {middle.x() - expanded.size.width() / 2, middle.y() - expanded.size.height() / 2};
grownImage = grown;
grownTransform = expanded;
grownMargin = std::min({bounds.left() - target.left(), bounds.top() - target.top(), target.right() - bounds.right(), target.bottom() - bounds.bottom()});
prepare(ImportedImage(grown, QImage(), QString()), expanded);
}
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
{
const QSize size = source.size();
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise and grain preview whole: enlarged, grain looks coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground;
const double factor = whole ? 1 : std::min(1.0, previewLimit / std::max(size.width(), size.height()));
if (!(factor < 1)) {
previewSource = source.image();
previewScale = 1;
previewMapping = mapping;
return;
}
const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor));
previewSource = BrushRaster::context(width, height, false);
QPainter painter(&previewSource);
const QRectF rect(0, 0, width, height);
if (source.raster)
source.raster->draw(rect, painter);
else
BrushRaster::draw(source.image(), rect, painter);
painter.end();
previewScale = double(width) / size.width();
previewMapping = BrushRaster::pixelToDocument(placed, width, height);
}
std::optional<QImage> FilterEdit::previewImage(QUuid id) const
{
return preview && id == layerID ? preparedPreview : std::nullopt;
}
FilterJob FilterEdit::previewJob() const
{
return FilterJob{kind, previewSource, settings, previewScale, selection, previewMapping, seed};
}
+141
View File
@@ -0,0 +1,141 @@
#pragma once
#include "Document/Curves.h"
#include "Document/ImageAdjustments.h"
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include <QImage>
#include <QRandomGenerator>
#include <QTransform>
#include <QUuid>
#include <array>
#include <cmath>
struct ImageLayer;
// The Filter and Image menus' filters.
enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain };
// The menu's title, Swift's rawValue.
QString rawValue(FilterKind kind);
// Made without settings: its preview is its result.
bool isAutomatic(FilterKind kind);
// The Image menu's colour adjustments, not under Filter.
bool isImageAdjustment(FilterKind kind);
// Swift's allCases, the menus' order.
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
FilterKind::lensCorrection, FilterKind::removeBackground, FilterKind::contentAwareFill,
FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap,
FilterKind::grain};
// Remove Background's mask: the model's alone, or refined.
enum class BackgroundQuality { basic, advanced };
QString rawValue(BackgroundQuality quality);
inline constexpr std::array allBackgroundQualities{BackgroundQuality::basic, BackgroundQuality::advanced};
// Every filter's settings; each filter reads only its own.
struct FilterSettings {
// Gaussian Blur's radius in layer pixels: the blur's sigma.
double radius = 1;
// Motion Blur's angle, counterclockwise from horizontal, as Photoshop's.
double angle = 0;
double distance = 10;
// Add Noise's strength, Photoshop's percentage.
double amount = 10;
bool gaussian = false;
// The same noise on every channel: brightness alone.
bool monochromatic = false;
// Positive straightens barrel distortion, negative pincushion.
double distortion = 0;
CurvesSettings curves{};
ExposureSettings exposure{};
GradientMapSettings gradientMap{};
GrainSettings grain{};
BackgroundQuality backgroundQuality = BackgroundQuality::basic;
// How far, in pixels, the mask is pulled onto edges.
double refineEdges = 12;
// Pushes the mask's grays apart, 0 to 100, clearing haze.
double matteContrast = 25;
// Contracts (negative) or grows the mask's edge, in pixels.
double shiftEdge = 0;
FilterSettings normalized() const;
friend bool operator==(const FilterSettings &, const FilterSettings &) = default;
};
// What a filter runs on: grid, settings, selection, placement.
struct FilterJob {
FilterKind kind;
QImage image;
FilterSettings settings;
// Pixels in `image` a layer pixel: smaller previews blur less.
double scale;
std::optional<SelectionClip> selection;
QTransform mapping;
// Add Noise's pattern: the same seed, the same grain.
quint32 seed = 0;
};
// Swift's PixelFilter.
namespace PixelFilter {
struct Trimmed {
QImage image;
LayerTransform transform;
};
// CIMotionBlur's radius a streak pixel: an even streak's spread.
inline const double motionRadiusPerPixel = 1 / std::sqrt(12.0);
// Remove Distortion at ±100 moves the corners this share.
inline constexpr double lensStrength = 0.35;
// The pixels that are there, placed where they were.
Trimmed trimmed(const QImage &image, const LayerTransform &placed);
// The filter over the job's image, through its selection.
QImage run(const FilterJob &job);
}
// An open filter: the layer's pixels, preview copy, grown grid.
class FilterEdit {
public:
// Previews render from a copy no longer than this.
static constexpr double previewLimit = 2048;
// `area`: a document area the grid must cover (Content-Aware Fill).
FilterEdit(FilterKind kind, const ImageLayer &layer, std::optional<SelectionClip> selection, const FilterSettings &settings,
std::optional<QRectF> area);
// The room a blur needs round the layer, in pixels.
static double blurMargin(FilterKind kind, const FilterSettings &settings);
// Pads the grid for the blur; it only ever grows.
void growForBlur();
std::optional<QImage> previewImage(QUuid id) const;
FilterJob previewJob() const;
// Swift's object identity: a preview lands on its own edit.
const QUuid id = QUuid::createUuid();
const FilterKind kind;
const QUuid layerID;
const ImportedImage original;
const LayerTransform transform;
const std::optional<SelectionClip> selection;
QTransform mapping;
QImage previewSource;
double previewScale = 1;
QTransform previewMapping;
// The layer padded out, and the transform placing it.
std::optional<QImage> grownImage;
std::optional<LayerTransform> grownTransform;
// How far the padding reaches past every side.
double grownMargin = 0;
FilterSettings settings;
bool preview = true;
bool committing = false;
std::optional<QString> previewError;
bool preparing = false;
// Add Noise's grain, fixed while the panel is open.
const quint32 seed = QRandomGenerator::global()->generate();
std::optional<QImage> preparedPreview;
// The settings the prepared preview was made with.
std::optional<FilterSettings> preparedSettings;
std::optional<FilterJob> pending;
private:
void grow(const QRectF &extent);
// Swift's prepared and prepare: mapping and preview copy.
void prepare(const ImportedImage &source, const LayerTransform &placed);
};
+10
View File
@@ -0,0 +1,10 @@
// Swift's FloatingSelection extension: Ctrl+T on selected pixels.
public:
bool canTransformSelection() const;
void transformCommand();
void beginSelectionTransform(std::function<void()> done = {});
std::optional<QTransform> floatingSelectionTransform(const TransformEdit &edit) const;
private:
void mergeFloatingTransform(const TransformEdit &edit, const FloatingTransform &floating);
void cancelFloatingTransform(const FloatingTransform &floating);
+196
View File
@@ -0,0 +1,196 @@
#include "Document/FloatingSelection.h"
#include "Document/BrushStroke.h"
#include "Document/Distort.h"
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <cmath>
// Swift's FloatingSelection extension: one "Transform Selection" step.
namespace {
QRectF integral(const QRectF &rect)
{
return QRectF(QPointF(std::floor(rect.left()), std::floor(rect.top())), QPointF(std::ceil(rect.right()), std::ceil(rect.bottom())));
}
}
bool EditorSession::canTransformSelection() const
{
const std::optional<DocumentSelection> current = selection();
const std::optional<ImageLayer> layer = activeLayer();
// `canEditPixels` refuses an open edit and an empty selection.
return canEditPixels() && !m_isMaskSelected && current && layer && layer->asset;
}
// Ctrl+T: the selected pixels with a selection, else the layer.
void EditorSession::transformCommand()
{
if (canTransformSelection())
beginSelectionTransform();
else
beginTransform();
}
void EditorSession::beginSelectionTransform(std::function<void()> done)
{
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (!canTransformSelection()) {
qCWarning(lcApp) << "no selected pixels to transform";
finish();
return;
}
const ImageLayer source = activeLayer().value();
std::optional<CopiedPixels> lifted;
try {
lifted = renderSelectedPixels(source, false);
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
finish();
return;
}
if (!lifted) {
qCWarning(lcApp) << "no selected pixels to lift";
finish();
return;
}
const CanvasDocument before = m_document.value();
const std::optional<QUuid> beforeActive = m_activeLayerID;
// The outer step: closed by the merge or the restore.
beginEdit(QStringLiteral("Transform Selection"));
clearSelectedPixels([this, source, lifted = *lifted, before, beforeActive, finish] {
const int index = m_document ? indexOf(m_document->layers, source.id) : -1;
std::optional<QImage> thumbnail;
try {
thumbnail = PixelAdjust::thumbnail(lifted.image);
} catch (const ExportError &error) {
qCWarning(lcApp) << "the floating selection could not be made:" << error.what();
}
if (index < 0 || !thumbnail) {
m_document = before;
endEdit();
finish();
return;
}
ImageLayer floating(ImportedImage(lifted.image, *thumbnail, QStringLiteral("Floating Selection")), lifted.region.topLeft());
floating.name = QStringLiteral("Floating Selection");
floating.parentID = source.parentID;
floating.opacity = source.opacity;
floating.blendMode = source.blendMode;
m_document->layers.insert(m_document->layers.begin() + index + 1, floating);
setActiveLayerID(floating.id);
m_tool = NavigationTool::move;
const auto state = std::make_shared<const FloatingTransform>(FloatingTransform{source.id, before, beforeActive, floating.transform, lifted.region.size()});
m_transformEdit = TransformEdit{floating.id, floating.transform, std::nullopt, false, true, std::nullopt, state};
notify();
finish();
});
}
// Maps the original selection to where the pixels float now.
std::optional<QTransform> EditorSession::floatingSelectionTransform(const TransformEdit &edit) const
{
if (!edit.floating)
return std::nullopt;
const int width = int(edit.floating->pixelSize.width()), height = int(edit.floating->pixelSize.height());
return BrushRaster::pixelToDocument(edit.floating->original, width, height).inverted() * BrushRaster::pixelToDocument(edit.draft, width, height);
}
// The pixels back into their layer, the selection moved along.
void EditorSession::mergeFloatingTransform(const TransformEdit &edit, const FloatingTransform &floating)
{
try {
// The open edit holds both layers; checked reads suffice.
const QImage pixels = m_document->layers[indexOf(m_document->layers, edit.layerID)].asset.value().image();
const ImageLayer source = m_document->layers[indexOf(m_document->layers, floating.sourceID)];
// A distorted selection is warped into its shape first.
QImage placedImage = pixels;
LayerTransform placedTransform = edit.draft;
if (edit.corners) {
const DistortWarp::Trimmed warped = DistortWarp::warpTrimmed(pixels, edit.draft, *edit.corners);
placedImage = warped.image;
placedTransform = warped.transform;
}
const FloatingMerge::Merged merged = FloatingMerge::merge(placedImage, placedTransform, source);
const std::optional<DocumentSelection> current = selection();
std::optional<DocumentSelection> moved;
if (current && edit.corners) {
const QTransform placement = BrushRaster::pixelToDocument(floating.original, int(floating.pixelSize.width()), int(floating.pixelSize.height()));
if (const std::optional<QPainterPath> path = DistortWarp::mapPath(current->path, placement, floating.pixelSize, edit.draft, *edit.corners))
moved = DocumentSelection{*path, current->antialiased};
} else if (const std::optional<QTransform> shift = floatingSelectionTransform(edit); current && shift) {
moved = DocumentSelection{shift->map(current->path), current->antialiased};
}
std::erase_if(m_document->layers, [&](const ImageLayer &layer) { return layer.id == edit.layerID; });
ImageLayer &layer = m_document->layers[indexOf(m_document->layers, source.id)];
layer.asset = merged.asset;
layer.transform = merged.transform;
layer.isGroup = false;
layer.mask = merged.mask;
// Swift rebuilds the layer without shape, effects and text.
layer.shape = std::nullopt;
layer.effects = std::nullopt;
layer.text = std::nullopt;
m_document->selection = moved;
setActiveLayerID(source.id);
} catch (const ProjectError &error) {
m_document = floating.before;
setActiveLayerID(floating.beforeActive);
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
m_document = floating.before;
setActiveLayerID(floating.beforeActive);
setBrushError(QString::fromUtf8(error.what()));
}
endEdit();
}
void EditorSession::cancelFloatingTransform(const FloatingTransform &floating)
{
m_document = floating.before;
setActiveLayerID(floating.beforeActive);
endEdit();
}
FloatingMerge::Merged FloatingMerge::merge(const QImage &pixels, const LayerTransform &transform, const ImageLayer &source)
{
if (!source.asset)
throw ProjectError(ProjectError::Kind::invalid);
const QImage sourceImage = source.asset->image();
const int width = sourceImage.width(), height = sourceImage.height();
const QTransform toDocument = BrushRaster::pixelToDocument(source.transform, width, height);
const QTransform toPixels = toDocument.inverted();
const QRectF floatingBounds = toPixels.mapRect(BrushRaster::pixelToDocument(transform, pixels.width(), pixels.height()).mapRect(QRectF(pixels.rect())));
const QRectF original(0, 0, width, height);
const QRectF extent = integral(original.united(floatingBounds));
if (extent.width() > 30'000 || extent.height() > 30'000 || extent.width() * extent.height() > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
QImage context = BrushRaster::context(int(extent.width()), int(extent.height()), false);
const QRectF placed = original.translated(-extent.topLeft());
QPainter painter(&context);
BrushRaster::draw(sourceImage, placed, painter);
painter.setTransform(toPixels * QTransform::fromTranslate(-extent.left(), -extent.top()));
LayerRenderer::draw(pixels, transform, transform.center(), painter, {});
painter.end();
const ImportedImage asset(context, PixelAdjust::thumbnail(context), source.name);
LayerTransform merged = source.transform;
merged.size = {extent.width() * source.size().width() / width, extent.height() * source.size().height() / height};
const QPointF center = toDocument.map(extent.center());
merged.origin = {center.x() - merged.size.width() / 2, center.y() - merged.size.height() / 2};
std::optional<LayerMask> mask = source.mask;
if (source.mask && !source.mask->placement && extent != original) {
// The mask grows with the layer, revealing the new area.
QImage grown = BrushRaster::context(int(extent.width()), int(extent.height()), true);
grown.fill(Qt::white);
QPainter maskPainter(&grown);
BrushRaster::draw(source.mask->asset.image(), placed, maskPainter);
maskPainter.end();
mask = source.mask->replacing(LayerMask::assetFrom(grown));
}
return {asset, merged, mask};
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "Document/EditorSession+Model.h"
#include "Document/LayerTransform.h"
#include <QImage>
// Ctrl+T with a selection: the pixels float, then merge back.
struct FloatingTransform {
QUuid sourceID;
CanvasDocument before;
std::optional<QUuid> beforeActive;
LayerTransform original;
QSizeF pixelSize;
};
// Floating pixels drawn back onto their layer, grown as needed.
namespace FloatingMerge {
struct Merged {
ImportedImage asset;
LayerTransform transform;
std::optional<LayerMask> mask;
};
Merged merge(const QImage &pixels, const LayerTransform &transform, const ImageLayer &source);
}
+14
View File
@@ -0,0 +1,14 @@
// Swift's Gradient extension: a pending gradient, drawn until applied.
public:
void beginGradient(QPointF point);
void moveGradient(std::optional<QPointF> start, std::optional<QPointF> end);
// Redraws the pending gradient from its ends, settings and palette.
void refreshGradient();
std::array<QColor, 2> gradientColors(bool mask) const;
// Ends a drag; a click without a line leaves nothing.
void endGradientDrag();
void cancelGradient();
// Off the UI thread; `done` follows the step or refusal.
void commitGradient(std::function<void()> done = {});
// Switching tools, layers or targets applies it, as Photoshop.
void resolveGradient();
+152
View File
@@ -0,0 +1,152 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include <stdexcept>
QString rawValue(GradientStyle style)
{
switch (style) {
case GradientStyle::foregroundToBackground:
return QStringLiteral("Foreground to Background");
case GradientStyle::foregroundToTransparent:
return QStringLiteral("Foreground to Transparent");
}
throw std::logic_error("unknown gradient style");
}
QString rawValue(GradientShape shape)
{
switch (shape) {
case GradientShape::linear:
return QStringLiteral("Linear");
case GradientShape::radial:
return QStringLiteral("Radial");
}
throw std::logic_error("unknown gradient shape");
}
void EditorSession::setGradientSettings(const GradientSettings &settings)
{
m_gradientSettings = settings;
refreshGradient();
notify();
}
void EditorSession::beginGradient(QPointF point)
{
const std::optional<ImageLayer> layer = activeLayer();
if (m_tool != NavigationTool::gradient || !(canPaint() || m_gradientEdit) || !layer)
return;
// A new line on this target replaces the pending one.
if (m_gradientEdit && m_gradientEdit->raster->layer.id == layer->id && m_gradientEdit->raster->isMask == m_isMaskSelected) {
m_gradientEdit->start = point;
m_gradientEdit->end = point;
refreshGradient();
return;
}
if (!canPaint())
return;
finishOpacityEdit();
try {
m_gradientEdit = GradientEdit{std::shared_ptr<BrushStroke>(makeRasterEdit(*layer)), point, point};
++m_brushRevision;
notify();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
}
void EditorSession::moveGradient(std::optional<QPointF> start, std::optional<QPointF> end)
{
if (!m_gradientEdit)
return;
if (start)
m_gradientEdit->start = *start;
if (end)
m_gradientEdit->end = *end;
refreshGradient();
}
void EditorSession::refreshGradient()
{
if (!m_gradientEdit)
return;
if (m_gradientEdit->hasLine()) {
try {
m_gradientEdit->raster->fillGradient(m_gradientSettings.shape, m_gradientEdit->start, m_gradientEdit->end,
gradientColors(m_gradientEdit->raster->isMask), m_gradientSettings.opacity);
} catch (const ProjectError &error) {
cancelGradient();
setBrushError(QString::fromUtf8(error.what()));
return;
} catch (const ExportError &error) {
cancelGradient();
setBrushError(QString::fromUtf8(error.what()));
return;
}
}
++m_brushRevision;
notify();
}
std::array<QColor, 2> EditorSession::gradientColors(bool mask) const
{
// A mask's colours are gray, the palette's red.
const auto color = [mask](const PaletteColor &value, double alpha) {
return mask ? QColor::fromRgbF(float(value.red), float(value.red), float(value.red), float(alpha)) : value.color(alpha);
};
const PaletteColor foreground = paletteColor(false);
std::array<QColor, 2> colors = m_gradientSettings.style == GradientStyle::foregroundToBackground
? std::array{color(foreground, 1), color(paletteColor(true), 1)}
: std::array{color(foreground, 1), color(foreground, 0)};
if (m_gradientSettings.reversed)
std::swap(colors[0], colors[1]);
return colors;
}
void EditorSession::endGradientDrag()
{
if (m_gradientEdit && !m_gradientEdit->hasLine())
cancelGradient();
}
void EditorSession::cancelGradient()
{
if (!m_gradientEdit)
return;
m_gradientEdit = std::nullopt;
++m_brushRevision;
notify();
}
void EditorSession::commitGradient(std::function<void()> done)
{
if (!m_gradientEdit || m_isProjectBusy) {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
if (!m_gradientEdit->hasLine()) {
cancelGradient();
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
return;
}
const std::shared_ptr<BrushStroke> raster = m_gradientEdit->raster;
commitRasterEdit(raster, raster->isMask ? QStringLiteral("Gradient Mask") : QStringLiteral("Gradient"), {}, [this, raster, done] {
// Swift's `gradientEdit === edit`: the same edit ends here.
if (m_gradientEdit && m_gradientEdit->raster == raster)
cancelGradient();
if (done)
done();
});
}
// Swift's `Task`: the commit starts once the caller returns.
void EditorSession::resolveGradient()
{
if (m_gradientEdit)
QMetaObject::invokeMethod(this, [this] { commitGradient(); }, Qt::QueuedConnection);
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <QPointF>
#include <QString>
#include <array>
#include <cmath>
#include <memory>
class BrushStroke;
enum class GradientStyle { foregroundToBackground, foregroundToTransparent };
inline constexpr std::array allGradientStyles{GradientStyle::foregroundToBackground, GradientStyle::foregroundToTransparent};
QString rawValue(GradientStyle style);
// Linear runs start to end; radial centres on the start.
enum class GradientShape { linear, radial };
inline constexpr std::array allGradientShapes{GradientShape::linear, GradientShape::radial};
QString rawValue(GradientShape shape);
struct GradientSettings {
GradientShape shape = GradientShape::linear;
GradientStyle style = GradientStyle::foregroundToTransparent;
bool reversed = false;
double opacity = 1;
friend bool operator==(const GradientSettings &, const GradientSettings &) = default;
};
// A pending gradient on a layer or mask, document pixels.
struct GradientEdit {
// Shared: the canvas draws it while a commit renders.
std::shared_ptr<BrushStroke> raster;
QPointF start;
QPointF end;
bool hasLine() const { return std::hypot(end.x() - start.x(), end.y() - start.y()) >= 0.5; }
};
+124
View File
@@ -0,0 +1,124 @@
#include "Document/GuidedMatte.h"
#include "IO/ImageExporter.h"
#include <algorithm>
#include <cmath>
#include <new>
namespace {
// Floats; a failed allocation is a render error.
std::vector<float> floats(size_t count)
{
try {
return std::vector<float>(count);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
QImage checked(const QImage &image)
{
if (image.isNull())
throw ExportError(ExportError::Kind::render);
return image;
}
}
std::vector<float> GuidedMatte::box(const std::vector<float> &source, int width, int height, int radius)
{
const float span = float(radius * 2 + 1);
const size_t stride = size_t(width);
std::vector<float> pass = floats(stride * size_t(height));
for (int y = 0; y < height; ++y) {
const float *row = source.data() + size_t(y) * stride;
float sum = 0;
for (int x = -radius; x <= radius; ++x)
sum += row[std::clamp(x, 0, width - 1)];
float *out = pass.data() + size_t(y) * stride;
for (int x = 0; x < width; ++x) {
out[x] = sum / span;
sum -= row[std::clamp(x - radius, 0, width - 1)];
sum += row[std::clamp(x + radius + 1, 0, width - 1)];
}
}
// Each column sums as Swift's, a row at a time.
std::vector<float> result = floats(stride * size_t(height)), sums = floats(stride);
for (int y = -radius; y <= radius; ++y) {
const float *row = pass.data() + size_t(std::clamp(y, 0, height - 1)) * stride;
for (size_t x = 0; x < stride; ++x)
sums[x] += row[x];
}
for (int y = 0; y < height; ++y) {
float *out = result.data() + size_t(y) * stride;
const float *leaving = pass.data() + size_t(std::clamp(y - radius, 0, height - 1)) * stride;
const float *entering = pass.data() + size_t(std::clamp(y + radius + 1, 0, height - 1)) * stride;
for (size_t x = 0; x < stride; ++x) {
out[x] = sums[x] / span;
sums[x] -= leaving[x];
sums[x] += entering[x];
}
}
return result;
}
std::vector<float> GuidedMatte::filter(const std::vector<float> &mask, const std::vector<float> &guide, int width, int height, int radius, float epsilon)
{
const size_t count = size_t(width) * size_t(height);
const std::vector<float> meanGuide = box(guide, width, height, radius), meanMask = box(mask, width, height, radius);
std::vector<float> slope = floats(count), offset = floats(count);
{
std::vector<float> squares = floats(count), products = floats(count);
for (size_t i = 0; i < count; ++i) {
squares[i] = guide[i] * guide[i];
products[i] = guide[i] * mask[i];
}
const std::vector<float> meanSquares = box(squares, width, height, radius), meanProducts = box(products, width, height, radius);
for (size_t i = 0; i < count; ++i) {
const float variance = meanSquares[i] - meanGuide[i] * meanGuide[i];
const float covariance = meanProducts[i] - meanGuide[i] * meanMask[i];
slope[i] = covariance / (variance + epsilon);
offset[i] = meanMask[i] - slope[i] * meanGuide[i];
}
}
const std::vector<float> meanSlope = box(slope, width, height, radius), meanOffset = box(offset, width, height, radius);
std::vector<float> result = floats(count);
for (size_t i = 0; i < count; ++i)
result[i] = std::min(1.0f, std::max(0.0f, meanSlope[i] * guide[i] + meanOffset[i]));
return result;
}
// Premultiplied first: Qt area-filters that, not gray, when shrinking.
std::vector<float> GuidedMatte::levels(const QImage &image, int width, int height)
{
const QImage source = checked(checked(image.convertToFormat(QImage::Format_RGBA8888_Premultiplied))
.scaled(width, height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(QImage::Format_RGBA8888_Premultiplied));
std::vector<float> result = floats(size_t(width) * size_t(height));
for (int y = 0; y < height; ++y) {
const uchar *row = source.constScanLine(y);
for (int x = 0; x < width; ++x)
result[size_t(y) * size_t(width) + size_t(x)] = float(qGray(row[x * 4], row[x * 4 + 1], row[x * 4 + 2])) / 255;
}
return result;
}
QImage GuidedMatte::image(const std::vector<float> &levels, int width, int height)
{
QImage result = checked(QImage(width, height, QImage::Format_Grayscale8));
for (int y = 0; y < height; ++y) {
uchar *row = result.scanLine(y);
for (int x = 0; x < width; ++x)
row[x] = uchar(std::min(255.0f, std::max(0.0f, levels[size_t(y) * size_t(width) + size_t(x)] * 255 + 0.5f)));
}
return result;
}
QImage GuidedMatte::refine(const QImage &mask, const QImage &guide, double radius, double limit)
{
const QSize full = mask.size();
const double factor = std::min(1.0, limit / std::max(full.width(), full.height()));
const int width = std::max(1, int(std::round(full.width() * factor))), height = std::max(1, int(std::round(full.height() * factor)));
const int steps = std::max(1, int(std::round(radius * factor)));
const QImage small = image(filter(levels(mask, width, height), levels(guide, width, height), width, height, steps, 1e-4f), width, height);
// Drawn back up, filtered; Qt returns one already that size.
return checked(small.scaled(full, Qt::IgnoreAspectRatio, Qt::SmoothTransformation).convertToFormat(QImage::Format_Grayscale8));
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <QImage>
#include <vector>
// Swift's GuidedMatte: a mask pulled onto its image's own edges.
namespace GuidedMatte {
// Mean over a (2r+1)² square, two running-sum passes.
std::vector<float> box(const std::vector<float> &source, int width, int height, int radius);
// `mask` refined by `guide`: both 0 to 1, one size.
std::vector<float> filter(const std::vector<float> &mask, const std::vector<float> &guide, int width, int height, int radius, float epsilon);
// `image`'s gray levels at this size, 0 to 1.
std::vector<float> levels(const QImage &image, int width, int height);
// Levels back to a gray image.
QImage image(const std::vector<float> &levels, int width, int height);
// Refined on a copy `limit` long at most, drawn back.
QImage refine(const QImage &mask, const QImage &guide, double radius, double limit);
}
+190
View File
@@ -0,0 +1,190 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "Document/HueSaturation.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include <QPainter>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <numeric>
namespace {
struct HSL {
double hue;
double saturation;
double lightness;
};
HSL toHSL(double red, double green, double blue)
{
const double high = std::max({red, green, blue}), low = std::min({red, green, blue});
const double lightness = (high + low) / 2, delta = high - low;
if (!(delta > 0))
return {0, 0, lightness};
const double saturation = delta / (1 - std::abs(2 * lightness - 1));
double hue = high == red ? (green - blue) / delta : high == green ? (blue - red) / delta + 2 : (red - green) / delta + 4;
hue *= 60;
if (hue < 0)
hue += 360;
return {hue, std::min(1.0, saturation), lightness};
}
HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness)
{
if (!(saturation > 0))
return {lightness, lightness, lightness};
const double chroma = (1 - std::abs(2 * lightness - 1)) * saturation, sector = hue / 60;
const double second = chroma * (1 - std::abs(std::fmod(sector, 2) - 1)), base = lightness - chroma / 2;
HueSaturationFilter::Color color{chroma, 0, second};
switch (int(sector)) {
case 0: color = {chroma, second, 0}; break;
case 1: color = {second, chroma, 0}; break;
case 2: color = {0, chroma, second}; break;
case 3: color = {0, second, chroma}; break;
case 4: color = {second, 0, chroma}; break;
}
const auto clamp = [base](double value) { return std::min(1.0, std::max(0.0, value + base)); };
return {clamp(color.red), clamp(color.green), clamp(color.blue)};
}
// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back.
void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows)
{
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;
}
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));
}
});
}
}
std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(const HueSaturationSettings &settings)
{
std::vector<HueResponse> result(361);
for (int degree = 0; degree <= 360; ++degree) {
HueResponse &response = result[size_t(degree)];
for (const auto &[colorRange, adjustment] : settings.adjustments) {
if (adjustment == RangeAdjustment())
continue;
const double weight = settings.weight(colorRange, degree);
if (!(weight > 0))
continue;
response.shift += adjustment.hue * weight;
response.saturation += adjustment.saturation * weight;
response.lightness += adjustment.lightness * weight;
}
}
return result;
}
std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings)
{
const std::vector<HueResponse> response = hueResponse(settings);
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const Color color = adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
}
}
return values;
}
HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green, double blue, const HueSaturationSettings &settings)
{
return adjust(red, green, blue, settings, hueResponse(settings));
}
HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green, double blue, const HueSaturationSettings &settings,
const std::vector<HueResponse> &response)
{
auto [hue, saturation, lightness] = toHSL(red, green, blue);
double lightnessAmount = 0;
if (settings.colorize) {
hue = std::fmod(settings.hue(), 360);
saturation = std::min(1.0, std::max(0.0, settings.saturation() / 100));
lightnessAmount = settings.lightness() / 100;
} else {
// Every range adds in, weighted by its claim.
const HueResponse &sampled = response[size_t(std::min(response.size() - 1, size_t(std::max(0.0, std::round(hue)))))];
lightnessAmount = sampled.lightness / 100;
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)));
}
// Toward white above zero, black below, reaching either at 100.
const double amount = std::min(1.0, std::max(-1.0, lightnessAmount));
lightness = amount >= 0 ? lightness + (1 - lightness) * amount : lightness * (1 + amount);
return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness)));
}
double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings)
{
double shift = 0;
for (const auto &[colorRange, adjustment] : settings.adjustments) {
if (adjustment.hue != 0)
shift += adjustment.hue * settings.weight(colorRange, hue);
}
const double shifted = std::fmod(hue + shift, 360);
return shifted < 0 ? shifted + 360 : shifted;
}
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);
}
std::vector<float> table;
std::vector<int> rows;
try {
table = cube(job.settings);
rows.resize(size_t(height));
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
std::iota(rows.begin(), rows.end(), 0);
applyCube(result.bits(), result.bytesPerLine(), width, table, rows);
// Swift blends over the image drawn again: the same pixels.
if (job.selection)
result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false);
return {result, job.thumbnail ? std::optional(PixelAdjust::thumbnail(result)) : std::nullopt};
}
+276
View File
@@ -0,0 +1,276 @@
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QtConcurrent>
namespace {
// Swift's adjustedPixels: the render, or why it failed.
SessionJobs::Adjusted adjusted(const HueSaturationJob &job)
{
try {
return {HueSaturationFilter::run(job), std::nullopt};
} catch (const ExportError &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}
}
// Swift's session extension in HueSaturation.swift.
bool EditorSession::canAdjustColors() const
{
const std::optional<ImageLayer> layer = activeLayer();
const std::optional<DocumentSelection> current = selection();
return !m_levels && !m_filterEdit && m_document && layer && !m_isProjectBusy && !m_isImporting && !m_brushStroke && !m_pixelMove && !m_renamingLayerID && !m_showsNewDocument
&& !m_showsImporter && m_selectedLayerIDs.size() == 1 && !layer->isGroup && !m_isMaskSelected && layer->asset
&& m_document->effectiveVisibleIDs().contains(layer->id) && !(current && current->isEmpty());
}
void EditorSession::beginHueSaturation()
{
// Swift beeps.
if (m_hueSaturation || !canAdjustColors()) {
qCWarning(lcApp) << "Hue/Saturation cannot open here";
return;
}
// A pending gradient lands first, as beginLevels waits.
if (m_gradientEdit) {
commitGradient([this] { beginHueSaturation(); });
return;
}
commitTransform();
const ImageLayer layer = activeLayer().value();
const std::optional<DocumentSelection> current = selection();
try {
m_hueSaturation.emplace(layer.id, layer.asset.value(), current ? std::optional(current->clip(m_document->size())) : std::nullopt,
layer.transform);
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
return;
}
notify();
}
void EditorSession::updateHueSaturation(const HueSaturationSettings &settings, bool preview)
{
if (!m_hueSaturation)
return;
HueSaturationEdit &edit = *m_hueSaturation;
edit.settings = settings;
edit.preview = preview;
// An adjustment layer takes the settings; its pixels stay.
if (previewAdjustmentEditing(preview)) {
notify();
return;
}
if (!preview || settings.isIdentity()) {
// Swift cancels the running preview: its result drops.
m_hueSaturationPreviewFor = QUuid();
m_hueSaturationPending.reset();
edit.setPreview(std::nullopt);
++m_brushRevision;
notify();
return;
}
m_hueSaturationPending = HueSaturationJob{edit.previewSource, settings, edit.selection, edit.previewPixelToDocument, false};
renderPendingHuePreview();
notify();
}
// Renders the newest job; one that arrives mid-render waits.
void EditorSession::renderPendingHuePreview()
{
if (!m_hueSaturation || !m_hueSaturationPending || m_hueSaturationRendering)
return;
const HueSaturationJob job = *std::exchange(m_hueSaturationPending, std::nullopt);
m_hueSaturationPreviewFor = m_hueSaturation->id;
m_hueSaturationRendering = true;
m_hueSaturationTask.setFuture(QtConcurrent::run([job] { return adjusted(job); }));
}
void EditorSession::finishHueSaturationPreview()
{
m_hueSaturationRendering = false;
const Adjusted made = m_hueSaturationTask.result();
// Swift's adjustedPixels shows a failure whichever edit asked.
if (made.failure)
setBrushError(made.failure);
if (m_hueSaturation && m_hueSaturation->id == std::exchange(m_hueSaturationPreviewFor, QUuid()) && made.pixels) {
m_hueSaturation->setPreview(made.pixels->image);
++m_brushRevision;
notify();
}
renderPendingHuePreview();
}
void EditorSession::commitHueSaturation(std::function<void()> done)
{
// The caller resumes from the event loop, as after await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (finishAdjustmentEditing(true) || !m_hueSaturation || m_committingHueSaturation) {
finish();
return;
}
m_hueSampleMode.reset();
m_hueTargeting = false;
m_hueTargetDrag.reset();
m_hueSaturationPending.reset();
m_hueSaturationPreviewFor = QUuid();
const HueSaturationEdit &edit = *m_hueSaturation;
if (edit.settings.isIdentity()) {
m_hueSaturation.reset();
++m_brushRevision;
notify();
finish();
return;
}
// The preview stays on screen until the pixels land.
m_committingHueSaturation.emplace(CommittingHue{edit, std::move(done)});
setIsProjectBusy(true);
// A painted layer flattens there, where a failure is caught.
m_hueSaturationCommit.setFuture(QtConcurrent::run([original = edit.original, settings = edit.settings, selection = edit.selection,
mapping = edit.pixelToDocument]() -> Adjusted {
try {
return adjusted(HueSaturationJob{original.image(), settings, selection, mapping, true});
} catch (const ExportError &error) {
return {std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishHueSaturationCommit()
{
const Adjusted made = m_hueSaturationCommit.result();
const CommittingHue committing = std::exchange(m_committingHueSaturation, std::nullopt).value();
const HueSaturationEdit &edit = committing.edit;
const int index = m_document ? indexOf(m_document->layers, edit.layerID) : -1;
if (made.failure)
setBrushError(made.failure);
else if (index >= 0 && m_document->layers[index].asset && m_document->layers[index].asset->identity() == edit.original.identity()) {
beginEdit(QStringLiteral("Hue/Saturation"));
ImageLayer &layer = m_document->layers[index];
layer.asset = ImportedImage(made.pixels->image, made.pixels->thumbnail.value_or(made.pixels->image), layer.name);
// Swift rebuilds the layer without shape, effects and text.
layer.shape = std::nullopt;
layer.effects = std::nullopt;
layer.text = std::nullopt;
endEdit();
}
// Swift's defers: the project frees, then the edit goes.
setIsProjectBusy(false);
m_hueSaturation.reset();
++m_brushRevision;
notify();
if (committing.done)
QMetaObject::invokeMethod(this, committing.done, Qt::QueuedConnection);
}
std::optional<double> EditorSession::sampledHue(QPointF point) const
{
const std::optional<PaletteColor> color = sampleCompositeColor(point);
if (!color)
return std::nullopt;
const PickerHSB hsb(*color);
return hsb.saturation > 0.02 ? std::optional(hsb.hue) : std::nullopt;
}
void EditorSession::sampleHueRange(QPointF point)
{
if (!m_hueSaturation || !m_hueSampleMode)
return;
HueSaturationSettings settings = m_hueSaturation->settings;
const std::optional<double> hue = settings.range != ColorRange::master && !settings.colorize ? sampledHue(point) : std::nullopt;
// Swift beeps: Master, Colorize, or a gray pixel.
if (!hue) {
qCWarning(lcApp) << "no hue to sample here";
return;
}
HueBand band = settings.band();
if (*m_hueSampleMode == HueSampleMode::replace)
band = band.centered(*hue);
else if (*m_hueSampleMode == HueSampleMode::add)
band.include(*hue);
else
band.exclude(*hue);
settings.setBand(band);
updateHueSaturation(settings, m_hueSaturation->preview);
}
bool EditorSession::beginHueTargeting(QPointF point)
{
const std::optional<double> hue = m_hueSaturation && m_hueTargeting && !m_hueSaturation->settings.colorize ? sampledHue(point) : std::nullopt;
// Swift beeps.
if (!hue) {
qCWarning(lcApp) << "no colour to target here";
return false;
}
HueSaturationSettings settings = m_hueSaturation->settings;
// Swift's max(by:): the first of the strongest claims.
ColorRange range = colorRanges.front();
for (const ColorRange each : colorRanges) {
if (settings.weight(each, *hue) > settings.weight(range, *hue))
range = each;
}
settings.range = range;
const RangeAdjustment adjustment = settings.adjustments.contains(range) ? settings.adjustments.at(range) : RangeAdjustment();
m_hueTargetDrag = HueTargetDrag{range, adjustment.hue, adjustment.saturation};
updateHueSaturation(settings, m_hueSaturation->preview);
return true;
}
void EditorSession::dragHueTargeting(double viewDelta, bool adjustsHue)
{
if (!m_hueSaturation || !m_hueTargetDrag)
return;
HueSaturationSettings settings = m_hueSaturation->settings;
const HueTargetDrag drag = *m_hueTargetDrag;
RangeAdjustment &adjustment = settings.adjustments[drag.range];
if (adjustsHue)
adjustment.hue = std::min(180.0, std::max(-180.0, drag.hue + viewDelta / 2));
else
adjustment.saturation = std::min(100.0, std::max(-100.0, drag.saturation + viewDelta / 2));
updateHueSaturation(settings, m_hueSaturation->preview);
}
void EditorSession::endHueTargeting()
{
m_hueTargetDrag.reset();
}
void EditorSession::cancelHueSaturation()
{
if (finishAdjustmentEditing(false))
return;
const bool armed = m_hueSampleMode || m_hueTargeting;
m_hueSampleMode.reset();
m_hueTargeting = false;
m_hueTargetDrag.reset();
if (!m_hueSaturation) {
if (armed)
notify();
return;
}
// A running preview drops: its edit is gone.
m_hueSaturationPending.reset();
m_hueSaturation.reset();
++m_brushRevision;
notify();
}
void EditorSession::setHueSampleMode(std::optional<HueSampleMode> mode)
{
if (m_hueSampleMode == mode)
return;
m_hueSampleMode = mode;
notify();
}
void EditorSession::setHueTargeting(bool targeting)
{
if (m_hueTargeting == targeting)
return;
m_hueTargeting = targeting;
notify();
}
+26
View File
@@ -0,0 +1,26 @@
// Swift's HueSaturation extension.
public:
bool canAdjustColors() const;
void beginHueSaturation();
// Previews coalesce: the newest waits for the running one.
void updateHueSaturation(const HueSaturationSettings &settings, bool preview);
// Off the UI thread; `done` follows the step or refusal.
void commitHueSaturation(std::function<void()> done = {});
// The hue under a document point; near-grays have none.
std::optional<double> sampledHue(QPointF point) const;
// The eyedroppers: re-centre, widen or narrow the band.
void sampleHueRange(QPointF point);
// Targeted adjustment: the range owning the sampled colour.
bool beginHueTargeting(QPointF point);
// A unit every two view points, from the drag's start.
void dragHueTargeting(double viewDelta, bool adjustsHue);
void endHueTargeting();
void cancelHueSaturation();
// The sheet's writes, as Swift's sheet sets them.
void setHueSampleMode(std::optional<HueSampleMode> mode);
void setHueTargeting(bool targeting);
private:
void renderPendingHuePreview();
void finishHueSaturationPreview();
void finishHueSaturationCommit();
+246
View File
@@ -0,0 +1,246 @@
#include "Document/HueSaturation.h"
#include "Document/BrushStroke.h"
#include "Document/LayerTransform.h"
#include "Rendering/RasterSnapshot.h"
#include <QPainter>
#include <cmath>
#include <stdexcept>
QString rawValue(ColorRange range)
{
switch (range) {
case ColorRange::master: return QStringLiteral("Master");
case ColorRange::reds: return QStringLiteral("Reds");
case ColorRange::yellows: return QStringLiteral("Yellows");
case ColorRange::greens: return QStringLiteral("Greens");
case ColorRange::cyans: return QStringLiteral("Cyans");
case ColorRange::blues: return QStringLiteral("Blues");
case ColorRange::magentas: return QStringLiteral("Magentas");
}
throw std::logic_error("no such colour range");
}
HueBand defaultBand(ColorRange range)
{
switch (range) {
case ColorRange::master: return {0, 0, 360, 360};
case ColorRange::reds: return {315, 345, 15, 45};
case ColorRange::yellows: return {15, 45, 75, 105};
case ColorRange::greens: return {75, 105, 135, 165};
case ColorRange::cyans: return {135, 165, 195, 225};
case ColorRange::blues: return {195, 225, 255, 285};
case ColorRange::magentas: return {255, 285, 315, 345};
}
throw std::logic_error("no such colour range");
}
namespace {
// Swift's truncatingRemainder, brought into 0 to 360.
double wrap(double value)
{
const double remainder = std::fmod(value, 360);
return remainder < 0 ? remainder + 360 : remainder;
}
}
double HueBand::forward(double from, double to)
{
const double delta = std::fmod(to - from, 360);
return delta < 0 ? delta + 360 : delta;
}
double HueBand::weight(double hue) const
{
const double span = forward(falloffStart, falloffEnd);
// Master covers everything.
if (!(span > 0))
return 1;
const double position = forward(falloffStart, hue);
if (!(position <= span))
return 0;
const double rampIn = forward(falloffStart, rangeStart), plateauEnd = forward(falloffStart, rangeEnd);
if (position < rampIn)
return rampIn > 0 ? position / rampIn : 1;
if (position <= plateauEnd)
return 1;
const double rampOut = span - plateauEnd;
return rampOut > 0 ? (span - position) / rampOut : 1;
}
std::array<double, 4> HueBand::handles() const
{
return {falloffStart, rangeStart, rangeEnd, falloffEnd};
}
HueBand HueBand::centered(double hue) const
{
const double core = forward(rangeStart, rangeEnd), leading = forward(falloffStart, rangeStart), trailing = forward(rangeEnd, falloffEnd);
const double start = wrap(hue - core / 2);
return {wrap(start - leading), start, wrap(start + core), wrap(start + core + trailing)};
}
void HueBand::include(double hue)
{
if (!(weight(hue) < 1))
return;
const double shoulderIn = forward(falloffStart, rangeStart), shoulderOut = forward(rangeEnd, falloffEnd);
if (forward(hue, rangeStart) <= forward(rangeEnd, hue)) {
rangeStart = hue;
falloffStart = hue - shoulderIn;
} else {
rangeEnd = hue;
falloffEnd = hue + shoulderOut;
}
normalize();
}
void HueBand::exclude(double hue)
{
if (!(weight(hue) > 0))
return;
const double shoulderIn = forward(falloffStart, rangeStart), shoulderOut = forward(rangeEnd, falloffEnd);
if (forward(falloffStart, hue) <= forward(hue, falloffEnd)) {
falloffStart = hue + 1;
rangeStart = hue + 1 + shoulderIn;
} else {
falloffEnd = hue - 1;
rangeEnd = hue - 1 - shoulderOut;
}
normalize();
}
void HueBand::normalize()
{
falloffStart = wrap(falloffStart);
rangeStart = wrap(rangeStart);
rangeEnd = wrap(rangeEnd);
falloffEnd = wrap(falloffEnd);
// Under a full circle.
if (forward(falloffStart, falloffEnd) > 350)
falloffEnd = wrap(falloffStart + 350);
}
void HueBand::setHandle(int index, double degrees)
{
HueBand updated = *this;
const double value = std::fmod(std::fmod(degrees, 360) + 360, 360);
(index == 0 ? updated.falloffStart : index == 1 ? updated.rangeStart : index == 2 ? updated.rangeEnd : updated.falloffEnd) = value;
const double span = forward(updated.falloffStart, updated.falloffEnd), toStart = forward(updated.falloffStart, updated.rangeStart),
toEnd = forward(updated.falloffStart, updated.rangeEnd);
if (span > 1 && span <= 350 && toStart <= toEnd && toEnd <= span)
*this = updated;
}
QString rawValue(HueSampleMode mode)
{
switch (mode) {
case HueSampleMode::replace: return QStringLiteral("Sample");
case HueSampleMode::add: return QStringLiteral("Add");
case HueSampleMode::remove: return QStringLiteral("Remove");
}
throw std::logic_error("no such sample mode");
}
QString help(HueSampleMode mode)
{
switch (mode) {
case HueSampleMode::replace: return QStringLiteral("Click the image to center this range on that color");
case HueSampleMode::add: return QStringLiteral("Click the image to widen this range to include that color");
case HueSampleMode::remove: return QStringLiteral("Click the image to narrow this range to exclude that color");
}
throw std::logic_error("no such sample mode");
}
HueSaturationSettings::HueSaturationSettings(double hue, double saturation, double lightness, bool colorize, ColorRange range)
: range(range), colorize(colorize)
{
for (const ColorRange each : allColorRanges)
bands.emplace(each, defaultBand(each));
adjustments[range] = RangeAdjustment{hue, saturation, lightness};
}
double HueSaturationSettings::hue() const
{
return adjustments.contains(range) ? adjustments.at(range).hue : 0;
}
void HueSaturationSettings::setHue(double value)
{
adjustments[range].hue = value;
}
double HueSaturationSettings::saturation() const
{
return adjustments.contains(range) ? adjustments.at(range).saturation : 0;
}
void HueSaturationSettings::setSaturation(double value)
{
adjustments[range].saturation = value;
}
double HueSaturationSettings::lightness() const
{
return adjustments.contains(range) ? adjustments.at(range).lightness : 0;
}
void HueSaturationSettings::setLightness(double value)
{
adjustments[range].lightness = value;
}
HueBand HueSaturationSettings::band() const
{
return bands.contains(range) ? bands.at(range) : defaultBand(range);
}
void HueSaturationSettings::setBand(const HueBand &value)
{
bands[range] = value;
}
HueSaturationSettings HueSaturationSettings::colorizeStart()
{
return HueSaturationSettings(0, 25, 0, true);
}
bool HueSaturationSettings::isIdentity() const
{
return !colorize && std::all_of(adjustments.begin(), adjustments.end(), [](const auto &entry) { return entry.second == RangeAdjustment(); });
}
double HueSaturationSettings::weight(ColorRange colorRange, double hue) const
{
if (colorRange == ColorRange::master)
return 1;
const double inBand = (bands.contains(colorRange) ? bands.at(colorRange) : defaultBand(colorRange)).weight(hue);
return invertRange && colorRange == range ? 1 - inBand : inBand;
}
HueSaturationEdit::HueSaturationEdit(QUuid layerID, const ImportedImage &original, std::optional<SelectionClip> selection,
const LayerTransform &transform)
: layerID(layerID), original(original), selection(std::move(selection)),
pixelToDocument(BrushRaster::pixelToDocument(transform, original.size().width(), original.size().height()))
{
const QSize size = original.size();
const double factor = std::min(1.0, double(previewLimit) / std::max(size.width(), size.height()));
if (factor < 1) {
const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor));
previewSource = BrushRaster::context(width, height, false);
QPainter painter(&previewSource);
const QRectF rect(0, 0, width, height);
if (original.raster)
original.raster->draw(rect, painter);
else
BrushRaster::draw(original.image(), rect, painter);
previewPixelToDocument = BrushRaster::pixelToDocument(transform, width, height);
} else {
previewSource = original.image();
previewPixelToDocument = pixelToDocument;
}
}
std::optional<QImage> HueSaturationEdit::previewImage(QUuid layer) const
{
return layer == layerID ? m_preparedPreview : std::nullopt;
}
+160
View File
@@ -0,0 +1,160 @@
#pragma once
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include <QImage>
#include <QTransform>
#include <QUuid>
#include <array>
#include <map>
#include <optional>
#include <vector>
struct LayerTransform;
// The six colour ranges and Master, as Photoshop's Cmd+U.
enum class ColorRange { master, reds, yellows, greens, cyans, blues, magentas };
inline constexpr std::array<ColorRange, 7> allColorRanges{ColorRange::master, ColorRange::reds, ColorRange::yellows, ColorRange::greens,
ColorRange::cyans, ColorRange::blues, ColorRange::magentas};
// Swift's colorRanges: every range but Master.
inline constexpr std::array<ColorRange, 6> colorRanges{ColorRange::reds, ColorRange::yellows, ColorRange::greens,
ColorRange::cyans, ColorRange::blues, ColorRange::magentas};
QString rawValue(ColorRange range);
// A hue band in degrees, wrapping at 360.
struct HueBand {
double falloffStart;
double rangeStart;
double rangeEnd;
double falloffEnd;
// Degrees forward from one hue to another, 0 to 360.
static double forward(double from, double to);
// Full inside the range, ramping through each shoulder.
double weight(double hue) const;
std::array<double, 4> handles() const;
// Centred on a hue, keeping the core and shoulders.
HueBand centered(double hue) const;
// Widens to hold the hue, moving the nearer edge.
void include(double hue);
// Narrows to leave the hue out, shoulder included.
void exclude(double hue);
// Moves a handle unless the four would cross.
void setHandle(int index, double degrees);
friend bool operator==(const HueBand &, const HueBand &) = default;
private:
void normalize();
};
// Photoshop's starting band for a range.
HueBand defaultBand(ColorRange range);
// Which eyedropper the panel arms.
enum class HueSampleMode { replace, add, remove };
inline constexpr std::array<HueSampleMode, 3> allHueSampleModes{HueSampleMode::replace, HueSampleMode::add, HueSampleMode::remove};
QString rawValue(HueSampleMode mode);
QString help(HueSampleMode mode);
// A targeted-adjustment drag: its range and starting values.
struct HueTargetDrag {
ColorRange range;
double hue;
double saturation;
};
struct RangeAdjustment {
double hue = 0;
double saturation = 0;
double lightness = 0;
friend bool operator==(const RangeAdjustment &, const RangeAdjustment &) = default;
};
// Each range keeps its own values; Master applies everywhere.
struct HueSaturationSettings {
explicit HueSaturationSettings(double hue = 0, double saturation = 0, double lightness = 0, bool colorize = false,
ColorRange range = ColorRange::master);
ColorRange range;
bool colorize;
// Applies the range outside its band instead.
bool invertRange = false;
std::map<ColorRange, RangeAdjustment> adjustments;
std::map<ColorRange, HueBand> bands;
// Swift's computed properties: the selected range's values.
double hue() const;
void setHue(double value);
double saturation() const;
void setSaturation(double value);
double lightness() const;
void setLightness(double value);
HueBand band() const;
void setBand(const HueBand &value);
// Photoshop's start when Colorize is switched on.
static HueSaturationSettings colorizeStart();
bool isIdentity() const;
// Master everywhere; the others through their bands.
double weight(ColorRange colorRange, double hue) const;
friend bool operator==(const HueSaturationSettings &, const HueSaturationSettings &) = default;
};
struct HueSaturationJob {
QImage image;
HueSaturationSettings settings;
std::optional<SelectionClip> selection;
QTransform pixelToDocument;
// Previews skip the panel's thumbnail.
bool thumbnail = true;
};
struct AdjustedPixels {
QImage image;
std::optional<QImage> thumbnail;
};
// Swift's HueSaturationFilter: a colour cube built from the settings.
namespace HueSaturationFilter {
inline constexpr int dimension = 33;
// Swift's tuple: how every range moves one hue.
struct HueResponse {
double shift = 0;
double saturation = 0;
double lightness = 0;
};
struct Color {
double red;
double green;
double blue;
};
// Throws ExportError when out of memory.
AdjustedPixels run(const HueSaturationJob &job);
// Once a degree, 0 to 360.
std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings);
// RGBA floats, red fastest, then green, then blue.
std::vector<float> cube(const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response);
// The hue a spectrum swatch becomes.
double shiftedHue(double hue, const HueSaturationSettings &settings);
}
// One open Hue/Saturation dialog over a layer's pixels.
class HueSaturationEdit {
public:
// Past 8000 pixels a side the preview copy is smaller.
static constexpr int previewLimit = 8000;
HueSaturationEdit(QUuid layerID, const ImportedImage &original, std::optional<SelectionClip> selection, const LayerTransform &transform);
std::optional<QImage> previewImage(QUuid layer) const;
void setPreview(std::optional<QImage> image) { m_preparedPreview = std::move(image); }
// Swift's object identity: results land on their own edit.
const QUuid id = QUuid::createUuid();
const QUuid layerID;
const ImportedImage original;
const std::optional<SelectionClip> selection;
const QTransform pixelToDocument;
QImage previewSource;
QTransform previewPixelToDocument;
HueSaturationSettings settings;
bool preview = true;
private:
std::optional<QImage> m_preparedPreview;
};
+133
View File
@@ -0,0 +1,133 @@
#include "Document/ImageAdjustments.h"
#include "Document/BrushStroke.h"
#include "IO/ProjectStore.h"
#include <QPainter>
#include <array>
#include <cmath>
extern "C" {
#include "AdjustPixels.h"
#include "LevelsPixels.h"
}
QImage ImageAdjustmentPixels::run(const QImage &image, const std::function<void(uchar *, int, int, qsizetype)> &body)
{
QImage context = BrushRaster::context(image.width(), image.height(), false);
{
QPainter painter(&context);
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
}
body(context.bits(), context.width(), context.height(), context.bytesPerLine());
return context;
}
double ImageAdjustmentPixels::clamp(double value, double low, double high, double fallback)
{
return std::isfinite(value) ? std::min(high, std::max(low, value)) : fallback;
}
bool AdjustmentColor::isValid() const
{
return red >= 0 && red <= 1 && green >= 0 && green <= 1 && blue >= 0 && blue <= 1;
}
AdjustmentColor AdjustmentColor::clamped() const
{
using ImageAdjustmentPixels::clamp;
return {clamp(red, 0, 1, 0), clamp(green, 0, 1, 0), clamp(blue, 0, 1, 0)};
}
bool ExposureSettings::isValid() const
{
return exposure >= exposureLow && exposure <= exposureHigh && offset >= offsetLow && offset <= offsetHigh && gamma >= gammaLow
&& gamma <= gammaHigh;
}
ExposureSettings ExposureSettings::normalized() const
{
using ImageAdjustmentPixels::clamp;
return {clamp(exposure, exposureLow, exposureHigh, 0), clamp(offset, offsetLow, offsetHigh, 0), clamp(gamma, gammaLow, gammaHigh, 1)};
}
std::array<float, 256> ExposureSettings::table() const
{
const double scale = std::pow(2, exposure);
std::array<float, 256> result;
for (size_t index = 0; index < 256; ++index) {
const double encoded = double(index) / 255;
double linear = encoded <= 0.04045 ? encoded / 12.92 : std::pow((encoded + 0.055) / 1.055, 2.4);
linear = std::pow(std::max(0.0, linear * scale + offset), 1 / gamma);
const double output = linear <= 0.0031308 ? linear * 12.92 : 1.055 * std::pow(linear, 1 / 2.4) - 0.055;
result[index] = float(std::min(1.0, std::max(0.0, output)));
}
return result;
}
QImage ExposureSettings::apply(const QImage &image) const
{
if (!isValid())
throw ProjectError(ProjectError::Kind::invalid);
const std::array<float, 256> curve = table();
std::array<float, 3 * 256> tables;
for (size_t channel = 0; channel < 3; ++channel)
std::copy(curve.begin(), curve.end(), tables.begin() + qsizetype(channel * 256));
return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype) {
levels_apply(pixels, size_t(width) * size_t(height), tables.data());
});
}
bool GradientMapSettings::isValid() const
{
return shadows.isValid() && highlights.isValid();
}
GradientMapSettings GradientMapSettings::normalized() const
{
return {shadows.clamped(), highlights.clamped(), reversed};
}
GradientMapSettings::Ends GradientMapSettings::ends() const
{
return reversed ? Ends{highlights, shadows} : Ends{shadows, highlights};
}
QImage GradientMapSettings::apply(const QImage &image) const
{
if (!isValid())
throw ProjectError(ProjectError::Kind::invalid);
const auto [dark, light] = ends();
std::array<uint8_t, 3 * 256> table;
const auto byte = [](double value) { return uint8_t(std::min(255.0, std::max(0.0, std::round(value * 255)))); };
for (size_t index = 0; index < 256; ++index) {
const double t = double(index) / 255;
table[index * 3] = byte(dark.red + (light.red - dark.red) * t);
table[index * 3 + 1] = byte(dark.green + (light.green - dark.green) * t);
table[index * 3 + 2] = byte(dark.blue + (light.blue - dark.blue) * t);
}
return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
adjust_gradient_map(pixels, size_t(width), size_t(height), size_t(stride), table.data());
});
}
bool GrainSettings::isValid() const
{
return amount >= amountLow && amount <= amountHigh && size >= sizeLow && size <= sizeHigh && roughness >= roughnessLow
&& roughness <= roughnessHigh;
}
GrainSettings GrainSettings::normalized() const
{
using ImageAdjustmentPixels::clamp;
return {clamp(amount, amountLow, amountHigh, 25), clamp(size, sizeLow, sizeHigh, 1.5), clamp(roughness, roughnessLow, roughnessHigh, 50), seed};
}
QImage GrainSettings::apply(const QImage &image, QPointF origin, double unitsPerPixel, std::optional<quint32> seed) const
{
if (!isValid() || !std::isfinite(unitsPerPixel) || !(unitsPerPixel > 0))
throw ProjectError(ProjectError::Kind::invalid);
if (!(amount > 0))
return image;
const quint32 pattern = seed.value_or(this->seed);
return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
adjust_grain(pixels, size_t(width), size_t(height), size_t(stride), amount, size, roughness, pattern, origin.x(), origin.y(), unitsPerPixel);
});
}
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "Document/ColorPalette.h"
#include <QImage>
#include <QPointF>
#include <array>
#include <functional>
#include <optional>
// Swift's ImageAdjustmentPixels: a kernel over premultiplied RGBA.
namespace ImageAdjustmentPixels {
// The body gets pixels, width, height and bytes a row.
QImage run(const QImage &image, const std::function<void(uchar *, int, int, qsizetype)> &body);
// Within range; a value that is no number falls back.
double clamp(double value, double low, double high, double fallback);
}
// A straight sRGB colour an adjustment stores, 0 to 1.
struct AdjustmentColor {
AdjustmentColor(double red, double green, double blue) : red(red), green(green), blue(blue) {}
explicit AdjustmentColor(const PaletteColor &color) : AdjustmentColor(color.red, color.green, color.blue) {}
double red;
double green;
double blue;
bool isValid() const;
AdjustmentColor clamped() const;
friend bool operator==(const AdjustmentColor &, const AdjustmentColor &) = default;
};
// Photoshop's Exposure: stops and offset in linear light, then gamma.
struct ExposureSettings {
static constexpr double exposureLow = -20, exposureHigh = 20;
static constexpr double offsetLow = -0.5, offsetHigh = 0.5;
static constexpr double gammaLow = 0.01, gammaHigh = 9.99;
double exposure = 0;
double offset = 0;
double gamma = 1;
bool isValid() const;
ExposureSettings normalized() const;
// Each input byte's output, 0 to 1, through linear light.
std::array<float, 256> table() const;
QImage apply(const QImage &image) const;
friend bool operator==(const ExposureSettings &, const ExposureSettings &) = default;
};
// Gradient Map: brightness picks a colour between the two ends.
struct GradientMapSettings {
AdjustmentColor shadows{0, 0, 0};
AdjustmentColor highlights{1, 1, 1};
bool reversed = false;
bool isValid() const;
GradientMapSettings normalized() const;
// Swift's ends: the darkest tone's colour, then the lightest's.
struct Ends {
AdjustmentColor dark;
AdjustmentColor light;
};
Ends ends() const;
QImage apply(const QImage &image) const;
friend bool operator==(const GradientMapSettings &, const GradientMapSettings &) = default;
};
// Film grain, fixed in document space by its seed.
struct GrainSettings {
static constexpr double amountLow = 0, amountHigh = 100;
static constexpr double sizeLow = 0.5, sizeHigh = 20;
static constexpr double roughnessLow = 0, roughnessHigh = 100;
double amount = 25;
double size = 1.5;
double roughness = 50;
quint32 seed = 0;
bool isValid() const;
GrainSettings normalized() const;
// Pixels placed in the document; a given seed wins.
QImage apply(const QImage &image, QPointF origin = QPointF(), double unitsPerPixel = 1, std::optional<quint32> seed = std::nullopt) const;
friend bool operator==(const GrainSettings &, const GrainSettings &) = default;
};
+6
View File
@@ -0,0 +1,6 @@
// Swift's LayerAdjustment extension: adding and updating adjustments.
public:
// Above the active layer, one step; then its editor opens.
void addAdjustment(AdjustmentKind kind);
// Valid settings alone, with no step of their own.
void updateAdjustment(QUuid id, const LayerAdjustment &value);
+157
View File
@@ -0,0 +1,157 @@
#include "Document/LayerAdjustment.h"
#include "Document/EditorSession.h"
#include <QRandomGenerator>
#include <cmath>
QString rawValue(AdjustmentKind kind)
{
switch (kind) {
case AdjustmentKind::hsv: return QStringLiteral("Hue/Saturation");
case AdjustmentKind::levels: return QStringLiteral("Levels");
case AdjustmentKind::curves: return QStringLiteral("Curves");
case AdjustmentKind::exposure: return QStringLiteral("Exposure");
case AdjustmentKind::gradientMap: return QStringLiteral("Gradient Map");
case AdjustmentKind::grain: return QStringLiteral("Grain");
}
throw std::logic_error("unknown adjustment kind");
}
std::optional<AdjustmentKind> adjustmentKind(const QString &text)
{
for (const AdjustmentKind kind : allAdjustmentKinds) {
if (rawValue(kind) == text)
return kind;
}
return std::nullopt;
}
std::optional<FilterKind> filterKind(AdjustmentKind kind)
{
switch (kind) {
case AdjustmentKind::curves: return FilterKind::curves;
case AdjustmentKind::exposure: return FilterKind::exposure;
case AdjustmentKind::gradientMap: return FilterKind::gradientMap;
case AdjustmentKind::grain: return FilterKind::grain;
case AdjustmentKind::hsv:
case AdjustmentKind::levels: return std::nullopt;
}
throw std::logic_error("unknown adjustment kind");
}
HueSaturationSettings LayerAdjustment::resolvedHSV() const
{
return hsvSettings.value_or(HueSaturationSettings(hue, saturation, lightness, colorize));
}
ExposureSettings LayerAdjustment::exposure() const
{
return exposureSettings.value_or(ExposureSettings());
}
void LayerAdjustment::setExposure(const ExposureSettings &value)
{
exposureSettings = value;
}
GradientMapSettings LayerAdjustment::gradientMap() const
{
return gradientMapSettings.value_or(GradientMapSettings());
}
void LayerAdjustment::setGradientMap(const GradientMapSettings &value)
{
gradientMapSettings = value;
}
GrainSettings LayerAdjustment::grain() const
{
return grainSettings.value_or(GrainSettings());
}
void LayerAdjustment::setGrain(const GrainSettings &value)
{
grainSettings = value;
}
// Bounds refuse what is no number: no isFinite terms.
bool LayerAdjustment::isValid() const
{
const auto within = [](const RangeAdjustment &values) {
return std::abs(values.hue) <= 360 && std::abs(values.saturation) <= 100 && std::abs(values.lightness) <= 100;
};
if (!within(RangeAdjustment{hue, saturation, lightness}))
return false;
const HueSaturationSettings hsv = resolvedHSV();
for (const auto &[range, values] : hsv.adjustments) {
if (!within(values))
return false;
}
for (const auto &[range, band] : hsv.bands) {
for (const double handle : band.handles()) {
if (!std::isfinite(handle))
return false;
}
}
for (const LevelRange &range : levels.ranges) {
if (!(range == range.normalized()))
return false;
}
return curves.isValid() && exposure().isValid() && gradientMap().isValid() && grain().isValid();
}
QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region) const
{
switch (kind) {
case AdjustmentKind::hsv:
return HueSaturationFilter::run(HueSaturationJob{image, resolvedHSV(), std::nullopt, QTransform(), false}).image;
case AdjustmentKind::levels: return LevelsFilter::run(LevelsJob{image, levels, std::nullopt, QTransform()});
case AdjustmentKind::curves: return curves.apply(image);
case AdjustmentKind::exposure: return exposure().apply(image);
case AdjustmentKind::gradientMap: return gradientMap().apply(image);
case AdjustmentKind::grain: {
// Grain sits in the document, however the image is cut.
const QRectF area = region.value_or(QRectF(0, 0, image.width(), image.height()));
return grain().apply(image, area.topLeft(), area.width() / std::max(1, image.width()));
}
}
throw std::logic_error("unknown adjustment kind");
}
void EditorSession::addAdjustment(AdjustmentKind kind)
{
if (!canEditLayers() || m_document->layers.size() >= 10'000)
return;
ImageLayer layer(rawValue(kind), m_document->size());
LayerAdjustment adjustment{kind};
// A Gradient Map runs from foreground to background, as Photoshop's.
if (kind == AdjustmentKind::gradientMap)
adjustment.setGradientMap(GradientMapSettings{AdjustmentColor(foregroundColor()), AdjustmentColor(m_backgroundColor)});
// Each Grain layer rolls a pattern of its own.
if (kind == AdjustmentKind::grain) {
GrainSettings grain = adjustment.grain();
grain.seed = QRandomGenerator::global()->generate();
adjustment.setGrain(grain);
}
layer.adjustment = adjustment;
const std::optional<ImageLayer> active = activeLayer();
layer.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt;
const int activeIndex = indexOf(m_document->layers, m_activeLayerID);
const int index = activeIndex >= 0 ? activeIndex + 1 : int(m_document->layers.size());
beginEdit(QStringLiteral("New %1 Adjustment").arg(rawValue(kind)));
m_document->layers.insert(m_document->layers.begin() + index, layer);
if (layer.parentID)
m_collapsedGroupIDs.remove(*layer.parentID);
setActiveLayerID(layer.id);
endEdit();
setAdjustmentEditingID(layer.id);
}
void EditorSession::updateAdjustment(QUuid id, const LayerAdjustment &value)
{
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (index < 0 || !value.isValid())
return;
m_document->layers[size_t(index)].adjustment = value;
++m_brushRevision;
notify();
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include "Document/Curves.h"
#include "Document/Filters.h"
#include "Document/HueSaturation.h"
#include "Document/ImageAdjustments.h"
#include "Document/Levels.h"
#include <QRectF>
#include <QString>
#include <array>
#include <optional>
// An adjustment layer's kind, in the Layer menu's order.
enum class AdjustmentKind { hsv, levels, curves, exposure, gradientMap, grain };
inline constexpr std::array allAdjustmentKinds{AdjustmentKind::hsv, AdjustmentKind::levels, AdjustmentKind::curves,
AdjustmentKind::exposure, AdjustmentKind::gradientMap, AdjustmentKind::grain};
// Swift's rawValue: the new layer's name and the manifest's word.
QString rawValue(AdjustmentKind kind);
// The kind a manifest names, or none.
std::optional<AdjustmentKind> adjustmentKind(const QString &text);
// The filter panel that edits it; Levels and Hue/Saturation: none.
std::optional<FilterKind> filterKind(AdjustmentKind kind);
// An adjustment layer's settings; its kind reads only its own.
struct LayerAdjustment {
AdjustmentKind kind;
double hue = 0;
double saturation = 0;
double lightness = 0;
bool colorize = false;
// Absent in projects from before range-aware Hue/Saturation.
std::optional<HueSaturationSettings> hsvSettings = std::nullopt;
LevelsSettings levels{};
CurvesSettings curves{};
// Absent unless set, so older kinds save exactly as before.
std::optional<ExposureSettings> exposureSettings = std::nullopt;
std::optional<GradientMapSettings> gradientMapSettings = std::nullopt;
std::optional<GrainSettings> grainSettings = std::nullopt;
HueSaturationSettings resolvedHSV() const;
// Swift's computed properties over the optional settings.
ExposureSettings exposure() const;
void setExposure(const ExposureSettings &value);
GradientMapSettings gradientMap() const;
void setGradientMap(const GradientMapSettings &value);
GrainSettings grain() const;
void setGrain(const GrainSettings &value);
bool isValid() const;
// `region`: the document `image` covers, whole pixels by default.
QImage apply(const QImage &image, std::optional<QRectF> region = std::nullopt) const;
friend bool operator==(const LayerAdjustment &, const LayerAdjustment &) = default;
};
+12
View File
@@ -0,0 +1,12 @@
// Swift's LayerAppearance extension: opacity and blend modes.
public:
LayerBlendMode displayedBlendMode(const ImageLayer &layer) const;
void previewBlendMode(std::optional<LayerBlendMode> mode, std::optional<QUuid> id);
bool canEditAppearance() const;
// A slider drag: many values, one undo step.
void beginOpacityEdit();
void finishOpacityEdit();
void setLayerOpacity(double opacity);
void setSelectedLayersOpacity(double opacity);
void cycleBlendMode(bool forward);
void setLayerBlendMode(LayerBlendMode mode);
+130
View File
@@ -0,0 +1,130 @@
#include "Document/LayerAppearance.h"
#include "Document/EditorSession.h"
#include <algorithm>
#include <cmath>
#include <array>
#include <utility>
namespace {
const std::array<std::pair<LayerBlendMode, const char *>, 13> names{{
{LayerBlendMode::normal, "Normal"}, {LayerBlendMode::multiply, "Multiply"}, {LayerBlendMode::screen, "Screen"},
{LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::darken, "Darken"}, {LayerBlendMode::lighten, "Lighten"},
{LayerBlendMode::difference, "Difference"}, {LayerBlendMode::colorDodge, "Color Dodge"},
{LayerBlendMode::colorBurn, "Color Burn"}, {LayerBlendMode::hue, "Hue"}, {LayerBlendMode::saturation, "Saturation"},
{LayerBlendMode::color, "Color"}, {LayerBlendMode::luminosity, "Luminosity"},
}};
}
QString rawValue(LayerBlendMode mode)
{
return QString::fromLatin1(names.at(size_t(mode)).second);
}
std::optional<LayerBlendMode> layerBlendMode(const QString &rawValue)
{
for (const auto &[mode, name] : names) {
if (rawValue == QLatin1String(name))
return mode;
}
return std::nullopt;
}
LayerBlendMode EditorSession::displayedBlendMode(const ImageLayer &layer) const
{
if (m_blendPreview && m_blendPreview->layerID == layer.id && m_activeLayerID == layer.id)
return m_blendPreview->mode;
return layer.blendMode;
}
void EditorSession::previewBlendMode(std::optional<LayerBlendMode> mode, std::optional<QUuid> id)
{
std::optional<BlendPreview> preview;
if (mode && id && id == m_activeLayerID && canEditAppearance())
preview = BlendPreview{*id, *mode};
// Hovering over the same entry again says nothing.
if (preview == m_blendPreview)
return;
m_blendPreview = preview;
notify();
}
bool EditorSession::canEditAppearance() const
{
const std::optional<ImageLayer> active = activeLayer();
return canEditLayers() && m_selectedLayerIDs.size() == 1 && active && !active->isGroup;
}
void EditorSession::beginOpacityEdit()
{
if (!canEditAppearance() || m_opacityEditLayerID)
return;
beginEdit(QStringLiteral("Layer Opacity"));
m_opacityEditLayerID = m_activeLayerID;
}
void EditorSession::finishOpacityEdit()
{
if (!m_opacityEditLayerID)
return;
m_opacityEditLayerID = std::nullopt;
endEdit();
}
void EditorSession::setLayerOpacity(double opacity)
{
if (!std::isfinite(opacity) || !canEditAppearance())
return;
const QUuid id = m_opacityEditLayerID.value_or(*m_activeLayerID);
const auto layer = std::find_if(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &each) { return each.id == id; });
if (layer == m_document->layers.end())
return;
// Inside a drag this nests in the drag's one step.
beginEdit(QStringLiteral("Layer Opacity"));
layer->opacity = std::clamp(opacity, 0.0, 1.0);
endEdit();
}
void EditorSession::setSelectedLayersOpacity(double opacity)
{
if (!std::isfinite(opacity) || !canEditLayers())
return;
// Folders have no opacity of their own.
const double value = std::clamp(opacity, 0.0, 1.0);
const auto changes = [&](const ImageLayer &layer) { return m_selectedLayerIDs.contains(layer.id) && !layer.isGroup && layer.opacity != value; };
if (std::none_of(m_document->layers.begin(), m_document->layers.end(), changes))
return;
finishOpacityEdit();
beginEdit(QStringLiteral("Layer Opacity"));
for (ImageLayer &layer : m_document->layers) {
if (changes(layer))
layer.opacity = value;
}
endEdit();
}
void EditorSession::cycleBlendMode(bool forward)
{
const std::optional<ImageLayer> layer = activeLayer();
if (!canEditAppearance() || !layer)
return;
const auto found = std::find(allLayerBlendModes.begin(), allLayerBlendModes.end(), layer->blendMode);
const size_t count = allLayerBlendModes.size(), index = size_t(found - allLayerBlendModes.begin());
setLayerBlendMode(allLayerBlendModes[(index + (forward ? 1 : count - 1)) % count]);
}
void EditorSession::setLayerBlendMode(LayerBlendMode mode)
{
if (m_blendPreview) {
m_blendPreview = std::nullopt;
notify();
}
if (!canEditAppearance())
return;
finishOpacityEdit();
beginEdit(QStringLiteral("Layer Blend Mode"));
for (ImageLayer &layer : m_document->layers) {
if (layer.id == m_activeLayerID)
layer.blendMode = mode;
}
endEdit();
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <QString>
#include <array>
#include <optional>
enum class LayerBlendMode {
normal, multiply, screen, overlay, darken, lighten, difference,
colorDodge, colorBurn, hue, saturation, color, luminosity
};
// Swift's `allCases`: the order of the blend menu.
inline constexpr std::array allLayerBlendModes{
LayerBlendMode::normal, LayerBlendMode::multiply, LayerBlendMode::screen, LayerBlendMode::overlay,
LayerBlendMode::darken, LayerBlendMode::lighten, LayerBlendMode::difference, LayerBlendMode::colorDodge,
LayerBlendMode::colorBurn, LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color,
LayerBlendMode::luminosity};
// The name users see and the manifest stores.
QString rawValue(LayerBlendMode mode);
std::optional<LayerBlendMode> layerBlendMode(const QString &rawValue);
+126
View File
@@ -0,0 +1,126 @@
#include "Document/LayerEffects+Renderer.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerEffectsKernel.h"
#include <QMutex>
#include <QPainter>
#include <cmath>
#include <vector>
// Swift's LayerEffectsRenderer, on the kernel alone.
namespace {
struct Entry {
QImage image;
std::optional<QImage> mask;
LayerEffects effects;
LayerEffectsRenderer::Rendered result;
};
qint64 bytes(const Entry &entry)
{
return entry.result.image.sizeInBytes() + entry.image.sizeInBytes() + (entry.mask ? entry.mask->sizeInBytes() : 0);
}
// Swift's `===`: the same pixels, not equal ones.
bool same(const std::optional<QImage> &lhs, const std::optional<QImage> &rhs)
{
return lhs.has_value() == rhs.has_value() && (!lhs || lhs->cacheKey() == rhs->cacheKey());
}
QMutex lock;
std::vector<Entry> entries;
// The layer's pixels through its mask, as they show.
QImage masked(const QImage &image, const std::optional<QImage> &mask)
{
if (!mask)
return image;
const QRectF bounds(0, 0, image.width(), image.height());
QImage result = BrushRaster::context(image.width(), image.height(), false);
QPainter painter(&result);
BrushRaster::draw(image, bounds, painter);
// CoreGraphics scales a clip mask smoothly to its bounds.
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.setCompositionMode(QPainter::CompositionMode_DestinationIn);
painter.drawImage(bounds, BrushRaster::alphaView(*mask));
return result;
}
}
std::optional<LayerEffectsRenderer::Rendered> LayerEffectsRenderer::cached(const QImage &image, const std::optional<QImage> &mask,
const std::optional<LayerEffects> &effects)
{
const LayerEffects shown = effects.value_or(LayerEffects()).visible();
if (shown.isEmpty() || !shown.isValid())
return std::nullopt;
{
const QMutexLocker locked(&lock);
for (const Entry &entry : entries) {
if (entry.image.cacheKey() == image.cacheKey() && same(entry.mask, mask) && entry.effects == shown)
return entry.result;
}
}
try {
const Entry made{image, mask, shown, render(image, mask, shown)};
// About 64 MiB of results and sources, eight at most.
constexpr qint64 budget = 64 * 1024 * 1024;
const QMutexLocker locked(&lock);
if (bytes(made) <= budget) {
entries.push_back(made);
qint64 total = 0;
for (const Entry &entry : entries)
total += bytes(entry);
while (entries.size() > 8 || total > budget) {
total -= bytes(entries.front());
entries.erase(entries.begin());
}
}
return made.result;
} catch (const ProjectError &error) {
qCWarning(lcRendering) << "a layer's effects could not be made:" << error.what();
} catch (const ExportError &error) {
qCWarning(lcRendering) << "a layer's effects could not be made:" << error.what();
}
return std::nullopt;
}
LayerTransform LayerEffectsRenderer::placed(const LayerTransform &transform, const QImage &image, double inset)
{
const double width = image.width(), height = image.height();
if (!(width > inset * 2) || !(height > inset * 2))
return transform;
LayerTransform grown = transform;
grown.size = QSizeF(transform.size.width() * width / (width - inset * 2), transform.size.height() * height / (height - inset * 2));
grown.origin = transform.center() - QPointF(grown.size.width() / 2, grown.size.height() / 2);
return grown;
}
double LayerEffectsRenderer::margin(const LayerEffects &all)
{
const LayerEffects effects = all.visible();
double margin = 0;
if (effects.stroke && !effects.stroke->inside)
margin = std::max(margin, effects.stroke->size);
if (effects.shadow)
margin = std::max(margin, effects.shadow->distance + effects.shadow->blur * 3);
return std::ceil(margin) + 2;
}
LayerEffectsRenderer::Rendered LayerEffectsRenderer::render(const QImage &image, const std::optional<QImage> &mask, const LayerEffects &all)
{
const LayerEffects effects = all.visible();
if (!effects.isValid())
throw ProjectError(ProjectError::Kind::invalid);
const double inset = margin(effects);
const qint64 width = image.width() + qint64(inset) * 2, height = image.height() + qint64(inset) * 2;
if (width <= 0 || height <= 0 || width * height > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
// The pixels with room round them, then the effects.
QImage padded = BrushRaster::context(int(width), int(height), false);
{
QPainter painter(&padded);
BrushRaster::draw(masked(image, mask), QRectF(inset, inset, image.width(), image.height()), painter);
}
return {LayerEffectsKernel::render(padded, effects), inset};
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include "Document/LayerEffects.h"
#include "Document/LayerTransform.h"
#include <QImage>
#include <optional>
// Swift's LayerEffectsRenderer: a layer with its effects round it.
namespace LayerEffectsRenderer {
// Swift's tuple: the grown image, the room round the pixels.
struct Rendered {
QImage image;
double inset;
};
// Reused for the same pixels, mask and effects; eight kept.
std::optional<Rendered> cached(const QImage &image, const std::optional<QImage> &mask, const std::optional<LayerEffects> &effects);
// The transform grown by the margin, the image in place.
LayerTransform placed(const LayerTransform &transform, const QImage &image, double inset);
double margin(const LayerEffects &effects);
// `mask` hides pixels first, so effects follow what shows.
Rendered render(const QImage &image, const std::optional<QImage> &mask, const LayerEffects &effects);
}
+217
View File
@@ -0,0 +1,217 @@
#include "Document/EditorSession.h"
// Swift's session extension in LayerEffects.swift.
namespace {
// Swift's switch: one kind's effect, present or not.
void take(LayerEffects &into, const LayerEffects &from, LayerEffectKind kind)
{
switch (kind) {
case LayerEffectKind::stroke: into.stroke = from.stroke; return;
case LayerEffectKind::shadow: into.shadow = from.shadow; return;
case LayerEffectKind::colorOverlay: into.colorOverlay = from.colorOverlay; return;
case LayerEffectKind::innerShadow: into.innerShadow = from.innerShadow; return;
}
throw std::logic_error("unknown effect kind");
}
}
// Swift's isGroup terms are dead: folders never hold pixels.
bool EditorSession::canEditEffects() const
{
const std::optional<ImageLayer> layer = activeLayer();
return canEditLayers() && layer && layer->asset;
}
LayerEffects EditorSession::activeEffects() const
{
const std::optional<ImageLayer> layer = activeLayer();
return layer && layer->effects ? *layer->effects : LayerEffects();
}
LayerEffects EditorSession::editingEffects() const
{
const int index = m_document && m_effectsEditing ? indexOf(m_document->layers, m_effectsEditing->layerID) : -1;
return index >= 0 ? m_document->layers[size_t(index)].effects.value_or(LayerEffects()) : LayerEffects();
}
std::optional<LayerEffectSelection> EditorSession::selectedEffect() const
{
if (!m_effectSelection || m_effectSelection->layerID != m_activeLayerID || !activeEffects().contains(m_effectSelection->kind))
return std::nullopt;
return m_effectSelection;
}
void EditorSession::addEffect(LayerEffectKind kind)
{
if (!canEditEffects())
return;
const QUuid id = m_activeLayerID.value();
if (m_effectsEditing == LayerEffectSelection{id, kind})
return;
finishEffectsEditing(false);
const LayerEffects original = activeEffects();
LayerEffects effects = original;
// A new stroke or overlay takes the background colour.
const PaletteColor ground = m_backgroundColor;
switch (kind) {
case LayerEffectKind::stroke:
if (!effects.stroke)
effects.stroke = StrokeEffect{.red = ground.red, .green = ground.green, .blue = ground.blue};
break;
case LayerEffectKind::shadow:
if (!effects.shadow)
effects.shadow = ShadowEffect();
break;
case LayerEffectKind::colorOverlay:
if (!effects.colorOverlay)
effects.colorOverlay = ColorOverlayEffect{.red = ground.red, .green = ground.green, .blue = ground.blue};
break;
case LayerEffectKind::innerShadow:
if (!effects.innerShadow)
effects.innerShadow = InnerShadowEffect();
break;
}
setEffects(effects, id, QStringLiteral("Add ") + rawValue(kind));
selectEffect(kind, id, true);
m_effectsEditingOriginal = original;
notify();
}
void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing)
{
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (!canEditLayers() || index < 0 || !m_document->layers[size_t(index)].effects || !m_document->layers[size_t(index)].effects->contains(kind))
return;
const LayerEffectSelection selection{id, kind};
if (editing && m_effectsEditing != selection)
finishEffectsEditing(false);
// Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here.
selectLayer(id);
m_isMaskSelected = false;
m_effectSelection = selection;
if (editing && m_effectsEditing != selection) {
m_effectsEditingOriginal = m_document->layers[size_t(indexOf(m_document->layers, id))].effects.value_or(LayerEffects());
m_effectsEditing = selection;
}
notify();
}
void EditorSession::finishEffectsEditing(bool commit)
{
if (!m_effectsEditing)
return;
const LayerEffectSelection editing = *m_effectsEditing;
closeEffectColorPicker(commit);
const int index = m_document ? indexOf(m_document->layers, editing.layerID) : -1;
if (!commit && m_effectsEditingOriginal && index >= 0 && m_document->layers[size_t(index)].effects) {
LayerEffects effects = *m_document->layers[size_t(index)].effects;
take(effects, *m_effectsEditingOriginal, editing.kind);
setEffects(effects, editing.layerID, QStringLiteral("Cancel ") + rawValue(editing.kind));
}
m_effectsEditing.reset();
m_effectsEditingOriginal.reset();
if (!selectedEffect())
m_effectSelection.reset();
notify();
}
void EditorSession::setEffects(const LayerEffects &effects, std::optional<QUuid> id, const QString &name)
{
const int index = m_document ? indexOf(m_document->layers, id ? id : m_activeLayerID) : -1;
if (!canEditLayers() || !effects.isValid() || index < 0)
return;
const ImageLayer &layer = m_document->layers[size_t(index)];
const std::optional<LayerEffects> stored = effects.isEmpty() ? std::nullopt : std::optional(effects);
if (!layer.asset || layer.effects == stored)
return;
finishOpacityEdit();
beginEdit(name);
m_document->layers[size_t(index)].effects = stored;
endEdit();
}
void EditorSession::changeEffects(const std::function<void(LayerEffects &)> &change)
{
const int index = m_document && m_effectsEditing ? indexOf(m_document->layers, m_effectsEditing->layerID) : -1;
if (index < 0 || !m_document->layers[size_t(index)].effects || !m_document->layers[size_t(index)].effects->contains(m_effectsEditing->kind))
return;
LayerEffects effects = *m_document->layers[size_t(index)].effects;
change(effects);
setEffects(effects, m_effectsEditing->layerID, QStringLiteral("Edit ") + rawValue(m_effectsEditing->kind));
}
bool EditorSession::canCopyEffect(LayerEffectKind kind, QUuid source, QUuid target) const
{
const int from = m_document ? indexOf(m_document->layers, source) : -1, to = m_document ? indexOf(m_document->layers, target) : -1;
if (!canEditLayers() || source == target || from < 0 || to < 0)
return false;
const ImageLayer &sourceLayer = m_document->layers[size_t(from)], &targetLayer = m_document->layers[size_t(to)];
return sourceLayer.effects && sourceLayer.effects->contains(kind) && targetLayer.asset;
}
void EditorSession::copyEffect(LayerEffectKind kind, QUuid source, QUuid target)
{
if (!canCopyEffect(kind, source, target))
return;
const LayerEffects original = m_document->layers[size_t(indexOf(m_document->layers, source))].effects.value();
// Its editor closes first: a later Cancel keeps the copy.
if (m_effectsEditing == LayerEffectSelection{target, kind})
finishEffectsEditing(true);
LayerEffects effects = m_document->layers[size_t(indexOf(m_document->layers, target))].effects.value_or(LayerEffects());
take(effects, original, kind);
setEffects(effects, target, QStringLiteral("Copy ") + rawValue(kind));
selectEffect(kind, target);
}
void EditorSession::toggleEffect(LayerEffectKind kind, QUuid id)
{
const int index = m_document ? indexOf(m_document->layers, id) : -1;
if (index < 0 || !m_document->layers[size_t(index)].effects)
return;
LayerEffects effects = *m_document->layers[size_t(index)].effects;
const bool enabled = effects.isEnabled(kind);
effects.setEnabled(!enabled, kind);
setEffects(effects, id, (enabled ? QStringLiteral("Hide ") : QStringLiteral("Show ")) + rawValue(kind));
}
void EditorSession::removeSelectedEffect()
{
const std::optional<LayerEffectSelection> selected = selectedEffect();
const int index = m_document && selected ? indexOf(m_document->layers, selected->layerID) : -1;
if (!selected || !canEditLayers() || index < 0 || !m_document->layers[size_t(index)].effects)
return;
LayerEffects effects = *m_document->layers[size_t(index)].effects;
if (m_effectsEditing == selected) {
closeEffectColorPicker(false);
m_effectsEditing.reset();
m_effectsEditingOriginal.reset();
}
effects.remove(selected->kind);
setEffects(effects, selected->layerID, QStringLiteral("Remove ") + rawValue(selected->kind));
m_effectSelection.reset();
notify();
}
void EditorSession::setEffectsEditing(std::optional<LayerEffectSelection> selection)
{
if (std::exchange(m_effectsEditing, selection) != selection)
notify();
}
void EditorSession::setEffectsEditingOriginal(std::optional<LayerEffects> original)
{
if (std::exchange(m_effectsEditingOriginal, original) != original)
notify();
}
void EditorSession::dropEffectSelection()
{
if (std::exchange(m_effectSelection, std::nullopt))
notify();
}
void EditorSession::closeEffectColorPicker(bool commit)
{
if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::effect)
closeColorPicker(commit);
}
+27
View File
@@ -0,0 +1,27 @@
// Swift's LayerEffects extension: effects kept with their layer.
public:
bool canEditEffects() const;
LayerEffects activeEffects() const;
// The effects of the layer whose panel is open.
LayerEffects editingEffects() const;
// The chosen effect, if active and still on its layer.
std::optional<LayerEffectSelection> selectedEffect() const;
// Adds the effect with its defaults, then opens its panel.
void addEffect(LayerEffectKind kind);
void selectEffect(LayerEffectKind kind, QUuid id, bool editing = false);
// Cancel puts back this panel's effect alone.
void finishEffectsEditing(bool commit);
// One step, on the active layer unless another is named.
void setEffects(const LayerEffects &effects, std::optional<QUuid> id = std::nullopt, const QString &name = QStringLiteral("Layer Effects"));
// Panel edits stay with the layer that opened the panel.
void changeEffects(const std::function<void(LayerEffects &)> &change);
bool canCopyEffect(LayerEffectKind kind, QUuid source, QUuid target) const;
void copyEffect(LayerEffectKind kind, QUuid source, QUuid target);
void toggleEffect(LayerEffectKind kind, QUuid id);
void removeSelectedEffect();
// Swift's `effectSelection = nil`, before any guard; announced.
void dropEffectSelection();
private:
// An open effect colour picker goes with its panel.
void closeEffectColorPicker(bool commit);
+178
View File
@@ -0,0 +1,178 @@
#include "Document/LayerEffects.h"
#include <cmath>
#include <stdexcept>
namespace {
bool within(double value, double low, double high)
{
return value >= low && value <= high;
}
bool colourIsValid(double red, double green, double blue)
{
return within(red, 0, 1) && within(green, 0, 1) && within(blue, 0, 1);
}
// Away from the light; a layer's pixels count y downward.
QSizeF fallingFrom(double angle, double distance)
{
const double radians = angle * M_PI / 180;
return QSizeF(-std::cos(radians) * distance, std::sin(radians) * distance);
}
bool shadowIsValid(double angle, double distance, double blur, double opacity)
{
return within(angle, -360, 360) && within(distance, 0, 5000) && within(blur, 0, 500) && within(opacity, 0, 1);
}
}
bool StrokeEffect::isValid() const
{
return within(size, 0, maxSize) && within(opacity, 0, 1) && colourIsValid(red, green, blue);
}
QSizeF ShadowEffect::offset() const
{
return fallingFrom(angle, distance);
}
bool ShadowEffect::isValid() const
{
return shadowIsValid(angle, distance, blur, opacity) && colourIsValid(red, green, blue);
}
bool ColorOverlayEffect::isValid() const
{
return within(opacity, 0, 1) && colourIsValid(red, green, blue);
}
QSizeF InnerShadowEffect::offset() const
{
return fallingFrom(angle, distance);
}
bool InnerShadowEffect::isValid() const
{
return shadowIsValid(angle, distance, blur, opacity) && colourIsValid(red, green, blue);
}
QString rawValue(LayerEffectKind kind)
{
switch (kind) {
case LayerEffectKind::stroke: return QStringLiteral("Stroke");
case LayerEffectKind::shadow: return QStringLiteral("Drop Shadow");
case LayerEffectKind::colorOverlay: return QStringLiteral("Color Overlay");
case LayerEffectKind::innerShadow: return QStringLiteral("Inner Shadow");
}
throw std::logic_error("unknown effect kind");
}
bool LayerEffects::isValid() const
{
return (!stroke || stroke->isValid()) && (!shadow || shadow->isValid()) && (!colorOverlay || colorOverlay->isValid())
&& (!innerShadow || innerShadow->isValid());
}
std::vector<LayerEffectKind> LayerEffects::kinds() const
{
std::vector<LayerEffectKind> result;
for (const LayerEffectKind kind : allLayerEffectKinds) {
if (contains(kind))
result.push_back(kind);
}
return result;
}
bool LayerEffects::contains(LayerEffectKind kind) const
{
switch (kind) {
case LayerEffectKind::stroke: return stroke.has_value();
case LayerEffectKind::shadow: return shadow.has_value();
case LayerEffectKind::colorOverlay: return colorOverlay.has_value();
case LayerEffectKind::innerShadow: return innerShadow.has_value();
}
throw std::logic_error("unknown effect kind");
}
bool LayerEffects::isEnabled(LayerEffectKind kind) const
{
switch (kind) {
case LayerEffectKind::stroke: return stroke && stroke->isEnabled();
case LayerEffectKind::shadow: return shadow && shadow->isEnabled();
case LayerEffectKind::colorOverlay: return colorOverlay && colorOverlay->isEnabled();
case LayerEffectKind::innerShadow: return innerShadow && innerShadow->isEnabled();
}
throw std::logic_error("unknown effect kind");
}
std::optional<PaletteColor> LayerEffects::color(LayerEffectKind kind) const
{
switch (kind) {
case LayerEffectKind::stroke: return stroke ? std::optional(stroke->color()) : std::nullopt;
case LayerEffectKind::shadow: return shadow ? std::optional(shadow->color()) : std::nullopt;
case LayerEffectKind::colorOverlay: return colorOverlay ? std::optional(colorOverlay->color()) : std::nullopt;
case LayerEffectKind::innerShadow: return innerShadow ? std::optional(innerShadow->color()) : std::nullopt;
}
throw std::logic_error("unknown effect kind");
}
namespace {
// Swift's optional chaining: a missing effect takes nothing.
template <typename Effect> void paint(std::optional<Effect> &effect, const PaletteColor &color)
{
if (!effect)
return;
effect->red = color.red;
effect->green = color.green;
effect->blue = color.blue;
}
template <typename Effect> void enable(std::optional<Effect> &effect, bool enabled)
{
if (effect)
effect->enabled = enabled;
}
template <typename Effect> std::optional<Effect> shown(const std::optional<Effect> &effect)
{
return effect && effect->isEnabled() ? effect : std::nullopt;
}
}
void LayerEffects::setColor(const PaletteColor &color, LayerEffectKind kind)
{
switch (kind) {
case LayerEffectKind::stroke: paint(stroke, color); return;
case LayerEffectKind::shadow: paint(shadow, color); return;
case LayerEffectKind::colorOverlay: paint(colorOverlay, color); return;
case LayerEffectKind::innerShadow: paint(innerShadow, color); return;
}
throw std::logic_error("unknown effect kind");
}
void LayerEffects::remove(LayerEffectKind kind)
{
switch (kind) {
case LayerEffectKind::stroke: stroke.reset(); return;
case LayerEffectKind::shadow: shadow.reset(); return;
case LayerEffectKind::colorOverlay: colorOverlay.reset(); return;
case LayerEffectKind::innerShadow: innerShadow.reset(); return;
}
throw std::logic_error("unknown effect kind");
}
void LayerEffects::setEnabled(bool enabled, LayerEffectKind kind)
{
switch (kind) {
case LayerEffectKind::stroke: enable(stroke, enabled); return;
case LayerEffectKind::shadow: enable(shadow, enabled); return;
case LayerEffectKind::colorOverlay: enable(colorOverlay, enabled); return;
case LayerEffectKind::innerShadow: enable(innerShadow, enabled); return;
}
throw std::logic_error("unknown effect kind");
}
LayerEffects LayerEffects::visible() const
{
return LayerEffects{shown(stroke), shown(shadow), shown(colorOverlay), shown(innerShadow)};
}
+108
View File
@@ -0,0 +1,108 @@
#pragma once
#include "Document/ColorPalette.h"
#include <QSizeF>
#include <QString>
#include <QUuid>
#include <array>
#include <optional>
#include <vector>
// A line round what the layer shows, outside or in.
struct StrokeEffect {
// Swift's supported width in document pixels.
static constexpr double maxSize = 500;
// Absent in older projects, which means shown.
std::optional<bool> enabled = std::nullopt;
double size = 4;
double red = 0;
double green = 0;
double blue = 0;
double opacity = 1;
bool inside = false;
bool isEnabled() const { return enabled.value_or(true); }
PaletteColor color() const { return {red, green, blue}; }
bool isValid() const;
friend bool operator==(const StrokeEffect &, const StrokeEffect &) = default;
};
// The layer's shape behind it, offset and softened.
struct ShadowEffect {
std::optional<bool> enabled = std::nullopt;
// Where the light comes from, counterclockwise from the right.
double angle = 90;
double distance = 20;
double blur = 20;
double red = 0;
double green = 0;
double blue = 0;
double opacity = 0.5;
bool isEnabled() const { return enabled.value_or(true); }
PaletteColor color() const { return {red, green, blue}; }
// In layer pixels, y down: away from the light.
QSizeF offset() const;
bool isValid() const;
friend bool operator==(const ShadowEffect &, const ShadowEffect &) = default;
};
// A flat colour over everything the layer shows.
struct ColorOverlayEffect {
std::optional<bool> enabled = std::nullopt;
double red = 0;
double green = 0;
double blue = 0;
double opacity = 1;
bool isEnabled() const { return enabled.value_or(true); }
PaletteColor color() const { return {red, green, blue}; }
bool isValid() const;
friend bool operator==(const ColorOverlayEffect &, const ColorOverlayEffect &) = default;
};
// A shadow inside the layer's own edges.
struct InnerShadowEffect {
std::optional<bool> enabled = std::nullopt;
double angle = 90;
double distance = 10;
double blur = 10;
double red = 0;
double green = 0;
double blue = 0;
double opacity = 0.5;
bool isEnabled() const { return enabled.value_or(true); }
PaletteColor color() const { return {red, green, blue}; }
QSizeF offset() const;
bool isValid() const;
friend bool operator==(const InnerShadowEffect &, const InnerShadowEffect &) = default;
};
// Swift's raw values are the panel's and the menu's words.
enum class LayerEffectKind { stroke, shadow, colorOverlay, innerShadow };
inline constexpr std::array allLayerEffectKinds{LayerEffectKind::stroke, LayerEffectKind::shadow, LayerEffectKind::colorOverlay,
LayerEffectKind::innerShadow};
QString rawValue(LayerEffectKind kind);
// What a layer draws round itself; its pixels stay untouched.
struct LayerEffects {
std::optional<StrokeEffect> stroke = std::nullopt;
std::optional<ShadowEffect> shadow = std::nullopt;
std::optional<ColorOverlayEffect> colorOverlay = std::nullopt;
std::optional<InnerShadowEffect> innerShadow = std::nullopt;
bool isEmpty() const { return !stroke && !shadow && !colorOverlay && !innerShadow; }
bool isValid() const;
std::vector<LayerEffectKind> kinds() const;
bool contains(LayerEffectKind kind) const;
bool isEnabled(LayerEffectKind kind) const;
std::optional<PaletteColor> color(LayerEffectKind kind) const;
void setColor(const PaletteColor &color, LayerEffectKind kind);
void remove(LayerEffectKind kind);
void setEnabled(bool enabled, LayerEffectKind kind);
// The effects that are switched on.
LayerEffects visible() const;
friend bool operator==(const LayerEffects &, const LayerEffects &) = default;
};
// One effect of one layer: the panel's and the list's.
struct LayerEffectSelection {
QUuid layerID;
LayerEffectKind kind;
friend bool operator==(const LayerEffectSelection &, const LayerEffectSelection &) = default;
};
+5
View File
@@ -0,0 +1,5 @@
// Swift's LayerFlip extension.
public:
void flipLayers(bool horizontally);
// Every layer, placed mask and the selection across the middle.
void flipCanvas(bool horizontally);
+71
View File
@@ -0,0 +1,71 @@
#include "Document/EditorSession.h"
LayerTransform LayerTransform::mirrored(bool horizontally, double axis) const
{
LayerTransform result = *this;
// The middle crosses the line; picture and angle turn over.
if (horizontally) {
result.flipX = !flipX;
result.origin.setX(2 * axis - center().x() - size.width() / 2);
} else {
result.flipY = !flipY;
result.origin.setY(2 * axis - center().y() - size.height() / 2);
}
result.rotation = -rotation;
return result;
}
// A layer about its middle; a group about its box.
void EditorSession::flipLayers(bool horizontally)
{
commitTransform();
if (!canTransform())
return;
std::vector<ImageLayer> members;
double axis = 0;
if (transformsAsGroup()) {
const LayerTransform box = groupTransformBox().value();
members = groupTransformMembers();
axis = horizontally ? box.center().x() : box.center().y();
} else {
const ImageLayer layer = activeLayer().value();
members = {layer};
axis = horizontally ? layer.transform.center().x() : layer.transform.center().y();
}
QSet<QUuid> ids;
for (const ImageLayer &member : members)
ids.insert(member.id);
beginEdit(horizontally ? QStringLiteral("Flip Horizontal") : QStringLiteral("Flip Vertical"));
for (ImageLayer &layer : m_document->layers) {
if (!ids.contains(layer.id))
continue;
const LayerTransform flipped = layer.transform.mirrored(horizontally, axis);
// A linked mask flips along; an unlinked one stays.
if (layer.mask)
layer.mask->placement = layer.mask->placementMovingLayer(layer.transform, flipped);
layer.transform = flipped;
}
endEdit();
}
// One step; Swift's finishOpacityEdit ran in commitTransform already.
void EditorSession::flipCanvas(bool horizontally)
{
commitTransform();
cancelCrop();
if (!canEditLayers())
return;
const QSizeF size = m_document->size();
const double axis = horizontally ? size.width() / 2 : size.height() / 2;
beginEdit(horizontally ? QStringLiteral("Flip Canvas Horizontal") : QStringLiteral("Flip Canvas Vertical"));
for (ImageLayer &layer : m_document->layers) {
layer.transform = layer.transform.mirrored(horizontally, axis);
if (layer.mask && layer.mask->placement)
layer.mask->placement = layer.mask->placement->mirrored(horizontally, axis);
}
if (m_document->selection) {
const QTransform mirror = horizontally ? QTransform(-1, 0, 0, 1, size.width(), 0) : QTransform(1, 0, 0, -1, 0, size.height());
m_document->selection->path = mirror.map(m_document->selection->path);
}
endEdit();
}
+13
View File
@@ -0,0 +1,13 @@
// Swift's LayerGroups extension: folders and the multi-selection.
public:
QSet<QUuid> descendantIDs(QUuid id) const;
void addGroup();
void toggleGroupExpansion(QUuid id);
// The primary layer becomes active when the document holds it.
void selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary);
void extendSelection(QUuid id);
void groupSelectedLayers();
std::vector<LayerHierarchy::Entry> layerRows() const;
bool canPlaceLayer(QUuid id, std::optional<QUuid> parent) const;
bool placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false);
void moveActiveLayerOutOfGroup();
+405
View File
@@ -0,0 +1,405 @@
#include "Document/LayerGroups.h"
#include "Document/EditorSession.h"
#include <QHash>
#include <algorithm>
#include <map>
#include <stdexcept>
namespace {
using Children = std::map<std::optional<QUuid>, std::vector<const ProjectLayerRecord *>>;
void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool visible, bool topFirst,
const QSet<QUuid> &collapsed, std::vector<LayerHierarchy::Entry> &result)
{
if (depth > 64)
return;
const auto found = children.find(parent);
if (found == children.end())
return;
std::vector<const ProjectLayerRecord *> siblings = found->second;
if (topFirst)
std::reverse(siblings.begin(), siblings.end());
for (const ProjectLayerRecord *layer : siblings) {
const bool effective = visible && layer->isVisible;
result.push_back({*layer, depth, effective});
if (layer->isGroup == true && !collapsed.contains(layer->id))
visit(children, layer->id, depth + 1, effective, topFirst, collapsed, result);
}
}
// Swift's `try?`: a refused hierarchy changes nothing.
bool isValidHierarchy(const std::vector<ImageLayer> &layers)
{
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : layers)
records.push_back(layer.hierarchyRecord());
try {
LayerHierarchy::validate(records);
} catch (const ProjectError &) {
return false;
}
return true;
}
}
std::vector<LayerHierarchy::Entry> LayerHierarchy::entries(const std::vector<ProjectLayerRecord> &layers,
bool topFirst, const QSet<QUuid> &collapsed)
{
Children children;
for (const ProjectLayerRecord &layer : layers)
children[layer.parentID].push_back(&layer);
std::vector<Entry> result;
visit(children, std::nullopt, 0, true, topFirst, collapsed, result);
return result;
}
std::vector<ProjectLayerRecord> LayerHierarchy::visibleLayers(const std::vector<ProjectLayerRecord> &layers)
{
std::vector<ProjectLayerRecord> result;
for (const Entry &entry : entries(layers)) {
if (entry.visible && entry.layer.isGroup != true)
result.push_back(entry.layer);
}
return result;
}
void LayerHierarchy::validate(const std::vector<ProjectLayerRecord> &layers)
{
QHash<QUuid, const ProjectLayerRecord *> byID;
for (const ProjectLayerRecord &layer : layers) {
if (byID.contains(layer.id) || (layer.isGroup == true && layer.imageFile))
throw ProjectError(ProjectError::Kind::invalid);
byID.insert(layer.id, &layer);
}
for (const ProjectLayerRecord &layer : layers) {
QSet<QUuid> seen{layer.id};
std::optional<QUuid> parent = layer.parentID;
while (parent) {
const ProjectLayerRecord *node = byID.value(*parent);
if (seen.size() > 64 || seen.contains(*parent) || !node || node->isGroup != true)
throw ProjectError(ProjectError::Kind::invalid);
seen.insert(*parent);
parent = node->parentID;
}
if (layer.isGroup == true && seen.size() > 64)
throw ProjectError(ProjectError::Kind::invalid);
}
}
ProjectLayerRecord ImageLayer::hierarchyRecord() const
{
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
const std::optional<LayerShape> live = liveShape();
const std::optional<LayerText> typed = liveText();
return {.id = id,
.name = name,
.isVisible = isVisible,
.transform = transform,
.imageFile = asset ? std::optional(file + ".png") : std::nullopt,
.parentID = parentID,
.isGroup = isGroup,
.opacity = opacity,
.blendMode = blendMode,
.maskFile = mask ? std::optional(file + ".mask.png") : std::nullopt,
.maskEnabled = mask ? std::optional(mask->isEnabled) : std::nullopt,
.maskSourceID = maskSourceID,
.adjustment = adjustment,
.maskPlacement = mask ? mask->placement : std::nullopt,
.maskLinked = mask ? std::optional(mask->isLinked) : std::nullopt,
.shape = live ? std::optional(live->style) : std::nullopt,
.effects = effects,
.text = typed ? std::optional(typed->style) : std::nullopt};
}
std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const
{
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : layers)
records.push_back(layer.hierarchyRecord());
return LayerHierarchy::entries(records);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
QSet<QUuid> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible)
result.insert(entry.layer.id);
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
QHash<QUuid, const ImageLayer *> byID;
for (const ImageLayer &layer : layers) {
if (byID.contains(layer.id))
throw std::logic_error("two layers share an id");
byID.insert(layer.id, &layer);
}
std::vector<ImageLayer> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible && entry.layer.isGroup != true)
result.push_back(*byID.value(entry.layer.id));
}
return result;
}
void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary)
{
dropEffectSelection();
if (ids != m_selectedLayerIDs && !finishText())
return;
if (m_brushStroke)
return;
QSet<QUuid> valid;
if (m_document) {
for (const ImageLayer &layer : m_document->layers) {
if (ids.contains(layer.id))
valid.insert(layer.id);
}
}
if (valid != m_selectedLayerIDs) {
commitTransform();
resolveGradient();
}
// The primary layer is active; any other of them otherwise.
const std::optional<QUuid> active = primary && valid.contains(*primary) ? primary
: valid.isEmpty() ? std::nullopt
: std::optional(*valid.begin());
setActiveLayerID(active);
m_selectedLayerIDs = valid;
notify();
}
void EditorSession::extendSelection(QUuid id)
{
const auto known = [&](const ImageLayer &layer) { return layer.id == id; };
// Cmd-Shift-click works while a transform is open too.
if ((!canEditLayers() && !m_transformEdit) || std::none_of(m_document->layers.begin(), m_document->layers.end(), known))
return;
QSet<QUuid> ids = m_selectedLayerIDs;
if (ids.contains(id) && ids.size() > 1) {
// Taking the active layer out hands its role on.
ids.remove(id);
selectLayers(ids, m_activeLayerID == id ? std::optional(*ids.begin()) : m_activeLayerID);
} else {
ids.insert(id);
selectLayers(ids, id);
}
}
void EditorSession::groupSelectedLayers()
{
if (!canEditLayers() || m_document->layers.size() >= 10'000)
return;
const std::vector<ImageLayer> &existing = m_document->layers;
std::map<QUuid, const ImageLayer *> byID;
for (const ImageLayer &layer : existing)
byID[layer.id] = &layer;
// The folders around a layer, nearest first.
using Chain = std::vector<QUuid>;
const auto ancestors = [&](QUuid id) {
Chain result;
for (std::optional<QUuid> parent = byID.at(id)->parentID; parent; parent = byID.at(*parent)->parentID)
result.push_back(*parent);
return result;
};
// A selected folder carries its contents: they stay inside it.
QSet<QUuid> rootIDs;
for (const QUuid id : m_selectedLayerIDs) {
if (!byID.count(id))
continue;
const Chain chain = ancestors(id);
if (std::none_of(chain.begin(), chain.end(), [&](QUuid above) { return m_selectedLayerIDs.contains(above); }))
rootIDs.insert(id);
}
std::vector<QUuid> ordered;
std::vector<Chain> chains;
for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) {
if (rootIDs.contains(entry.layer.id)) {
ordered.push_back(entry.layer.id);
chains.push_back(ancestors(entry.layer.id));
}
}
// The nearest folder they all share; the root when none.
std::optional<QUuid> parent;
for (const QUuid candidate : chains.empty() ? Chain() : chains.front()) {
const auto holds = [&](const Chain &chain) { return std::find(chain.begin(), chain.end(), candidate) != chain.end(); };
if (std::all_of(chains.begin(), chains.end(), holds)) {
parent = candidate;
break;
}
}
int number = 1;
const auto taken = [&](const QString &name) {
return std::any_of(existing.begin(), existing.end(), [&](const ImageLayer &layer) { return layer.name == name; });
};
while (taken(QStringLiteral("Folder %1").arg(number)))
++number;
ImageLayer group(QStringLiteral("Folder %1").arg(number), m_document->size());
group.isGroup = true;
group.parentID = parent;
// It sits at the topmost selected branch under that parent.
QSet<QUuid> branches;
for (QUuid branch : ordered) {
while (byID.at(branch)->parentID && byID.at(branch)->parentID != parent)
branch = *byID.at(branch)->parentID;
branches.insert(branch);
}
const auto isKept = [&](const ImageLayer &layer) { return !rootIDs.contains(layer.id); };
const auto highest = std::find_if(existing.rbegin(), existing.rend(), [&](const ImageLayer &layer) { return branches.contains(layer.id); });
// With nothing selected the folder goes on top.
const auto insertion = highest == existing.rend() ? std::ptrdiff_t(existing.size()) : std::count_if(existing.begin(), highest.base(), isKept);
std::vector<ImageLayer> layers;
std::copy_if(existing.begin(), existing.end(), std::back_inserter(layers), isKept);
layers.insert(layers.begin() + insertion, group);
for (const QUuid id : ordered) {
ImageLayer child = *byID.at(id);
child.parentID = group.id;
layers.push_back(child);
}
if (!isValidHierarchy(layers))
return;
beginEdit(QStringLiteral("Group Layers"));
m_document->layers = layers;
setActiveLayerID(group.id);
if (parent)
m_collapsedGroupIDs.remove(*parent);
endEdit();
}
std::vector<LayerHierarchy::Entry> EditorSession::layerRows() const
{
std::vector<ProjectLayerRecord> records;
if (m_document) {
for (const ImageLayer &layer : m_document->layers)
records.push_back(layer.hierarchyRecord());
}
return LayerHierarchy::entries(records, true, m_collapsedGroupIDs);
}
QSet<QUuid> EditorSession::descendantIDs(QUuid id) const
{
QSet<QUuid> result;
if (!m_document)
return result;
// One lookup of children by parent, then the walk.
QMultiHash<QUuid, QUuid> children;
for (const ImageLayer &layer : m_document->layers) {
if (layer.parentID)
children.insert(*layer.parentID, layer.id);
}
std::vector<QUuid> pending{id};
while (!pending.empty()) {
const QUuid parent = pending.back();
pending.pop_back();
for (const QUuid child : children.values(parent)) {
if (!result.contains(child)) {
result.insert(child);
pending.push_back(child);
}
}
}
return result;
}
void EditorSession::addGroup()
{
if (!canEditLayers() || m_document->layers.size() >= 10'000)
return;
int number = 1;
const auto taken = [&](const QString &name) {
return std::any_of(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &layer) { return layer.name == name; });
};
while (taken(QStringLiteral("Folder %1").arg(number)))
++number;
ImageLayer group(QStringLiteral("Folder %1").arg(number), m_document->size());
group.isGroup = true;
const std::optional<ImageLayer> active = activeLayer();
group.parentID = active && active->isGroup ? m_activeLayerID : active ? active->parentID : std::nullopt;
std::vector<ImageLayer> layers = m_document->layers;
const auto place = std::find_if(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return m_activeLayerID && layer.id == *m_activeLayerID; });
layers.insert(place == layers.end() ? place : place + 1, group);
// A folder nested too deep is refused.
if (!isValidHierarchy(layers))
return;
beginEdit(QStringLiteral("New Folder"));
m_document->layers = layers;
setActiveLayerID(group.id);
if (group.parentID)
m_collapsedGroupIDs.remove(*group.parentID);
endEdit();
}
void EditorSession::toggleGroupExpansion(QUuid id)
{
const auto isFolder = [&](const ImageLayer &layer) { return layer.id == id && layer.isGroup; };
if (m_isProjectBusy || !m_document || std::none_of(m_document->layers.begin(), m_document->layers.end(), isFolder))
return;
if (m_collapsedGroupIDs.contains(id)) {
m_collapsedGroupIDs.remove(id);
} else {
// A folded folder takes the selection from what it hides.
if (m_activeLayerID && descendantIDs(id).contains(*m_activeLayerID))
selectLayer(id);
m_collapsedGroupIDs.insert(id);
}
notify();
}
bool EditorSession::canPlaceLayer(QUuid id, std::optional<QUuid> parent) const
{
const auto holds = [&](QUuid wanted, bool folder) {
return std::any_of(m_document->layers.begin(), m_document->layers.end(),
[&](const ImageLayer &layer) { return layer.id == wanted && (!folder || layer.isGroup); });
};
if (!canEditLayers() || !holds(id, false))
return false;
// Into a folder: never itself nor one of its contents.
return !parent || (*parent != id && !descendantIDs(id).contains(*parent) && holds(*parent, true));
}
bool EditorSession::placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above, bool atBottom)
{
if (!canPlaceLayer(id, parent))
return false;
std::vector<ImageLayer> layers = m_document->layers;
const auto from = std::find_if(layers.begin(), layers.end(), [&](const ImageLayer &layer) { return layer.id == id; });
ImageLayer layer = *from;
layers.erase(from);
layer.parentID = parent;
auto insertion = atBottom ? layers.begin() : layers.end();
// The layer itself is gone by now: no target.
if (above) {
const auto target = std::find_if(layers.begin(), layers.end(),
[&](const ImageLayer &each) { return each.id == *above && each.parentID == parent; });
if (target == layers.end())
return false;
insertion = target + 1;
}
layers.insert(insertion, layer);
// Dropped into a clipping stack it joins; broken ones release.
adoptClipping(id, layers);
releaseDetachedClipping(layers);
if (!isValidHierarchy(layers))
return false;
beginEdit(QStringLiteral("Move Layer"));
m_document->layers = layers;
setActiveLayerID(id);
if (parent)
m_collapsedGroupIDs.remove(*parent);
endEdit();
return true;
}
void EditorSession::moveActiveLayerOutOfGroup()
{
const std::optional<ImageLayer> layer = activeLayer();
if (!layer || !layer->parentID)
return;
const auto group = std::find_if(m_document->layers.begin(), m_document->layers.end(),
[&](const ImageLayer &each) { return each.id == *layer->parentID; });
if (group != m_document->layers.end())
placeLayer(layer->id, group->parentID, group->id);
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "IO/ProjectStore.h"
#include <QSet>
#include <vector>
namespace LayerHierarchy {
struct Entry {
ProjectLayerRecord layer;
int depth;
bool visible;
};
std::vector<Entry> entries(const std::vector<ProjectLayerRecord> &layers, bool topFirst = false,
const QSet<QUuid> &collapsed = {});
std::vector<ProjectLayerRecord> visibleLayers(const std::vector<ProjectLayerRecord> &layers);
void validate(const std::vector<ProjectLayerRecord> &layers);
}
+20
View File
@@ -0,0 +1,20 @@
// Swift's LayerMask extension: masks on layers and folders.
public:
void selectLayerTarget(QUuid id, bool mask);
// Layers and folders alike take a mask.
bool canEditMask() const;
void addLayerMask(bool revealing = true);
void toggleLayerMask();
void deleteLayerMask();
bool canCopyMask(QUuid source, QUuid target) const;
void copyMask(QUuid source, QUuid target);
void toggleMaskLink(QUuid id);
// Where a mask shows now; nil while covering its layer.
std::optional<LayerTransform> displayedMaskPlacement(const ImageLayer &layer) const;
// An unlinked mask distorted alone, in the layer's grid.
std::optional<QImage> maskDistortPreview(const ImageLayer &layer) const;
// The selection made a mask; without one, a solid mask.
void addMask(bool revealing = true);
private:
void commitMaskTransform();
+493
View File
@@ -0,0 +1,493 @@
#include "Document/Distort.h"
#include "Document/LayerMask.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/DownsampleCache.h"
#include <QHash>
#include "IO/ProjectStore.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
LayerMask::LayerMask(ImportedImage asset, bool isEnabled, std::optional<LayerTransform> placement, bool isLinked)
: asset(std::move(asset)), isEnabled(isEnabled), placement(placement), isLinked(isLinked)
{
}
std::optional<QImage> LayerMask::enabledImage() const
{
return isEnabled ? std::optional(asset.image()) : std::nullopt;
}
std::optional<LayerMask> ProjectSnapshot::mask(const ProjectLayerRecord &layer) const
{
const auto asset = masks.find(layer.id);
if (!layer.maskFile || asset == masks.end())
return std::nullopt;
return LayerMask(asset->second, layer.maskEnabled.value_or(true), layer.maskPlacement, layer.maskLinked.value_or(true));
}
bool operator==(const LayerMask &lhs, const LayerMask &rhs)
{
return lhs.asset.identity() == rhs.asset.identity() && lhs.isEnabled == rhs.isEnabled
&& lhs.placement == rhs.placement && lhs.isLinked == rhs.isLinked;
}
LayerMask LayerMask::replacing(ImportedImage replacement) const
{
return LayerMask(std::move(replacement), isEnabled, placement, isLinked);
}
bool LayerMask::isValid(const QImage &image)
{
return image.format() == QImage::Format_Grayscale8;
}
LayerMask LayerMask::solid(bool revealing)
{
QImage image = BrushRaster::context(1, 1, true);
image.fill(revealing ? 255 : 0);
return LayerMask(ImportedImage(image, image, QStringLiteral("Layer Mask")));
}
ImportedImage LayerMask::assetFrom(const QImage &image)
{
if (!isValid(image))
throw ProjectError(ProjectError::Kind::invalid);
const double factor = std::min(1.0, 96.0 / std::max(image.width(), image.height()));
const QSize size(std::max(1, int(image.width() * factor)), std::max(1, int(image.height() * factor)));
const QImage thumbnail = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (thumbnail.isNull())
throw ExportError(ExportError::Kind::render);
return ImportedImage(image, thumbnail, QStringLiteral("Layer Mask"));
}
std::optional<LayerTransform> LayerMask::placementMovingLayer(const LayerTransform &old,
const LayerTransform &updated) const
{
// A uniform mask looks the same wherever it sits.
if (asset.size().width() <= 1 && asset.size().height() <= 1)
return std::nullopt;
std::optional<LayerTransform> moved;
if (!isLinked)
moved = placement.value_or(old);
else if (placement)
moved = placement->following(old, updated);
if (moved && moved->samePlacement(updated))
return std::nullopt;
return moved;
}
double LayerMask::background(const QImage &thumbnail)
{
const int width = thumbnail.width(), height = thumbnail.height();
if (width <= 0 || height <= 0)
return 1;
if (!isValid(thumbnail))
throw std::logic_error("a mask thumbnail must be 8-bit gray");
int total = 0, count = 0;
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
if (y != 0 && y != height - 1 && x != 0 && x != width - 1)
continue;
total += thumbnail.constScanLine(y)[x];
count += 1;
}
}
return total * 2 >= count * 255 ? 1 : 0;
}
QImage LayerMask::placed(int width, int height, const LayerTransform &layer, const LayerTransform &placement,
int maskWidth, int maskHeight, double background,
const std::function<void(QPainter &)> &compose)
{
QImage surface = BrushRaster::context(width, height, true);
surface.fill(qRound(background * 255));
QPainter context(&surface);
context.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
context.setTransform(BrushRaster::pixelToDocument(placement, maskWidth, maskHeight)
* BrushRaster::pixelToDocument(layer, width, height).inverted());
compose(context);
context.end();
return surface;
}
void LayerMask::drawSmooth(const QImage &image, const QRectF &rect, QPainter &context)
{
context.save();
context.setRenderHint(QPainter::SmoothPixmapTransform, true);
context.drawImage(rect, image);
context.restore();
}
std::optional<QImage> LayerMask::clipImage(const std::optional<LayerTransform> &placement, const LayerTransform &layer,
int width, int height, std::optional<double> limit) const
{
const std::optional<QImage> image = enabledImage();
if (!image)
return std::nullopt;
if (!placement || placement->samePlacement(layer) || width <= 0 || height <= 0)
return image;
const double factor = limit ? std::min(1.0, std::max(1.0, *limit) / std::max(width, height)) : 1;
const int w = std::max(1, int(std::ceil(width * factor))), h = std::max(1, int(std::ceil(height * factor)));
return MaskPlacementCache::shared().image(*image, *placement, layer, w, h, [&]() -> std::optional<QImage> {
// Drawn from a sharp halving near the covered size.
const double covered = placement->size.width() / std::max(1.0, layer.size.width()) * w;
const QImage source = DownsampleCache::shared().imageDrawnAt(*image, covered / image->width());
try {
return placed(w, h, layer, *placement, image->width(), image->height(), background(asset.thumbnail),
[&](QPainter &context) { drawSmooth(source, QRectF(0, 0, image->width(), image->height()), context); });
} catch (const ExportError &error) {
qCWarning(lcRendering) << "a placed mask could not be rendered:" << error.what();
return std::nullopt;
}
});
}
MaskPlacementCache &MaskPlacementCache::shared()
{
static MaskPlacementCache cache;
return cache;
}
std::optional<QImage> MaskPlacementCache::image(const QImage &mask, const LayerTransform &placement,
const LayerTransform &layer, int width, int height,
const std::function<std::optional<QImage>()> &build)
{
{
const std::lock_guard<std::mutex> guard(m_lock);
m_clock += 1;
for (Entry &entry : m_entries) {
if (entry.mask.cacheKey() == mask.cacheKey() && entry.placement == placement && entry.layer == layer
&& entry.width == width && entry.height == height) {
entry.lastUse = m_clock;
return entry.image;
}
}
}
const std::optional<QImage> image = build();
if (!image)
return std::nullopt;
constexpr qint64 pixelBudget = 64'000'000;
if (qint64(width) * height > pixelBudget)
return image;
const std::lock_guard<std::mutex> guard(m_lock);
m_entries.push_back({mask, placement, layer, width, height, *image, m_clock});
const auto pixels = [&] {
qint64 total = 0;
for (const Entry &entry : m_entries)
total += qint64(entry.width) * entry.height;
return total;
};
while (m_entries.size() > 8 || pixels() > pixelBudget) {
m_entries.erase(std::min_element(m_entries.begin(), m_entries.end(),
[](const Entry &lhs, const Entry &rhs) { return lhs.lastUse < rhs.lastUse; }));
}
return image;
}
void FolderMaskClip::apply(QPointF center, const QPainter &context, QImage &coverage, double scale) const
{
const double width = transform.size.width() * scale, height = transform.size.height() * scale;
QImage veil(coverage.size(), QImage::Format_Alpha8);
if (veil.isNull())
throw ExportError(ExportError::Kind::render);
veil.fill(0);
{
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
placement.scale(transform.flipX ? -1 : 1, transform.flipY ? -1 : 1);
QPainter masking(&veil);
masking.setRenderHint(QPainter::SmoothPixmapTransform, quality(transform.sampling) != InterpolationQuality::none);
masking.setRenderHint(QPainter::Antialiasing, true);
masking.setTransform(placement * context.deviceTransform());
masking.drawImage(QRectF(-width / 2, -height / 2, width, height), BrushRaster::alphaView(image));
}
// A failed copy of shared coverage must not pass.
QPainter multiplying(&coverage);
if (!multiplying.isActive())
throw ExportError(ExportError::Kind::render);
multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn);
multiplying.drawImage(QRectF(coverage.rect()), veil);
}
void FolderMaskClip::draw(const std::vector<QUuid> &ids, const std::function<std::optional<QUuid>(QUuid)> &parent,
const std::function<std::optional<Applier>(QUuid)> &clip, const QPainter &context,
const std::function<void(QUuid, const QImage &clip)> &drawLayer)
{
// Coverage through a folder and its ancestors; null for none.
QHash<QUuid, QImage> covered;
std::function<QImage(std::optional<QUuid>, int)> through = [&](std::optional<QUuid> folder, int depth) -> QImage {
if (!folder || depth >= 64)
return QImage();
if (const auto found = covered.constFind(*folder); found != covered.constEnd())
return *found;
QImage coverage = through(parent(*folder), depth + 1);
if (const std::optional<Applier> applier = clip(*folder)) {
if (coverage.isNull()) {
coverage = QImage(context.device()->width(), context.device()->height(), QImage::Format_Alpha8);
if (coverage.isNull())
throw ExportError(ExportError::Kind::render);
coverage.fill(255);
}
(*applier)(context, coverage);
}
covered.insert(*folder, coverage);
return coverage;
};
for (const QUuid &id : ids)
drawLayer(id, through(parent(id), 0));
}
LayerTransform ImageLayer::maskTransform() const
{
return mask && mask->placement ? *mask->placement : transform;
}
bool EditorSession::canEditMask() const
{
return canEditLayers() && m_selectedLayerIDs.size() == 1 && activeLayer().has_value();
}
void EditorSession::selectLayerTarget(QUuid id, bool mask)
{
dropEffectSelection();
if (m_isProjectBusy || m_isImporting || m_brushStroke)
return;
resolveGradient();
selectLayer(id);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = mask && active && active->mask;
notify();
}
void EditorSession::addMask(bool revealing)
{
const std::optional<DocumentSelection> current = selection();
if (!current) {
addLayerMask(revealing);
return;
}
const std::optional<ImageLayer> active = activeLayer();
if (!canEditMask() || active->mask)
return;
const int index = indexOf(m_document->layers, active->id);
// Mask pixels cover the layer's own pixel grid.
const int width = active->asset ? active->asset->size().width() : int(std::round(active->size().width()));
const int height = active->asset ? active->asset->size().height() : int(std::round(active->size().height()));
try {
if (width <= 0 || height <= 0 || qint64(width) * height > 100'000'000)
throw ProjectError(ProjectError::Kind::tooLarge);
QImage image = BrushRaster::context(width, height, true);
image.fill(revealing ? Qt::white : Qt::black);
const QTransform toPixels = BrushRaster::pixelToDocument(active->transform, width, height).inverted();
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing, current->antialiased);
painter.fillPath(toPixels.map(current->path), revealing ? Qt::black : Qt::white);
const LayerMask mask(LayerMask::assetFrom(image));
finishOpacityEdit();
beginEdit(QStringLiteral("Add Mask from Selection"));
m_document->layers[size_t(index)].mask = mask;
m_document->selection = std::nullopt;
m_isMaskSelected = true;
endEdit();
} catch (const std::runtime_error &error) {
qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what();
setBrushError(QString::fromUtf8(error.what()));
}
}
void EditorSession::addLayerMask(bool revealing)
{
const std::optional<ImageLayer> active = activeLayer();
if (!canEditMask() || active->mask)
return;
finishOpacityEdit();
beginEdit(revealing ? QStringLiteral("Add Reveal-All Mask") : QStringLiteral("Add Hide-All Mask"));
for (ImageLayer &layer : m_document->layers) {
if (layer.id == active->id)
layer.mask = LayerMask::solid(revealing);
}
m_isMaskSelected = true;
endEdit();
}
void EditorSession::toggleLayerMask()
{
const std::optional<ImageLayer> active = activeLayer();
if (!canEditMask() || !active->mask)
return;
finishOpacityEdit();
beginEdit(active->mask->isEnabled ? QStringLiteral("Disable Layer Mask") : QStringLiteral("Enable Layer Mask"));
for (ImageLayer &layer : m_document->layers) {
if (layer.id == active->id)
layer.mask->isEnabled = !layer.mask->isEnabled;
}
endEdit();
}
void EditorSession::deleteLayerMask()
{
const std::optional<ImageLayer> active = activeLayer();
if (!canEditMask() || !active->mask)
return;
finishOpacityEdit();
beginEdit(QStringLiteral("Delete Layer Mask"));
for (ImageLayer &layer : m_document->layers) {
if (layer.id == active->id)
layer.mask = std::nullopt;
}
m_isMaskSelected = false;
endEdit();
}
bool EditorSession::canCopyMask(QUuid source, QUuid target) const
{
if (!canEditLayers() || source == target)
return false;
const auto holds = [&](QUuid id, bool asSource) {
return std::any_of(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &layer) {
return layer.id == id && (asSource ? layer.mask.has_value() : !layer.isGroup);
});
};
return holds(source, true) && holds(target, false);
}
void EditorSession::copyMask(QUuid source, QUuid target)
{
if (!canCopyMask(source, target))
return;
commitTransform();
const auto find = [&](QUuid id) {
return std::find_if(m_document->layers.begin(), m_document->layers.end(), [&](const ImageLayer &layer) { return layer.id == id; });
};
// The copy sits where the mask sits on the document.
LayerMask mask = *find(source)->mask;
mask.placement = find(source)->maskTransform();
beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask"));
find(target)->mask = mask;
selectLayer(target);
m_isMaskSelected = true;
endEdit();
}
void EditorSession::toggleMaskLink(QUuid id)
{
const auto holds = [&](const ImageLayer &layer) { return layer.id == id && layer.mask; };
if (!canEditLayers() || std::none_of(m_document->layers.begin(), m_document->layers.end(), holds))
return;
commitTransform();
const auto layer = std::find_if(m_document->layers.begin(), m_document->layers.end(), holds);
beginEdit(layer->mask->isLinked ? QStringLiteral("Unlink Layer Mask") : QStringLiteral("Link Layer Mask"));
layer->mask->isLinked = !layer->mask->isLinked;
endEdit();
}
std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageLayer &layer) const
{
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))
return layer.mask->placement.value_or(layer.transform);
if (m_transformEdit && m_transformEdit->group) {
const TransformGroup &group = *m_transformEdit->group;
const auto original = group.originals.constFind(layer.id);
if (original == group.originals.constEnd())
return layer.mask->placement;
if (m_transformEdit->corners)
return layer.mask->isLinked && !layer.mask->placement ? std::nullopt : std::optional(layer.mask->placement.value_or(layer.transform));
return layer.mask->placementMovingLayer(layer.transform, original->following(group.box, m_transformEdit->draft));
}
if (!m_transformEdit || m_transformEdit->layerID != layer.id)
return layer.mask->placement;
const LayerTransform &draft = m_transformEdit->draft;
if (m_transformEdit->mask)
return draft.samePlacement(layer.transform) ? std::nullopt : std::optional(draft);
// A distortion carries a linked layer's own mask; others stay.
if (m_transformEdit->corners)
return layer.mask->isLinked && !layer.mask->placement ? std::nullopt : std::optional(layer.mask->placement.value_or(layer.transform));
return layer.mask->placementMovingLayer(layer.transform, draft);
}
std::optional<QImage> EditorSession::maskDistortPreview(const ImageLayer &layer) const
{
const std::optional<TransformEdit> &edit = m_transformEdit;
if (!edit || !edit->mask || edit->layerID != layer.id || !edit->corners || !layer.mask || !layer.mask->isEnabled)
return std::nullopt;
const LayerMask &owned = *layer.mask;
if (m_maskDistortPreviewCache && m_maskDistortPreviewCache->corners == *edit->corners && m_maskDistortPreviewCache->draft == edit->draft
&& m_maskDistortPreviewCache->mask == owned.asset.identity() && m_maskDistortPreviewCache->layer == layer.transform)
return m_maskDistortPreviewCache->result;
const int width = layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width()));
const int height = layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()));
std::optional<QImage> result;
try {
const DistortWarp::Warped moved = DistortWarp::warpMask(owned.asset.image(), edit->draft, *edit->corners, LayerMask::background(owned.asset.thumbnail), 2048);
result = LayerMask(ImportedImage(moved.image, owned.asset.thumbnail, owned.asset.name)).clipImage(moved.transform, layer.transform, width, height, 2048);
} catch (const ProjectError &) {
} catch (const ExportError &) {
}
m_maskDistortPreviewCache = MaskDistortPreviewCache{*edit->corners, edit->draft, owned.asset.identity(), layer.transform, result};
return result;
}
void EditorSession::commitMaskTransform()
{
const TransformEdit edit = *m_transformEdit;
m_maskDistortPreviewCache.reset();
const int index = indexOf(m_document->layers, edit.layerID);
if (edit.corners && edit.draft.isValid() && index >= 0 && m_document->layers[index].mask) {
// A distorted mask is resampled; its background fills outside.
m_transformEdit = std::nullopt;
const ImageLayer layer = m_document->layers[index];
const LayerMask &mask = *layer.mask;
try {
const DistortWarp::Warped moved = DistortWarp::warpMask(mask.asset.image(), edit.draft, *edit.corners, LayerMask::background(mask.asset.thumbnail));
// Everything that can throw comes before the transaction opens.
const ImportedImage asset = moved.image.cacheKey() == mask.asset.image().cacheKey() ? mask.asset : LayerMask::assetFrom(moved.image);
beginEdit(QStringLiteral("Distort Layer Mask"));
m_document->layers[index].mask = LayerMask(asset, mask.isEnabled,
moved.transform.samePlacement(layer.transform) ? std::nullopt : std::optional(moved.transform), mask.isLinked);
endEdit();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
return;
}
// The mask takes the placement; its pixels are untouched.
beginEdit(QStringLiteral("Transform Layer Mask"));
for (ImageLayer &layer : m_document->layers) {
if (layer.id == edit.layerID && layer.mask && edit.draft.isValid())
layer.mask->placement = edit.draft.samePlacement(layer.transform) ? std::nullopt : std::optional(edit.draft);
}
m_transformEdit = std::nullopt;
endEdit();
}
// The stroke's mask, in the layer's grid, preview-sized.
std::optional<QImage> BrushStroke::placedMaskPreview(const LayerTransform &placement) const
{
const int gridWidth = layer.asset ? layer.asset->size().width() : int(std::round(layer.size().width()));
const int gridHeight = layer.asset ? layer.asset->size().height() : int(std::round(layer.size().height()));
const double factor = std::min(1.0, 2048.0 / std::max({1, gridWidth, gridHeight}));
const int w = std::max(1, int(std::ceil(gridWidth * factor))), h = std::max(1, int(std::ceil(gridHeight * factor)));
const std::optional<ImportedImage> &old = layer.mask ? std::optional(layer.mask->asset) : std::nullopt;
const double perMaskPixel = placement.size.width() / std::max(1, width) * w / std::max(1.0, layer.transform.size.width());
try {
return LayerMask::placed(w, h, layer.transform, placement, width, height, old ? LayerMask::background(old->thumbnail) : 1, [&](QPainter &context) {
if (old)
LayerMask::drawSmooth(DownsampleCache::shared().imageDrawnAt(old->image(), perMaskPixel), sourceRect, context);
for (const BrushPatch &patch : patches())
LayerMask::drawSmooth(patch.image, patch.rect, context);
});
} catch (const ExportError &error) {
qCWarning(lcRendering) << "the placed mask's preview could not be made:" << error.what();
return std::nullopt;
}
}
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include "Document/LayerTransform.h"
#include "IO/ImageImporter.h"
#include <QPainter>
#include <QUuid>
#include <functional>
#include <mutex>
#include <optional>
#include <vector>
// Layer-local coverage: white reveals, black hides, gray is soft.
struct LayerMask {
ImportedImage asset;
bool isEnabled = true;
// Set once the mask moves apart from its layer.
std::optional<LayerTransform> placement;
bool isLinked = true;
explicit LayerMask(ImportedImage asset, bool isEnabled = true,
std::optional<LayerTransform> placement = std::nullopt, bool isLinked = true);
std::optional<QImage> enabledImage() const;
friend bool operator==(const LayerMask &lhs, const LayerMask &rhs);
LayerMask replacing(ImportedImage asset) const;
static bool isValid(const QImage &image);
static LayerMask solid(bool revealing);
static ImportedImage assetFrom(const QImage &image);
std::optional<LayerTransform> placementMovingLayer(const LayerTransform &old, const LayerTransform &updated) const;
static double background(const QImage &thumbnail);
static QImage placed(int width, int height, const LayerTransform &layer, const LayerTransform &placement,
int maskWidth, int maskHeight, double background,
const std::function<void(QPainter &)> &compose);
static void drawSmooth(const QImage &image, const QRectF &rect, QPainter &context);
// The mask over a layer's pixel grid; nil while disabled.
std::optional<QImage> clipImage(const std::optional<LayerTransform> &placement, const LayerTransform &layer,
int width, int height, std::optional<double> limit = std::nullopt) const;
};
// The canvas's last preview of an unlinked mask distorted alone.
struct MaskDistortPreviewCache {
Corners corners;
LayerTransform draft;
ImageIdentity mask;
LayerTransform layer;
std::optional<QImage> result;
};
// Masks resampled into their layers' grids, least recently used dropped.
class MaskPlacementCache {
public:
static MaskPlacementCache &shared();
std::optional<QImage> image(const QImage &mask, const LayerTransform &placement, const LayerTransform &layer,
int width, int height, const std::function<std::optional<QImage>()> &build);
private:
struct Entry {
QImage mask;
LayerTransform placement;
LayerTransform layer;
int width;
int height;
QImage image;
quint64 lastUse;
};
std::vector<Entry> m_entries;
quint64 m_clock = 0;
std::mutex m_lock;
};
// A folder's mask, multiplied into every layer inside the folder.
struct FolderMaskClip {
QImage image;
LayerTransform transform;
using Applier = std::function<void(const QPainter &context, QImage &coverage)>;
// Multiplies the mask, placed like a layer's own, into coverage.
void apply(QPointF center, const QPainter &context, QImage &coverage, double scale = 1) const;
// Draws ids in order, each through its folders' masks.
static void draw(const std::vector<QUuid> &ids, const std::function<std::optional<QUuid>(QUuid)> &parent,
const std::function<std::optional<Applier>(QUuid)> &clip, const QPainter &context,
const std::function<void(QUuid, const QImage &clip)> &drawLayer);
};
+8
View File
@@ -0,0 +1,8 @@
// Swift's LayerMerge extension: selected layers made one.
public:
bool canMergeLayers() const;
QString mergeTitle() const;
void mergeLayers();
private:
std::optional<MergePlan> mergePlan() const;
+140
View File
@@ -0,0 +1,140 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/Filters.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
// What Ctrl+E merges, in stacking order, and its place.
std::optional<EditorSession::MergePlan> EditorSession::mergePlan() const
{
const std::optional<ImageLayer> active = activeLayer();
if (!canEditLayers() || !active)
return std::nullopt;
const std::vector<ImageLayer> &layers = m_document->layers;
if (m_selectedLayerIDs.size() > 1) {
// Several selected layers, with anything their folders hold.
QSet<QUuid> picked = m_selectedLayerIDs;
for (const QUuid &id : m_selectedLayerIDs)
picked.unite(descendantIDs(id));
std::vector<QUuid> ordered;
std::optional<ImageLayer> top;
bool pixels = false;
for (const ImageLayer &layer : layers) {
if (!picked.contains(layer.id))
continue;
ordered.push_back(layer.id);
pixels = pixels || !layer.isGroup;
if (m_selectedLayerIDs.contains(layer.id))
top = layer;
}
if (!pixels || !top)
return std::nullopt;
return MergePlan{ordered, picked, top->name, top->parentID, top->id, QStringLiteral("Merge Layers")};
}
if (active->isGroup) {
// A folder merges its contents, and the folder goes.
const QSet<QUuid> inside = descendantIDs(active->id);
std::vector<QUuid> ids;
bool pixels = false;
for (const ImageLayer &layer : layers) {
if (inside.contains(layer.id) || layer.id == active->id)
ids.push_back(layer.id);
pixels = pixels || (inside.contains(layer.id) && !layer.isGroup);
}
if (!pixels)
return std::nullopt;
return MergePlan{ids, QSet<QUuid>(ids.begin(), ids.end()), active->name, active->parentID, active->id, QStringLiteral("Merge Group")};
}
// One layer merges with the one beneath it.
for (int index = indexOf(layers, active->id) - 1; index >= 0; --index) {
const ImageLayer &below = layers[index];
if (below.parentID != active->parentID)
continue;
if (below.isGroup)
return std::nullopt;
return MergePlan{{below.id, active->id}, {below.id, active->id}, below.name, active->parentID, active->id, QStringLiteral("Merge Down")};
}
return std::nullopt;
}
bool EditorSession::canMergeLayers() const
{
return mergePlan().has_value();
}
QString EditorSession::mergeTitle() const
{
const std::optional<MergePlan> plan = mergePlan();
return plan ? plan->action : QStringLiteral("Merge Down");
}
// The layers as shown, baked into one pixel layer.
void EditorSession::mergeLayers()
{
commitTransform();
const std::optional<MergePlan> plan = mergePlan();
if (!plan)
return;
const std::vector<ImageLayer> &layers = m_document->layers;
const QSet<QUuid> kept(plan->ids.begin(), plan->ids.end());
// Only the merged layers, cut loose from the rest.
CanvasDocument flat(m_document->width, m_document->height);
flat.id = m_document->id;
flat.resolution = m_document->resolution;
for (const ImageLayer &layer : layers) {
if (!kept.contains(layer.id))
continue;
ImageLayer copy = layer;
if (copy.parentID && !kept.contains(*copy.parentID))
copy.parentID = std::nullopt;
if (copy.maskSourceID && !kept.contains(*copy.maskSourceID))
copy.maskSourceID = std::nullopt;
flat.layers.push_back(copy);
}
std::optional<ImageLayer> merged;
try {
QImage full = BrushRaster::context(m_document->width, m_document->height, false);
{
QPainter context(&full);
drawLiveComposite(flat, context);
}
const LayerTransform canvas{.origin = {0, 0}, .size = m_document->size()};
const PixelFilter::Trimmed trimmed = PixelFilter::trimmed(full, canvas);
// At 1:1 the trimmed size is the crop's: the constructor's.
merged = ImageLayer(ImportedImage(trimmed.image, PixelAdjust::thumbnail(trimmed.image), plan->name), trimmed.transform.origin);
} catch (const ExportError &error) {
qCWarning(lcRendering) << "merge: the layers could not be rendered:" << error.what();
return;
}
merged->parentID = plan->parent;
std::vector<ImageLayer> next;
int insertion = 0;
for (const ImageLayer &layer : layers) {
if (plan->removed.contains(layer.id))
continue;
next.push_back(layer);
// Layers clipped to anything merged now clip to the result.
if (next.back().maskSourceID && plan->removed.contains(*next.back().maskSourceID))
next.back().maskSourceID = merged->id;
}
for (const ImageLayer &layer : layers) {
if (layer.id == plan->anchor)
break;
insertion += plan->removed.contains(layer.id) ? 0 : 1;
}
next.insert(next.begin() + insertion, *merged);
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : next)
records.push_back(layer.hierarchyRecord());
try {
LayerHierarchy::validate(records);
} catch (const ProjectError &error) {
qCWarning(lcRendering) << "merge: the result is no valid hierarchy:" << error.what();
return;
}
beginEdit(plan->action);
m_document->layers = std::move(next);
setActiveLayerID(merged->id);
endEdit();
}
+282
View File
@@ -0,0 +1,282 @@
#include "Document/LayerTransform.h"
#include "Document/BrushStroke.h"
#include <algorithm>
#include <cmath>
#include <numbers>
#include <stdexcept>
namespace {
bool sameSize(QSizeF lhs, QSizeF rhs)
{
return lhs.width() == rhs.width() && lhs.height() == rhs.height();
}
struct Shift {
double move = 0;
std::optional<double> target;
};
Shift shift(const std::array<double, 3> &guides, const std::vector<double> &targets, double tolerance)
{
Shift best;
for (double guide : guides) {
for (double target : targets) {
const double move = target - guide;
if (std::abs(move) > tolerance)
continue;
if (best.target && std::abs(best.move) <= std::abs(move))
continue;
best = {move, target};
}
}
return best;
}
}
QString rawValue(LayerSampling sampling)
{
switch (sampling) {
case LayerSampling::nearest:
return QStringLiteral("Nearest");
case LayerSampling::smooth:
return QStringLiteral("Smooth");
case LayerSampling::high:
return QStringLiteral("High quality");
}
throw std::logic_error("unknown LayerSampling");
}
std::optional<LayerSampling> layerSampling(const QString &rawValue)
{
for (const LayerSampling sampling : {LayerSampling::nearest, LayerSampling::smooth, LayerSampling::high}) {
if (rawValue == ::rawValue(sampling))
return sampling;
}
return std::nullopt;
}
InterpolationQuality quality(LayerSampling sampling)
{
switch (sampling) {
case LayerSampling::nearest:
return InterpolationQuality::none;
case LayerSampling::smooth:
return InterpolationQuality::low;
case LayerSampling::high:
return InterpolationQuality::high;
}
throw std::logic_error("unknown LayerSampling");
}
const std::array<QPointF, 8> LayerTransform::handles = {
QPointF(0, 0), QPointF(0.5, 0), QPointF(1, 0), QPointF(1, 0.5),
QPointF(1, 1), QPointF(0.5, 1), QPointF(0, 1), QPointF(0, 0.5)};
bool operator==(const LayerTransform &lhs, const LayerTransform &rhs)
{
return lhs.origin.x() == rhs.origin.x() && lhs.origin.y() == rhs.origin.y()
&& sameSize(lhs.size, rhs.size) && lhs.rotation == rhs.rotation
&& lhs.flipX == rhs.flipX && lhs.flipY == rhs.flipY && lhs.sampling == rhs.sampling;
}
QPointF LayerTransform::center() const
{
return {origin.x() + size.width() / 2, origin.y() + size.height() / 2};
}
double LayerTransform::radians() const
{
return std::fmod(rotation, 360) * std::numbers::pi / 180;
}
bool LayerTransform::isValid() const
{
const std::array<double, 5> values = {origin.x(), origin.y(), size.width(), size.height(), rotation};
return std::all_of(values.begin(), values.end(), [](double value) { return std::isfinite(value); })
&& size.width() >= 1 && size.width() <= 300'000 && size.height() >= 1 && size.height() <= 300'000
&& std::abs(origin.x()) <= 1'000'000 && std::abs(origin.y()) <= 1'000'000;
}
QPointF LayerTransform::point(QPointF unit) const
{
const double x = (unit.x() - 0.5) * size.width(), y = (unit.y() - 0.5) * size.height();
const QPointF middle = center();
return {middle.x() + x * std::cos(radians()) - y * std::sin(radians()),
middle.y() + x * std::sin(radians()) + y * std::cos(radians())};
}
bool LayerTransform::contains(QPointF point) const
{
const double x = point.x() - center().x(), y = point.y() - center().y();
return std::abs(x * std::cos(radians()) + y * std::sin(radians())) <= size.width() / 2
&& std::abs(-x * std::sin(radians()) + y * std::cos(radians())) <= size.height() / 2;
}
double LayerTransform::scalePercent(QSizeF pixelSize) const
{
return size.width() / std::max(1.0, pixelSize.width()) * 100;
}
LayerTransform LayerTransform::scaled(double toPercent, QSizeF pixelSize) const
{
LayerTransform result = *this;
result.size = {pixelSize.width() * toPercent / 100, pixelSize.height() * toPercent / 100};
result.origin = {center().x() - result.size.width() / 2, center().y() - result.size.height() / 2};
return result;
}
LayerTransform LayerTransform::rounded() const
{
LayerTransform result = *this;
result.origin = {std::round(origin.x()), std::round(origin.y())};
result.size = {std::max(1.0, std::round(size.width())), std::max(1.0, std::round(size.height()))};
result.rotation = std::round(rotation);
return result;
}
QTransform LayerTransform::unitToDocument() const
{
return BrushRaster::pixelToDocument(*this, 1, 1);
}
LayerTransform LayerTransform::placing(const QTransform &map) const
{
const double sign = flipX ? -1 : 1;
const double angle = std::atan2(map.m12() * sign, map.m11() * sign);
const double along = -map.m21() * std::sin(angle) + map.m22() * std::cos(angle);
const QPointF middle = map.map(QPointF(0.5, 0.5));
LayerTransform result = *this;
result.size = {std::hypot(map.m11(), map.m12()), std::abs(along)};
const double degrees = angle * 180 / std::numbers::pi;
result.rotation = degrees + std::round((rotation - degrees) / 360) * 360;
result.flipY = along < 0;
result.origin = {middle.x() - result.size.width() / 2, middle.y() - result.size.height() / 2};
return result;
}
LayerTransform LayerTransform::following(const LayerTransform &old, const LayerTransform &updated) const
{
if (old == updated)
return *this;
if (sameSize(old.size, updated.size) && old.rotation == updated.rotation
&& old.flipX == updated.flipX && old.flipY == updated.flipY) {
LayerTransform moved = *this;
moved.origin += updated.origin - old.origin;
return moved;
}
return placing(unitToDocument() * old.unitToDocument().inverted() * updated.unitToDocument());
}
bool LayerTransform::samePlacement(const LayerTransform &other) const
{
LayerTransform copy = *this;
copy.sampling = other.sampling;
return copy == other;
}
std::optional<Corners> TransformDrag::corners(QPointF to, bool shift) const
{
if (!originalCorners)
return std::nullopt;
double dx = to.x() - start.x(), dy = to.y() - start.y();
if (shift) {
if (std::abs(dx) >= std::abs(dy))
dy = 0;
else
dx = 0;
}
std::vector<int> moved;
if (mode.kind == Kind::distort) {
const int corner = mode.index / 2;
moved = mode.index % 2 == 0 ? std::vector<int>{corner} : std::vector<int>{corner, (corner + 1) % 4};
} else if (mode.kind == Kind::move) {
moved = {0, 1, 2, 3};
} else {
return std::nullopt;
}
Corners result = *originalCorners;
for (int corner : moved)
result[corner] += QPointF(dx, dy);
return result;
}
LayerTransform TransformDrag::updated(QPointF to, bool lockRatio, bool shift, bool option) const
{
LayerTransform result = original;
const double radians = original.radians();
switch (mode.kind) {
case Kind::distort:
break;
case Kind::move: {
double dx = to.x() - start.x(), dy = to.y() - start.y();
if (shift) {
if (std::abs(dx) >= std::abs(dy))
dy = 0;
else
dx = 0;
}
result.origin += QPointF(dx, dy);
break;
}
case Kind::rotate: {
const QPointF center = original.center();
const double delta = std::atan2(to.y() - center.y(), to.x() - center.x())
- std::atan2(start.y() - center.y(), start.x() - center.x());
result.rotation += delta * 180 / std::numbers::pi;
if (shift)
result.rotation = std::round(result.rotation / 15) * 15;
break;
}
case Kind::resize: {
const QPointF handle = LayerTransform::handles[mode.index];
const QPointF anchorUnit = option ? QPointF(0.5, 0.5) : QPointF(1 - handle.x(), 1 - handle.y());
const QPointF anchor = original.point(anchorUnit);
const QPointF initialHandle = original.point(handle);
const double dx = initialHandle.x() + to.x() - start.x() - anchor.x();
const double dy = initialHandle.y() + to.y() - start.y() - anchor.y();
const double span = option ? 2 : 1;
const double localX = (dx * std::cos(radians) + dy * std::sin(radians)) * span;
const double localY = (-dx * std::sin(radians) + dy * std::cos(radians)) * span;
const double sx = handle.x() * 2 - 1, sy = handle.y() * 2 - 1;
const double originalWidth = original.size.width(), originalHeight = original.size.height();
const double rawWidth = sx == 0 ? originalWidth : localX * sx;
const double rawHeight = sy == 0 ? originalHeight : localY * sy;
const bool mirroredX = rawWidth < 0, mirroredY = rawHeight < 0;
double width = std::max(1.0, std::abs(rawWidth));
double height = std::max(1.0, std::abs(rawHeight));
if (lockRatio != shift) {
double factor;
if (sx == 0)
factor = height / originalHeight;
else if (sy == 0)
factor = width / originalWidth;
else
factor = std::max(1 / std::min(originalWidth, originalHeight),
(localX * sx * originalWidth + localY * sy * originalHeight)
/ (originalWidth * originalWidth + originalHeight * originalHeight));
width = originalWidth * factor;
height = originalHeight * factor;
}
result.size = {width, height};
if (mirroredX)
result.flipX = !result.flipX;
if (mirroredY)
result.flipY = !result.flipY;
const double offsetX = (0.5 - anchorUnit.x()) * width * (mirroredX ? -1 : 1);
const double offsetY = (0.5 - anchorUnit.y()) * height * (mirroredY ? -1 : 1);
const QPointF center(anchor.x() + offsetX * std::cos(radians) - offsetY * std::sin(radians),
anchor.y() + offsetX * std::sin(radians) + offsetY * std::cos(radians));
result.origin = {center.x() - width / 2, center.y() - height / 2};
break;
}
}
return result.isValid() ? result : original;
}
TransformSnap::Offset TransformSnap::offset(const QRectF &box, const std::vector<double> &xs,
const std::vector<double> &ys, double tolerance)
{
const Shift horizontal = shift({box.left(), box.center().x(), box.right()}, xs, tolerance);
const Shift vertical = shift({box.top(), box.center().y(), box.bottom()}, ys, tolerance);
return {QSizeF(horizontal.move, vertical.move), horizontal.target, vertical.target};
}
+111
View File
@@ -0,0 +1,111 @@
#pragma once
#include <QHash>
#include <QPointF>
#include <QRectF>
#include <QSizeF>
#include <QString>
#include <QTransform>
#include <QUuid>
#include <array>
#include <memory>
#include <optional>
#include <vector>
enum class LayerSampling { nearest, smooth, high };
// The name users see and the manifest stores.
QString rawValue(LayerSampling sampling);
std::optional<LayerSampling> layerSampling(const QString &rawValue);
// CoreGraphics interpolation qualities the renderer asks for.
enum class InterpolationQuality { none, low, high };
InterpolationQuality quality(LayerSampling sampling);
using Corners = std::array<QPointF, 4>;
// Unrotated document bounds; rotation is clockwise about the center.
struct LayerTransform {
QPointF origin;
QSizeF size;
double rotation = 0;
bool flipX = false;
bool flipY = false;
LayerSampling sampling = LayerSampling::high;
static const std::array<QPointF, 8> handles;
friend bool operator==(const LayerTransform &lhs, const LayerTransform &rhs);
QPointF center() const;
double radians() const;
bool isValid() const;
QPointF point(QPointF unit) const;
bool contains(QPointF point) const;
double scalePercent(QSizeF pixelSize) const;
LayerTransform scaled(double toPercent, QSizeF pixelSize) const;
LayerTransform rounded() const;
QTransform unitToDocument() const;
LayerTransform placing(const QTransform &map) const;
LayerTransform following(const LayerTransform &old, const LayerTransform &updated) const;
bool samePlacement(const LayerTransform &other) const;
// Mirrored across a line at `axis`; defined in LayerFlip.cpp.
LayerTransform mirrored(bool horizontally, double axis) const;
};
// Several layers under one box; each follows it.
struct TransformGroup {
LayerTransform box;
QHash<QUuid, LayerTransform> originals;
};
// A pending transform: the layers change on commit.
struct FloatingTransform;
struct TransformEdit {
QUuid layerID;
LayerTransform draft;
std::optional<TransformGroup> group;
// An unlinked mask, selected: the edit places it alone.
bool mask = false;
// A drag's edit ends with it; Ctrl+T's waits for Apply.
bool persistent = true;
// Set once a handle is Ctrl-dragged: the corners move freely.
std::optional<Corners> corners;
// Ctrl+T on selected pixels: they float, then merge back.
std::shared_ptr<const FloatingTransform> floating;
};
// Where a move has just snapped: guides while it lasts.
struct SnapGuides {
std::vector<double> xs;
std::vector<double> ys;
friend bool operator==(const SnapGuides &, const SnapGuides &) = default;
};
struct TransformDrag {
enum class Kind { move, resize, rotate, distort };
struct Mode {
Kind kind;
int index = 0;
};
LayerTransform original;
QPointF start;
Mode mode;
std::optional<Corners> originalCorners;
std::optional<Corners> corners(QPointF to, bool shift = false) const;
LayerTransform updated(QPointF to, bool lockRatio, bool shift, bool option = false) const;
};
namespace TransformSnap {
// Screen points within which a guide snaps.
inline constexpr double distance = 10;
struct Offset {
QSizeF offset;
std::optional<double> x;
std::optional<double> y;
};
Offset offset(const QRectF &box, const std::vector<double> &xs, const std::vector<double> &ys,
double tolerance);
}
+17
View File
@@ -0,0 +1,17 @@
// Swift's Levels extension: one Levels edit at a time.
public:
void beginLevels();
void updateLevels(const LevelsSettings &settings, bool preview);
// The sheet's eyedroppers: the point a click sets, or none.
void setLevelsSampleMode(std::optional<LevelsSample> mode);
void cancelLevels();
// Off the UI thread; `done` follows the step or refusal.
void commitLevels(std::function<void()> done = {});
private:
// Off the UI thread, for the open edit.
void countLevelsHistogram();
void renderLevelsPreview();
void finishLevelsPreview();
void finishLevelsHistogram();
void finishLevelsCommit();
+369
View File
@@ -0,0 +1,369 @@
#include "Document/Levels.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/RasterSnapshot.h"
#include <QPainter>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <vector>
extern "C" {
#include "LevelsPixels.h"
}
QString rawValue(LevelsChannel channel)
{
switch (channel) {
case LevelsChannel::rgb: return QStringLiteral("RGB");
case LevelsChannel::red: return QStringLiteral("Red");
case LevelsChannel::green: return QStringLiteral("Green");
case LevelsChannel::blue: return QStringLiteral("Blue");
}
throw std::logic_error("no such channel");
}
namespace {
double clamped(double value, double low, double high, double fallback)
{
return std::isfinite(value) ? std::min(high, std::max(low, value)) : fallback;
}
}
LevelRange LevelRange::normalized() const
{
LevelRange result = *this;
result.black = clamped(black, 0, 254, 0);
result.white = clamped(white, result.black + 1, 255, 255);
result.gamma = clamped(gamma, 0.1, 9.99, 1);
result.outputBlack = clamped(outputBlack, 0, 255, 0);
result.outputWhite = clamped(outputWhite, 0, 255, 255);
return result;
}
double LevelRange::apply(double value) const
{
const LevelRange s = normalized();
const double input = std::min(1.0, std::max(0.0, (value * 255 - s.black) / (s.white - s.black)));
return (s.outputBlack + std::pow(input, 1 / s.gamma) * (s.outputWhite - s.outputBlack)) / 255;
}
LevelRange LevelsSettings::current() const
{
return ranges[size_t(channel)];
}
void LevelsSettings::setCurrent(const LevelRange &range)
{
ranges[size_t(channel)] = range.normalized();
}
bool LevelsSettings::isIdentity() const
{
return std::all_of(ranges.begin(), ranges.end(), [](const LevelRange &range) { return range.normalized() == LevelRange(); });
}
double LevelsSettings::apply(double value, LevelsChannel channel) const
{
return ranges[0].apply(ranges[size_t(channel)].apply(value));
}
double LevelsHistogramDisplay::scale(const std::array<double, 256> &bins)
{
const auto counted = [](double bin) { return std::isfinite(bin) && bin > 0; };
double peak = 0;
for (const double bin : bins) {
if (counted(bin))
peak = std::max(peak, bin);
}
// The ends and lone spikes cap at four typical peaks.
std::vector<double> interior;
std::copy_if(bins.begin() + 1, bins.end() - 1, std::back_inserter(interior), counted);
if (interior.empty())
return peak;
std::sort(interior.begin(), interior.end());
const double typicalPeak = interior[size_t(double(interior.size() - 1) * 0.95)];
return std::min(peak, typicalPeak * 4);
}
namespace {
// The job's image in a premultiplied context, rows packed.
QImage drawn(const QImage &image)
{
QImage context = BrushRaster::context(image.width(), image.height(), false);
QPainter painter(&context);
BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return context;
}
}
QImage LevelsFilter::run(const LevelsJob &job)
{
if (job.settings.isIdentity())
return job.image;
QImage context = drawn(job.image);
// On the stack: a heap failure would escape the catch.
std::array<float, 3 * 256> tables;
for (size_t channel = 0; channel < 3; ++channel) {
for (size_t value = 0; value <= 255; ++value)
tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
}
// The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice).
levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data());
if (job.selection)
return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false);
return context;
}
LevelsHistogram LevelsFilter::histogram(const LevelsJob &job)
{
const int width = job.image.width(), height = job.image.height();
const QImage context = drawn(job.image);
const std::optional<QImage> mask = job.selection ? std::optional(PixelAdjust::coverage(*job.selection, width, height, job.mapping)) : std::nullopt;
// The kernel accumulates: row by row, since Qt pads masks.
std::array<double, 1024> bins{};
for (int y = 0; y < height; ++y)
levels_histogram(context.constScanLine(y), mask ? mask->constScanLine(y) : nullptr, size_t(width), bins.data());
LevelsHistogram result{};
for (size_t channel = 0; channel < 4; ++channel)
std::copy_n(bins.begin() + ptrdiff_t(channel * 256), 256, result[channel].begin());
return result;
}
LevelsEdit::LevelsEdit(const ImageLayer &layer, std::optional<SelectionClip> selection)
: layerID(layer.id), original(layer.asset.value()), transform(layer.transform), selection(std::move(selection)),
mapping(BrushRaster::pixelToDocument(transform, original.size().width(), original.size().height()))
{
const QSize size = original.size();
// A lookup a pixel is quick; smaller copies looked coarse.
const double factor = std::min(1.0, 8000.0 / std::max(size.width(), size.height()));
if (factor < 1) {
const int width = std::max(1, int(size.width() * factor)), height = std::max(1, int(size.height() * factor));
previewSource = BrushRaster::context(width, height, false);
QPainter painter(&previewSource);
const QRectF rect(0, 0, width, height);
if (original.raster)
original.raster->draw(rect, painter);
else
BrushRaster::draw(original.image(), rect, painter);
previewMapping = BrushRaster::pixelToDocument(transform, width, height);
} else {
previewSource = original.image();
previewMapping = mapping;
}
}
std::optional<QImage> LevelsEdit::previewImage(QUuid layer) const
{
return layer == layerID ? preparedPreview : std::nullopt;
}
LevelsJob LevelsEdit::previewJob() const
{
return LevelsJob{previewSource, settings, selection, previewMapping};
}
// Swift's session extension in Levels.swift.
void EditorSession::beginLevels()
{
// `canAdjustColors` reads `levels`, not `hueSaturation`.
if (m_hueSaturation || !canAdjustColors())
return;
// A pending gradient lands first, then Levels begins.
if (m_gradientEdit) {
commitGradient([this] { beginLevels(); });
return;
}
commitTransform();
cancelCrop();
cancelLasso();
const std::optional<DocumentSelection> current = selection();
try {
m_levels.emplace(activeLayer().value(), current ? std::optional(current->clip(m_document->size())) : std::nullopt);
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
return;
}
countLevelsHistogram();
notify();
}
void EditorSession::countLevelsHistogram()
{
const LevelsJob job = m_levels.value().previewJob();
m_levelsHistogram.setFuture(QtConcurrent::run([job]() -> std::optional<LevelsHistogram> {
try {
return LevelsFilter::histogram(job);
} catch (const ExportError &error) {
qCWarning(lcApp) << "the Levels histogram cannot be made:" << error.what();
return std::nullopt;
}
}));
}
void EditorSession::finishLevelsHistogram()
{
const std::optional<LevelsHistogram> result = m_levelsHistogram.result();
// A commit cancels it, as Swift's; new edits replace it.
if (!m_levels || m_levels->committing)
return;
// A failed count leaves the bins empty, as Swift's try?.
if (result)
m_levels->histogram = *result;
m_levels->histogramReady = true;
notify();
}
void EditorSession::updateLevels(const LevelsSettings &settings, bool preview)
{
if (!m_levels || m_levels->committing)
return;
LevelsEdit &edit = *m_levels;
edit.settings = settings;
edit.preview = preview;
// An adjustment layer takes the settings; its pixels stay.
if (previewAdjustmentEditing(preview)) {
notify();
return;
}
if (!preview || settings.isIdentity()) {
// Swift cancels the running preview: its result drops.
m_levelsPreviewFor = QUuid();
edit.pending = std::nullopt;
edit.preparedPreview = std::nullopt;
++m_brushRevision;
notify();
return;
}
edit.pending = edit.previewJob();
renderLevelsPreview();
notify();
}
// Renders the newest job; one that arrives mid-render waits.
void EditorSession::renderLevelsPreview()
{
if (!m_levels || !m_levels->pending || m_levelsRendering)
return;
const LevelsJob job = *std::exchange(m_levels->pending, std::nullopt);
m_levelsPreviewFor = m_levels->id;
m_levelsRendering = true;
m_levelsPreview.setFuture(QtConcurrent::run([job]() -> std::optional<QImage> {
try {
return LevelsFilter::run(job);
} catch (const ExportError &error) {
qCWarning(lcApp) << "the Levels preview cannot be made:" << error.what();
return std::nullopt;
}
}));
}
void EditorSession::finishLevelsPreview()
{
m_levelsRendering = false;
const std::optional<QImage> result = m_levelsPreview.result();
// A cancelled run, or another edit's, lands nowhere.
if (m_levels && m_levels->id == std::exchange(m_levelsPreviewFor, QUuid())) {
m_levels->preparedPreview = result;
++m_brushRevision;
notify();
}
renderLevelsPreview();
}
void EditorSession::setLevelsSampleMode(std::optional<LevelsSample> mode)
{
if (!m_levels)
return;
m_levels->sampleMode = mode;
++m_brushRevision;
notify();
}
void EditorSession::cancelLevels()
{
if (finishAdjustmentEditing(false) || !m_levels || m_levels->committing)
return;
m_levels.reset();
++m_brushRevision;
resumeFileRequests();
notify();
}
void EditorSession::commitLevels(std::function<void()> done)
{
// The caller resumes from the event loop, as after await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
if (finishAdjustmentEditing(true) || !m_levels || m_levels->committing) {
finish();
return;
}
if (m_levels->settings.isIdentity()) {
cancelLevels();
finish();
return;
}
LevelsEdit &edit = *m_levels;
edit.committing = true;
// Swift cancels the preview: its result and queue drop.
m_levelsPreviewFor = QUuid();
edit.pending = std::nullopt;
m_committingLevels = Committing{edit.id, std::move(done)};
setIsProjectBusy(true);
// A painted layer flattens there, where a failure is caught.
m_levelsCommit.setFuture(QtConcurrent::run([original = edit.original, settings = edit.settings, selection = edit.selection,
mapping = edit.mapping]() -> Leveled {
try {
const QImage image = LevelsFilter::run(LevelsJob{original.image(), settings, selection, mapping});
return Leveled{ImportedImage(image, PixelAdjust::thumbnail(image), QStringLiteral("Levels")), std::nullopt};
} catch (const ExportError &error) {
return Leveled{std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishLevelsCommit()
{
const Leveled made = m_levelsCommit.result();
const LevelsEdit edit = std::exchange(m_levels, std::nullopt).value();
const Committing pending = std::exchange(m_committingLevels, std::nullopt).value();
// Swift's defer: the edit goes, the project frees, redraw.
const auto finish = [&] {
++m_brushRevision;
setIsProjectBusy(false);
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
};
if (made.failure) {
setBrushError(made.failure);
finish();
return;
}
const int index = m_document ? indexOf(m_document->layers, edit.layerID) : -1;
if (index < 0) {
finish();
return;
}
const ImageLayer current = m_document->layers[index];
if (!current.asset || current.asset->identity() != edit.original.identity() || current.transform != edit.transform) {
finish();
return;
}
beginEdit(QStringLiteral("Levels"));
ImageLayer layer = current;
layer.asset = made.asset;
// Swift rebuilds the layer without shape, effects and text.
layer.shape = std::nullopt;
layer.effects = std::nullopt;
layer.text = std::nullopt;
m_document->layers[index] = layer;
endEdit();
finish();
}
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include "Document/LayerTransform.h"
#include "Document/Selection.h"
#include "IO/ImageImporter.h"
#include <QImage>
#include <QTransform>
#include <QUuid>
#include <array>
#include <optional>
struct ImageLayer;
// Defined with the automatic levels, as Swift's.
enum class LevelsSample;
// The composite first, then each colour.
enum class LevelsChannel { rgb, red, green, blue };
inline constexpr std::array<LevelsChannel, 4> allLevelsChannels{LevelsChannel::rgb, LevelsChannel::red, LevelsChannel::green, LevelsChannel::blue};
// Swift's rawValue: the channel picker's title.
QString rawValue(LevelsChannel channel);
// One channel's input range, gamma and output range.
struct LevelRange {
double black = 0;
double gamma = 1;
double white = 255;
double outputBlack = 0;
double outputWhite = 255;
// Clamped into range; a value that is no number resets.
LevelRange normalized() const;
double apply(double value) const;
friend bool operator==(const LevelRange &, const LevelRange &) = default;
};
struct LevelsSettings {
LevelsChannel channel = LevelsChannel::rgb;
std::array<LevelRange, 4> ranges{};
LevelRange current() const;
// Stored normalized, as Swift's setter.
void setCurrent(const LevelRange &range);
bool isIdentity() const;
// The channel's range first, then the composite's.
double apply(double value, LevelsChannel channel) const;
// Swift's extension in LevelsAutomatic: a sample calibrates each channel.
LevelsSettings sampling(const std::array<double, 3> &rgb, LevelsSample mode) const;
friend bool operator==(const LevelsSettings &, const LevelsSettings &) = default;
};
// The composite's bins, then red's, green's and blue's.
using LevelsHistogram = std::array<std::array<double, 256>, 4>;
// Swift's LevelsHistogramDisplay: spikes cannot flatten the graph.
namespace LevelsHistogramDisplay {
double scale(const std::array<double, 256> &bins);
}
struct LevelsJob {
QImage image;
LevelsSettings settings;
std::optional<SelectionClip> selection;
QTransform mapping;
};
// Swift's LevelsFilter.
namespace LevelsFilter {
// The settings over the image, through the selection.
QImage run(const LevelsJob &job);
// Alpha-weighted; RGB is the mean of the channels, not luminance.
LevelsHistogram histogram(const LevelsJob &job);
}
// An open Levels edit: the layer's pixels, settings and preview.
class LevelsEdit {
public:
LevelsEdit(const ImageLayer &layer, std::optional<SelectionClip> selection);
// The prepared preview, for this edit's own layer.
std::optional<QImage> previewImage(QUuid layer) const;
LevelsJob previewJob() const;
// Swift's object identity: results land on their own edit.
const QUuid id = QUuid::createUuid();
const QUuid layerID;
const ImportedImage original;
const LayerTransform transform;
const std::optional<SelectionClip> selection;
const QTransform mapping;
// Full size to 8000 pixels a side, else scaled down.
QImage previewSource;
QTransform previewMapping;
std::optional<LevelsSample> sampleMode;
LevelsSettings settings;
bool preview = true;
bool committing = false;
LevelsHistogram histogram{};
bool histogramReady = false;
std::optional<QImage> preparedPreview;
std::optional<LevelsJob> pending;
};
+5
View File
@@ -0,0 +1,5 @@
// Swift's LevelsAutomatic extension: automatic settings and sampling.
public:
void autoLevels(LevelsAuto mode);
// The chosen eyedropper sets its point from the layer's pixel.
void sampleLevels(QPointF point);
+153
View File
@@ -0,0 +1,153 @@
#include "Document/LevelsAutomatic.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include <QPainter>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <stdexcept>
QString rawValue(LevelsSample sample)
{
switch (sample) {
case LevelsSample::black: return QStringLiteral("Black");
case LevelsSample::gray: return QStringLiteral("Gray");
case LevelsSample::white: return QStringLiteral("White");
}
throw std::logic_error("no such sample");
}
QString rawValue(LevelsAuto mode)
{
switch (mode) {
case LevelsAuto::contrast: return QStringLiteral("Contrast");
case LevelsAuto::color: return QStringLiteral("Color");
case LevelsAuto::neutral: return QStringLiteral("Color + neutral midtones");
}
throw std::logic_error("no such mode");
}
namespace {
// The bins holding all but a thousandth at either end.
std::optional<std::pair<double, double>> endpoints(const std::array<double, 256> &bins)
{
const double total = std::accumulate(bins.begin(), bins.end(), 0.0);
if (!(total > 0))
return std::nullopt;
double sum = 0;
int low = 0, high = 255;
for (int index = 0; index < 256; ++index) {
sum += bins[size_t(index)];
if (sum > total * 0.001) {
low = index;
break;
}
}
sum = 0;
for (int index = 255; index >= 0; --index) {
sum += bins[size_t(index)];
if (sum > total * 0.001) {
high = index;
break;
}
}
return low < high ? std::optional(std::pair(double(low), double(high))) : std::nullopt;
}
}
LevelsSettings settings(LevelsAuto mode, const LevelsHistogram &histogram)
{
LevelsSettings result;
if (mode == LevelsAuto::contrast) {
// A shared interval preserves channel relationships.
std::optional<double> low, high;
for (size_t channel = 1; channel < 4; ++channel) {
if (const std::optional<std::pair<double, double>> limits = endpoints(histogram[channel])) {
low = std::min(low.value_or(limits->first), limits->first);
high = std::max(high.value_or(limits->second), limits->second);
}
}
// Each channel's low lies below its high: so do theirs.
if (low)
result.ranges[0] = LevelRange{*low, 1, high.value(), 0, 255};
return result;
}
for (size_t channel = 1; channel < 4; ++channel) {
const std::optional<std::pair<double, double>> limits = endpoints(histogram[channel]);
if (!limits)
continue;
LevelRange range{limits->first, 1, limits->second, 0, 255};
if (mode == LevelsAuto::neutral) {
const double total = std::accumulate(histogram[channel].begin(), histogram[channel].end(), 0.0);
double weighted = 0;
for (size_t index = 0; index < 256; ++index)
weighted += range.apply(double(index) / 255) * histogram[channel][index];
// Weight at both ends keeps the mean inside, under 9.99.
range.gamma = std::max(0.1, std::log(weighted / total) / std::log(0.5));
}
result.ranges[channel] = range;
}
return result;
}
// Samples are unpremultiplied RGB; the channels calibrate together.
LevelsSettings LevelsSettings::sampling(const std::array<double, 3> &rgb, LevelsSample mode) const
{
LevelsSettings result = *this;
result.ranges[0] = LevelRange();
for (size_t channel = 1; channel < 4; ++channel) {
LevelRange range = result.ranges[channel];
const double value = rgb[channel - 1] * 255;
if (mode == LevelsSample::black) {
range.black = std::min(range.white - 1, value);
} else if (mode == LevelsSample::white) {
range.white = std::max(range.black + 1, value);
} else {
const double fraction = (value - range.black) / (range.white - range.black);
if (!(fraction > 0 && fraction < 1))
continue;
range.gamma = std::log(fraction) / std::log(0.5);
}
range.outputBlack = 0;
range.outputWhite = 255;
result.ranges[channel] = range.normalized();
}
return result;
}
// Swift's session extension in LevelsAutomatic.swift.
void EditorSession::autoLevels(LevelsAuto mode)
{
if (!m_levels || !m_levels->histogramReady || m_levels->committing)
return;
m_levels->sampleMode = std::nullopt;
updateLevels(::settings(mode, m_levels->histogram), m_levels->preview);
}
void EditorSession::sampleLevels(QPointF point)
{
// Positive bounds: a point that is no number samples nothing.
if (!m_levels || !m_levels->sampleMode || m_levels->committing || !m_document
|| !(point.x() >= 0 && point.y() >= 0 && point.x() < m_document->width && point.y() < m_document->height))
return;
const LevelsEdit &edit = *m_levels;
const QPointF pixel = edit.mapping.inverted().map(point);
const QSize size = edit.original.size();
if (!(pixel.x() >= 0 && pixel.y() >= 0 && pixel.x() < size.width() && pixel.y() < size.height()))
return;
try {
QImage context = BrushRaster::context(1, 1, false);
{
QPainter painter(&context);
BrushRaster::draw(edit.original.image(), QRectF(0, 0, 1, 1), painter, QRectF(std::floor(pixel.x()), std::floor(pixel.y()), 1, 1));
}
const uchar *bytes = context.constBits();
if (bytes[3] == 0)
return;
const auto channel = [bytes](int index) { return std::min(1.0, double(bytes[index]) / bytes[3]); };
updateLevels(edit.settings.sampling({channel(0), channel(1), channel(2)}, *edit.sampleMode), edit.preview);
} catch (const ExportError &error) {
setBrushError(QString::fromUtf8(error.what()));
}
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "Document/Levels.h"
// What a Levels eyedropper sets: black, gray or white point.
enum class LevelsSample { black, gray, white };
inline constexpr std::array<LevelsSample, 3> allLevelsSamples{LevelsSample::black, LevelsSample::gray, LevelsSample::white};
QString rawValue(LevelsSample sample);
// Swift's LevelsAuto: settings read from a histogram.
enum class LevelsAuto { contrast, color, neutral };
inline constexpr std::array<LevelsAuto, 3> allLevelsAutos{LevelsAuto::contrast, LevelsAuto::color, LevelsAuto::neutral};
QString rawValue(LevelsAuto mode);
LevelsSettings settings(LevelsAuto mode, const LevelsHistogram &histogram);
+22
View File
@@ -0,0 +1,22 @@
// Swift's LiveLayerMask extension: clipping and live masks.
public:
// The layers as the export draws them, in document pixels.
void drawLiveComposite(const CanvasDocument &document, QPainter &context, bool onSurface = false) const;
// A layer clipped to `source` shows through that layer's alpha.
bool canLinkMask(QUuid source, QUuid target) const;
bool linkMask(QUuid source, QUuid target);
void removeLiveMask(QUuid target);
// Alt-click clips a layer to its lower sibling, or frees.
bool canToggleClippingMask(QUuid id) const;
void toggleClippingMask(QUuid id);
static void adoptClipping(QUuid id, std::vector<ImageLayer> &layers);
static void releaseDetachedClipping(std::vector<ImageLayer> &layers);
// Asks: bake, unlink or cancel. False when nothing depends.
bool deleteWithLiveMaskChoice(const std::vector<QUuid> &ids);
using BakedImages = std::map<QUuid, ImportedImage>;
void finishDeletingLayer(QUuid id, const BakedImages &baked);
void finishDeletingLayers(const std::vector<QUuid> &ids, const BakedImages &baked);
private:
std::optional<QUuid> clippingBase(const ImageLayer &layer) const;
void finishBake();
+384
View File
@@ -0,0 +1,384 @@
#include "Document/LiveLayerMask.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/LayerEffects+Renderer.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/AdjustmentSurface.h"
#include "Rendering/LayerRenderer.h"
#include "Rendering/LiveMaskRenderer.h"
#include <QHash>
#include <QMessageBox>
#include <QPushButton>
#include <QSet>
#include <QtConcurrent>
#include <map>
#include <stdexcept>
void EditorSession::drawLiveComposite(const CanvasDocument &document, QPainter &context, bool onSurface) const
{
// Adjustments read the pixels beneath: a surface of their own.
if (!onSurface && std::any_of(document.layers.begin(), document.layers.end(), [](const ImageLayer &layer) { return layer.adjustment.has_value(); })) {
AdjustmentSurface::draw(context, [&](QPainter &surface) { drawLiveComposite(document, surface, true); });
return;
}
std::map<QUuid, ImageLayer> records;
for (const ImageLayer &layer : document.layers) {
if (!records.emplace(layer.id, layer).second)
throw std::logic_error("a document repeats a layer id");
}
const auto parent = [&](QUuid id) { return records.contains(id) ? records.at(id).parentID : std::nullopt; };
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.at(id).maskSourceID : std::nullopt; },
[&](QUuid id, QPainter &target, const QImage &clip) {
if (!records.contains(id) || !records.at(id).asset)
return;
const ImageLayer &layer = records.at(id);
const QImage image = layer.asset->image();
const LayerTransform transform = displayedTransform(layer);
const std::optional<QImage> mask =
layer.mask ? layer.mask->clipImage(displayedMaskPlacement(layer), transform, image.width(), image.height()) : std::nullopt;
// With effects the mask is in their image already.
if (const auto effects = LayerEffectsRenderer::cached(image, mask, layer.effects)) {
const LayerTransform grown = LayerEffectsRenderer::placed(transform, effects->image, effects->inset);
LayerRenderer::draw(effects->image, grown, grown.center(), target,
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .clip = clip});
return;
}
LayerRenderer::draw(image, transform, transform.center(), target,
{.opacity = layer.opacity, .blendMode = displayedBlendMode(layer), .mask = mask.value_or(QImage()), .clip = clip});
});
live.adjustment = [&](QUuid id) { return records.contains(id) ? records.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return records.at(id).opacity; };
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
const ImageLayer &layer = records.at(id);
if (const std::optional<QImage> image = layer.mask ? layer.mask->enabledImage() : std::nullopt)
FolderMaskClip{*image, layer.transform}.apply(layer.transform.center(), painter, coverage);
};
std::vector<QUuid> ids;
for (const ImageLayer &layer : document.renderLayers())
ids.push_back(layer.id);
live.prepareStacks(ids, parent, [&](QUuid id) { return records.contains(id) ? displayedBlendMode(records.at(id)) : LayerBlendMode::normal; });
FolderMaskClip::draw(ids, parent, [&](QUuid id) -> std::optional<FolderMaskClip::Applier> {
if (!records.contains(id) || !records.at(id).mask)
return std::nullopt;
const ImageLayer &folder = records.at(id);
const std::optional<QImage> image = folder.mask->enabledImage();
if (!image)
return std::nullopt;
const FolderMaskClip clip{*image, displayedTransform(folder)};
return [clip](const QPainter &painter, QImage &coverage) { clip.apply(clip.transform.center(), painter, coverage); };
}, context, [&](QUuid id, const QImage &clip) { live.drawComposite(id, context, clip); });
}
void LiveMaskGraph::validate(const std::vector<ProjectLayerRecord> &layers)
{
QHash<QUuid, const ProjectLayerRecord *> records;
for (const ProjectLayerRecord &layer : layers) {
if (records.contains(layer.id))
throw ProjectError(ProjectError::Kind::invalid);
records.insert(layer.id, &layer);
}
for (const ProjectLayerRecord &layer : layers) {
QSet<QUuid> path;
std::optional<QUuid> current = layer.id;
while (current) {
const ProjectLayerRecord *record = records.value(*current);
if (path.size() >= 256 || path.contains(*current) || !record)
throw ProjectError(ProjectError::Kind::invalid);
path.insert(*current);
if (record->maskSourceID) {
const ProjectLayerRecord *source = records.value(*record->maskSourceID);
// Folders never clip; nothing clips to an adjustment.
if (record->isGroup == true || !source || source->isGroup == true || source->adjustment)
throw ProjectError(ProjectError::Kind::invalid);
}
current = record->maskSourceID;
}
}
}
std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot, QUuid target)
{
const std::vector<ProjectLayerRecord> &layers = snapshot.manifest.layers;
const size_t found = std::find_if(layers.begin(), layers.end(), [&](const ProjectLayerRecord &layer) { return layer.id == target; }) - layers.begin();
if (found == layers.size() || !snapshot.images.contains(target))
return std::nullopt;
// Checked reads: a lost guard aborts or throws.
const ImportedImage &original = snapshot.images.at(target);
const int width = original.size().width(), height = original.size().height();
QImage surface = BrushRaster::context(width, height, false);
const QTransform inverse = BrushRaster::pixelToDocument(layers[found].transform, width, height).inverted();
// Swift's dictionary traps on an id that repeats.
QHash<QUuid, const ProjectLayerRecord *> records;
for (const ProjectLayerRecord &layer : layers) {
if (records.contains(layer.id))
throw std::logic_error("a snapshot to bake repeats a layer id");
records.insert(layer.id, &layer);
}
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.value(id)->maskSourceID : std::nullopt; },
[&](QUuid id, QPainter &context, const QImage &clip) {
// `at` is checked: end() would read wild memory.
if (!records.contains(id) || !snapshot.images.contains(id))
return;
const QImage pixels = snapshot.images.at(id).image();
// Only the live dependency bakes: mask and appearance stay.
if (id == target) {
LayerRenderer::draw(pixels, {.origin = {0, 0}, .size = QSizeF(width, height)}, QPointF(width / 2.0, height / 2.0),
context, {.clip = clip});
return;
}
const ProjectLayerRecord &layer = *records.value(id);
const std::optional<LayerMask> mask = snapshot.mask(layer);
const std::optional<QImage> shown =
mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt;
context.save();
context.setTransform(inverse, true);
LayerRenderer::draw(pixels, layer.transform, layer.transform.center(), context,
{.opacity = layer.opacity.value_or(1), .mask = shown.value_or(QImage()), .clip = clip});
context.restore();
});
{
QPainter painter(&surface);
live.draw(target, painter);
}
return ImportedImage(surface, PixelAdjust::thumbnail(surface), original.name);
}
bool EditorSession::canLinkMask(QUuid source, QUuid target) const
{
if (!canEditLayers())
return false;
bool found = false;
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : m_document->layers) {
records.push_back(layer.hierarchyRecord());
if (layer.id == target) {
records.back().maskSourceID = source;
found = true;
}
}
if (!found)
return false;
// The graph refuses itself, folders, strangers and rings.
try {
LiveMaskGraph::validate(records);
} catch (const ProjectError &) {
return false;
}
return true;
}
bool EditorSession::linkMask(QUuid source, QUuid target)
{
if (!canLinkMask(source, target))
return false;
ImageLayer &layer = m_document->layers[indexOf(m_document->layers, target)];
if (layer.maskSourceID == source)
return true;
beginEdit(QStringLiteral("Create Clipping Mask"));
layer.maskSourceID = source;
endEdit();
return true;
}
void EditorSession::adoptClipping(QUuid id, std::vector<ImageLayer> &layers)
{
const int moved = indexOf(layers, id);
if (moved < 0 || layers[moved].isGroup)
return;
std::vector<const ImageLayer *> siblings;
for (const ImageLayer &layer : layers) {
if (layer.parentID == layers[moved].parentID)
siblings.push_back(&layer);
}
const size_t own = std::find_if(siblings.begin(), siblings.end(), [&](const ImageLayer *layer) { return layer->id == id; }) - siblings.begin();
// Between a base or its stack and a clipped layer.
if (own == 0 || own + 1 == siblings.size())
return;
const std::optional<QUuid> source = siblings[own + 1]->maskSourceID;
const ImageLayer &below = *siblings[own - 1];
if (source && *source != id && (below.id == *source || below.maskSourceID == source))
layers[moved].maskSourceID = source;
}
void EditorSession::removeLiveMask(QUuid target)
{
if (!canEditLayers())
return;
const int clipped = indexOf(m_document->layers, target);
if (clipped < 0 || !m_document->layers[clipped].maskSourceID)
return;
// Those above it on the same base let go too.
const std::optional<QUuid> source = m_document->layers[clipped].maskSourceID, parent = m_document->layers[clipped].parentID;
beginEdit(QStringLiteral("Release Clipping Mask"));
bool reached = false;
for (ImageLayer &layer : m_document->layers) {
if (layer.parentID != parent)
continue;
reached = reached || layer.id == target;
if (reached && layer.id != target && layer.maskSourceID != source)
break;
if (reached)
layer.maskSourceID = std::nullopt;
}
endEdit();
}
bool EditorSession::canToggleClippingMask(QUuid id) const
{
if (!canEditLayers())
return false;
const std::vector<ImageLayer> &layers = m_document->layers;
const int layer = indexOf(layers, id);
if (layer < 0)
return false;
if (layers[layer].maskSourceID)
return true;
const std::optional<QUuid> base = clippingBase(layers[layer]);
return base && canLinkMask(*base, id);
}
std::optional<QUuid> EditorSession::clippingBase(const ImageLayer &layer) const
{
// The next lower sibling, or the base that one shares.
const ImageLayer *below = nullptr;
for (const ImageLayer &each : m_document->layers) {
if (each.id == layer.id)
break;
if (each.parentID == layer.parentID)
below = &each;
}
return below ? std::optional(below->maskSourceID.value_or(below->id)) : std::nullopt;
}
void EditorSession::toggleClippingMask(QUuid id)
{
if (!canEditLayers())
return;
const std::vector<ImageLayer> &layers = m_document->layers;
const int layer = indexOf(layers, id);
if (layer < 0)
return;
if (layers[layer].maskSourceID) {
removeLiveMask(id);
return;
}
if (const std::optional<QUuid> base = clippingBase(layers[layer]))
linkMask(*base, id);
}
void EditorSession::releaseDetachedClipping(std::vector<ImageLayer> &layers)
{
// A clipped layer sits in one run above its base.
std::map<std::optional<QUuid>, std::optional<QUuid>> bases;
for (ImageLayer &layer : layers) {
std::optional<QUuid> &base = bases[layer.parentID];
if (!layer.maskSourceID) {
base = layer.id;
} else if (layer.maskSourceID != base) {
layer.maskSourceID = std::nullopt;
base = layer.id;
}
}
}
bool EditorSession::deleteWithLiveMaskChoice(const std::vector<QUuid> &ids)
{
if (!m_document)
return false;
QSet<QUuid> removed;
for (const QUuid id : ids) {
removed.unite(descendantIDs(id));
removed.insert(id);
}
std::vector<QUuid> targets;
for (const ImageLayer &layer : m_document->layers) {
if (!removed.contains(layer.id) && layer.maskSourceID && removed.contains(*layer.maskSourceID))
targets.push_back(layer.id);
}
if (targets.empty())
return false;
QMessageBox alert;
alert.setIcon(QMessageBox::Warning);
alert.setText(ids.size() == 1 ? QStringLiteral("This layer supplies a live mask") : QStringLiteral("These layers supply live masks"));
alert.setInformativeText(QStringLiteral("Bake keeps the current masked appearance in the dependent layers’ pixels. "
"Remove Links reveals their pixels. You can undo either choice."));
const QPushButton *bake = alert.addButton(QStringLiteral("Bake and Delete"), QMessageBox::AcceptRole);
alert.addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole);
const QPushButton *unlink = alert.addButton(QStringLiteral("Remove Links and Delete"), QMessageBox::DestructiveRole);
alert.exec();
if (alert.clickedButton() == unlink) {
finishDeletingLayers(ids, {});
return true;
}
const std::optional<ProjectSnapshot> snapshot = projectSnapshot();
if (alert.clickedButton() != bake || !snapshot)
return true;
qCInfo(lcApp) << "baking" << int(targets.size()) << "live masks before a deletion";
setIsProjectBusy(true);
m_bakingIDs = ids;
m_baker.setFuture(QtConcurrent::run([snapshot = *snapshot, targets]() -> Baked {
Baked result;
try {
for (const QUuid target : targets) {
if (const std::optional<ImportedImage> image = LiveMaskBaker::bake(snapshot, target))
result.images.insert({target, *image});
}
} catch (const ExportError &error) {
result.failure = QString::fromUtf8(error.what());
}
return result;
}));
return true;
}
void EditorSession::finishBake()
{
const Baked baked = m_baker.result();
if (baked.failure) {
qCWarning(lcApp).noquote() << "cannot bake the live masks:" << *baked.failure;
setBrushError(baked.failure);
} else {
finishDeletingLayers(m_bakingIDs, baked.images);
}
setIsProjectBusy(false);
}
void EditorSession::finishDeletingLayer(QUuid id, const BakedImages &baked)
{
if (!m_document)
return;
std::vector<ImageLayer> &layers = m_document->layers;
const int index = indexOf(layers, id);
if (index < 0)
return;
QSet<QUuid> removed = descendantIDs(id);
removed.insert(id);
beginEdit(QStringLiteral("Delete Layer"));
std::erase_if(layers, [&](const ImageLayer &layer) { return removed.contains(layer.id); });
// Who was clipped to them is freed, baked where asked.
for (ImageLayer &layer : layers) {
if (!layer.maskSourceID || !removed.contains(*layer.maskSourceID))
continue;
layer.maskSourceID = std::nullopt;
if (const auto image = baked.find(layer.id); image != baked.end())
layer.asset = image->second;
}
if (m_activeLayerID && removed.contains(*m_activeLayerID))
setActiveLayerID(layers.empty() ? std::nullopt : std::optional(layers[std::min(index, int(layers.size()) - 1)].id));
endEdit();
}
void EditorSession::finishDeletingLayers(const std::vector<QUuid> &ids, const BakedImages &baked)
{
if (ids.size() <= 1) {
if (!ids.empty())
finishDeletingLayer(ids.front(), baked);
return;
}
beginEdit(QStringLiteral("Delete Layers"));
for (const QUuid id : ids)
finishDeletingLayer(id, baked);
endEdit();
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "IO/ProjectStore.h"
#include <vector>
namespace LiveMaskGraph {
void validate(const std::vector<ProjectLayerRecord> &layers);
}
namespace LiveMaskBaker {
// The target's pixels through its live mask; nil without pixels.
std::optional<ImportedImage> bake(const ProjectSnapshot &snapshot, QUuid target);
}
+8
View File
@@ -0,0 +1,8 @@
// Swift's MagicWand extension.
public:
// Off the UI thread; `done` follows the step or refusal.
void magicWand(QPointF point, SelectionMode mode, std::function<void()> done = {});
private:
void finishWand();
std::optional<QImage> wandSample(const CanvasDocument &document) const;
+174
View File
@@ -0,0 +1,174 @@
#include "Document/MagicWand.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <cstdlib>
extern "C" {
#include "WandPixels.h"
}
QString title(WandSampleSize size)
{
switch (size) {
case WandSampleSize::point:
return QStringLiteral("Point Sample");
case WandSampleSize::threeByThree:
return QStringLiteral("3 by 3 Average");
case WandSampleSize::fiveByFive:
return QStringLiteral("5 by 5 Average");
}
throw std::logic_error("unknown sample size");
}
int radius(WandSampleSize size)
{
return int(size);
}
MagicWandError::MagicWandError(Kind kind)
: std::runtime_error(kind == Kind::tooDetailed
? "That selection is too detailed to outline. Try a different Tolerance, or turn on Contiguous."
: "There isn’t enough memory to make that selection."),
kind(kind)
{
}
std::optional<QPainterPath> MagicWand::select(const QImage &image, QPointF point, const WandSettings &settings)
{
const int width = image.width(), height = image.height();
if (!std::isfinite(point.x()) || !std::isfinite(point.y()))
return std::nullopt;
const double fx = std::floor(point.x()), fy = std::floor(point.y());
if (fx < 0 || fy < 0 || fx >= width || fy >= height)
return std::nullopt;
// Premultiplied RGBA, rows top down, as the kernel reads.
QImage rgba = BrushRaster::context(width, height, false);
QPainter painter(&rgba);
BrushRaster::draw(image, QRectF(0, 0, width, height), painter);
painter.end();
std::vector<uchar> selected(size_t(width) * size_t(height));
const long count = wand_mask(rgba.constBits(), size_t(width), size_t(height), size_t(rgba.bytesPerLine()), size_t(fx), size_t(fy),
size_t(radius(settings.sampleSize)), std::clamp(settings.tolerance, 0, 255), settings.contiguous ? 1 : 0, selected.data());
if (count < 0)
throw MagicWandError(MagicWandError::Kind::memory);
if (count == 0)
return std::nullopt;
return outline(selected, width, height);
}
std::optional<QPainterPath> MagicWand::outline(const std::vector<uchar> &mask, int width, int height)
{
if (width <= 0 || height <= 0 || mask.size() != size_t(width) * size_t(height))
return std::nullopt;
int32_t *points = nullptr, *loops = nullptr;
size_t pointCount = 0, loopCount = 0;
const int status = wand_trace(mask.data(), size_t(width), size_t(height), &points, &pointCount, &loops, &loopCount);
const std::unique_ptr<int32_t, decltype(&std::free)> ownedPoints(points, &std::free), ownedLoops(loops, &std::free);
if (status == -2)
throw MagicWandError(MagicWandError::Kind::tooDetailed);
if (status != 0)
throw MagicWandError(MagicWandError::Kind::memory);
if (loopCount == 0)
return std::nullopt;
// Outer loops clockwise, holes the other way: winding fills them.
QPainterPath path;
path.setFillRule(Qt::WindingFill);
size_t index = 0;
for (size_t loop = 0; loop < loopCount; ++loop) {
const size_t length = size_t(loops[loop]);
path.moveTo(points[index * 2], points[index * 2 + 1]);
for (size_t corner = index + 1; corner < index + length; ++corner)
path.lineTo(points[corner * 2], points[corner * 2 + 1]);
path.closeSubpath();
index += length;
}
return path;
}
void EditorSession::setWandSettings(const WandSettings &settings)
{
m_wandSettings = settings;
notify();
}
// What the wand reads: composite, or the active layer's pixels.
std::optional<QImage> EditorSession::wandSample(const CanvasDocument &document) const
{
try {
QImage context = BrushRaster::context(document.width, document.height, false);
QPainter painter(&context);
// A painted asset flattens here; the catch covers it too.
if (m_wandSettings.sampleAllLayers) {
drawLiveComposite(document, painter);
} else if (const std::optional<ImageLayer> layer = activeLayer(); layer && !layer->isGroup && layer->asset) {
const LayerTransform transform = displayedTransform(*layer);
LayerRenderer::draw(layer->asset->image(), transform, transform.center(), painter, {});
}
painter.end();
return context;
} catch (const ExportError &error) {
qCWarning(lcApp) << "the wand cannot sample the canvas:" << error.what();
return std::nullopt;
}
}
void EditorSession::magicWand(QPointF point, SelectionMode mode, std::function<void()> done)
{
// The caller resumes from the event loop, as after await.
const auto finish = [this, done] {
if (done)
QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
};
const std::optional<CanvasDocument> &document = m_document;
// Swift's positive bounds refuse a point that is no number.
const bool inside = document && point.x() >= 0 && point.y() >= 0 && point.x() < document->width && point.y() < document->height;
if (!canEditSelection() || m_isProjectBusy || m_selectionMoveOrigin || !inside) {
finish();
return;
}
const std::optional<QImage> sample = wandSample(*document);
if (!sample) {
finish();
return;
}
setIsProjectBusy(true);
m_wanding = Wanding{mode, document->id, std::move(done)};
m_wand.setFuture(QtConcurrent::run([sample = *sample, point, settings = m_wandSettings]() -> Wanded {
try {
return Wanded{MagicWand::select(sample, point, settings), std::nullopt};
} catch (const MagicWandError &error) {
return Wanded{std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return Wanded{std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishWand()
{
const Wanded result = m_wand.result();
const Wanding pending = std::exchange(m_wanding, std::nullopt).value();
setIsProjectBusy(false);
if (result.failure) {
qCWarning(lcApp).noquote() << "the wand could not select:" << *result.failure;
setBrushError(result.failure);
} else if (m_document && m_document->id == pending.documentID) {
if (!result.path) {
// Nothing matched: New clears, as a lasso click enclosing nothing.
if (pending.mode == SelectionMode::replace)
deselect();
} else if (pending.mode == SelectionMode::replace) {
// A traced outline lies on the canvas: no costly clip.
setSelection(DocumentSelection{*result.path, m_selectionAntialiased}, QStringLiteral("Magic Wand"));
} else {
applySelection(*result.path, pending.mode, QStringLiteral("Magic Wand"));
}
}
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <QImage>
#include <QPainterPath>
#include <QPointF>
#include <QString>
#include <optional>
#include <stdexcept>
#include <vector>
// Pixels either side of the click, averaged into the reference.
enum class WandSampleSize { point, threeByThree, fiveByFive };
QString title(WandSampleSize size);
int radius(WandSampleSize size);
// The Magic Wand's options-bar settings.
struct WandSettings {
// How far each channel may differ, 0–255, and still match.
int tolerance = 32;
WandSampleSize sampleSize = WandSampleSize::point;
// Only similar pixels connected to the clicked one.
bool contiguous = true;
// The visible composite rather than the active layer.
bool sampleAllLayers = false;
friend bool operator==(const WandSettings &, const WandSettings &) = default;
};
class MagicWandError : public std::runtime_error {
public:
enum class Kind { tooDetailed, memory };
explicit MagicWandError(Kind kind);
const Kind kind;
};
// Selects pixels like a clicked one; the C kernel matches.
namespace MagicWand {
// The outline of pixels matching the click; nil when none.
std::optional<QPainterPath> select(const QImage &image, QPointF point, const WandSettings &settings);
// The outline of a mask's nonzero pixels along their edges.
std::optional<QPainterPath> outline(const std::vector<uchar> &mask, int width, int height);
}
+4
View File
@@ -0,0 +1,4 @@
// Swift's MaskTracing extension: thumbnails loaded as selections.
public:
void loadMaskSelection(QUuid layerID, SelectionMode mode = SelectionMode::replace);
void loadLayerSelection(QUuid layerID, SelectionMode mode = SelectionMode::replace);
+146
View File
@@ -0,0 +1,146 @@
#include "Document/MaskTracing.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
#include <functional>
#include <map>
#include <vector>
namespace {
// Outer loops run clockwise, holes counterclockwise, for winding fill.
std::optional<QPainterPath> trace(const QImage &image, bool alpha, const std::function<bool(uchar)> &test)
{
const int width = image.width(), height = image.height();
if (width <= 0 || height <= 0)
return std::nullopt;
const int channels = alpha ? 4 : 1;
// Drawn over a cleared bitmap, as the Swift tracer does.
QImage pixels(width, height, alpha ? QImage::Format_RGBA8888_Premultiplied : QImage::Format_Grayscale8);
if (pixels.isNull())
return std::nullopt;
pixels.fill(0);
QPainter(&pixels).drawImage(QRectF(0, 0, width, height), image);
const auto selected = [&](int x, int y) {
return x >= 0 && y >= 0 && x < width && y < height
&& test(pixels.constScanLine(y)[x * channels + channels - 1]);
};
// Directed unit edges, keyed by their start vertex.
const int stride = width + 1;
std::map<int, std::vector<int>> outgoing;
const auto edge = [&](int x0, int y0, int x1, int y1) { outgoing[y0 * stride + x0].push_back(y1 * stride + x1); };
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
if (!selected(x, y))
continue;
if (!selected(x, y - 1))
edge(x, y, x + 1, y);
if (!selected(x + 1, y))
edge(x + 1, y, x + 1, y + 1);
if (!selected(x, y + 1))
edge(x + 1, y + 1, x, y + 1);
if (!selected(x - 1, y))
edge(x, y + 1, x, y);
}
}
if (outgoing.empty())
return std::nullopt;
QPainterPath path;
path.setFillRule(Qt::WindingFill);
while (!outgoing.empty()) {
const int start = outgoing.begin()->first;
std::vector<int> loop;
int current = start;
do {
const auto found = outgoing.find(current);
if (found == outgoing.end())
break;
const int end = found->second.back();
found->second.pop_back();
if (found->second.empty())
outgoing.erase(found);
loop.push_back(current);
current = end;
} while (current != start);
// Keep corners; drop vertices that continue straight.
std::vector<QPointF> corners;
const int count = int(loop.size());
for (int index = 0; index < count; ++index) {
const int vertex = loop[index], previous = loop[(index + count - 1) % count], following = loop[(index + 1) % count];
const int inX = vertex % stride - previous % stride, inY = vertex / stride - previous / stride;
const int outX = following % stride - vertex % stride, outY = following / stride - vertex / stride;
if (inX != outX || inY != outY)
corners.push_back(QPointF(vertex % stride, vertex / stride));
}
if (corners.size() < 3)
continue;
path.moveTo(corners.front());
for (std::size_t index = 1; index < corners.size(); ++index)
path.lineTo(corners[index]);
path.closeSubpath();
}
return path.isEmpty() ? std::nullopt : std::optional(path);
}
}
std::optional<QPainterPath> MaskTracing::darkPixels(const QImage &image)
{
return trace(image, false, [](uchar value) { return value < 128; });
}
std::optional<QPainterPath> MaskTracing::opaquePixels(const QImage &image)
{
return trace(image, true, [](uchar value) { return value >= 128; });
}
void EditorSession::loadMaskSelection(QUuid layerID, SelectionMode mode)
{
if (!canEditSelection())
return;
const int index = indexOf(m_document->layers, layerID);
if (index < 0 || !m_document->layers[size_t(index)].mask)
return;
const ImageLayer &layer = m_document->layers[size_t(index)];
QImage mask;
// A painted mask flattens here; a failure loads nothing.
try {
mask = layer.mask->asset.image();
} catch (const ExportError &error) {
qCWarning(lcApp).noquote() << "cannot read the mask of" << layer.name << ":" << error.what();
return;
}
const std::optional<QPainterPath> traced = MaskTracing::darkPixels(mask);
// Swift beeps: nothing black to select.
if (!traced) {
qCWarning(lcApp).noquote() << "no black to select in the mask of" << layer.name;
return;
}
applySelection(BrushRaster::pixelToDocument(layer.maskTransform(), mask.width(), mask.height()).map(*traced), mode,
QStringLiteral("Load Mask Selection"));
}
void EditorSession::loadLayerSelection(QUuid layerID, SelectionMode mode)
{
const int index = canEditSelection() ? indexOf(m_document->layers, layerID) : -1;
// Swift beeps: no pixels, or none opaque.
if (index < 0 || m_document->layers[size_t(index)].isGroup || !m_document->layers[size_t(index)].asset) {
qCWarning(lcApp) << "no pixels to select for layer" << layerID.toString();
return;
}
const ImageLayer &layer = m_document->layers[size_t(index)];
QImage image;
try {
image = layer.asset->image();
} catch (const ExportError &error) {
qCWarning(lcApp).noquote() << "cannot read the pixels of" << layer.name << ":" << error.what();
return;
}
const std::optional<QPainterPath> traced = MaskTracing::opaquePixels(image);
if (!traced) {
qCWarning(lcApp).noquote() << "no opaque pixels to select in" << layer.name;
return;
}
applySelection(BrushRaster::pixelToDocument(layer.transform, image.width(), image.height()).map(*traced), mode,
QStringLiteral("Load Layer Selection"));
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <QImage>
#include <QPainterPath>
#include <optional>
// Raster coverage traced into an outline along exact pixel edges.
namespace MaskTracing {
std::optional<QPainterPath> darkPixels(const QImage &image);
std::optional<QPainterPath> opaquePixels(const QImage &image);
}
+236
View File
@@ -0,0 +1,236 @@
#include "Document/PixelAdjust.h"
#include "Rendering/PoolMap.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <memory>
#include <new>
#include <numeric>
#include <stdexcept>
namespace {
using Float4 = float __attribute__((vector_size(16)));
// A pixel in floats: gray has one lane, colour four.
template <typename Pixel> Pixel load(const uchar *bytes);
template <> float load<float>(const uchar *bytes)
{
return float(bytes[0]);
}
template <> Float4 load<Float4>(const uchar *bytes)
{
return Float4{float(bytes[0]), float(bytes[1]), float(bytes[2]), float(bytes[3])};
}
// Rounded once, as a render to eight bits does.
void store(float value, uchar *bytes)
{
bytes[0] = uchar(std::min(255.0f, std::max(0.0f, value) + 0.5f));
}
void store(Float4 value, uchar *bytes)
{
for (int c = 0; c < 4; ++c)
store(value[c], bytes + c);
}
// Normalised weights from the centre out, three sigmas long.
std::vector<float> gaussian(double sigma)
{
const int reach = int(std::ceil(3 * sigma));
std::vector<double> exact(size_t(reach) + 1);
double total = 0;
for (int i = 0; i <= reach; ++i) {
exact[size_t(i)] = std::exp(-double(i) * i / (2 * sigma * sigma));
total += i == 0 ? exact[0] : 2 * exact[size_t(i)];
}
std::vector<float> weights;
for (const double weight : exact)
weights.push_back(float(weight / total));
return weights;
}
// Rows for a parallel map; no memory: a render error.
std::vector<int> rowNumbers(int height)
{
try {
std::vector<int> rows(static_cast<size_t>(height));
std::iota(rows.begin(), rows.end(), 0);
return rows;
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
// Columns a block at a time keep the taps cached.
constexpr int block = 64;
template <typename Pixel> void blur(const QImage &image, const std::vector<float> &weights, bool clamped, Pixel *across, QImage &result)
{
const int width = image.width(), height = image.height(), reach = int(weights.size()) - 1, size = int(sizeof(Pixel) / sizeof(float));
const uchar *const in = image.constBits();
uchar *const out = result.bits();
const qsizetype inStride = image.bytesPerLine(), outStride = result.bytesPerLine();
// Past the edge: the edge itself, or nothing.
const auto source = [&](int at, int limit) { return clamped ? std::clamp(at, 0, limit - 1) : at; };
std::vector<int> rows = rowNumbers(height);
PoolMap::blocking(rows, [&](int y) {
const uchar *row = in + y * inStride;
Pixel *line = across + size_t(y) * size_t(width);
for (int x = 0; x < width; ++x) {
Pixel sum = weights[0] * load<Pixel>(row + x * size);
// Away from the edges every tap is inside: no checks.
if (x >= reach && x + reach < width) {
for (int i = 1; i <= reach; ++i)
sum += weights[size_t(i)] * (load<Pixel>(row + (x - i) * size) + load<Pixel>(row + (x + i) * size));
} else {
for (int i = 1; i <= reach; ++i) {
const int left = source(x - i, width), right = source(x + i, width);
if (left >= 0)
sum += weights[size_t(i)] * load<Pixel>(row + left * size);
if (right < width)
sum += weights[size_t(i)] * load<Pixel>(row + right * size);
}
}
line[x] = sum;
}
});
PoolMap::blocking(rows, [&](int y) {
uchar *line = out + y * outStride;
for (int start = 0; start < width; start += block) {
const int count = std::min(block, width - start);
Pixel sums[block];
for (int x = 0; x < count; ++x)
sums[x] = weights[0] * across[size_t(y) * size_t(width) + size_t(start + x)];
for (int i = -reach; i <= reach; ++i) {
const int from = source(y + i, height);
if (i == 0 || from < 0 || from >= height)
continue;
const Pixel *taps = across + size_t(from) * size_t(width) + size_t(start);
const float weight = weights[size_t(std::abs(i))];
for (int x = 0; x < count; ++x)
sums[x] += weight * taps[x];
}
for (int x = 0; x < count; ++x)
store(sums[x], line + (start + x) * size);
}
});
}
}
QImage PixelAdjust::thumbnail(const QImage &image)
{
const double factor = std::min(1.0, 96.0 / std::max(image.width(), image.height()));
const int width = std::max(1, int(image.width() * factor)), height = std::max(1, int(image.height() * factor));
QImage result = BrushRaster::context(width, height, false);
QPainter painter(&result);
BrushRaster::draw(image, QRectF(0, 0, width, height), painter);
return result;
}
QImage PixelAdjust::coverage(const SelectionClip &selection, int width, int height, const QTransform &pixelToDocument)
{
QImage mask = BrushRaster::context(width, height, true);
// An empty selection covers nothing.
if (!selection.coverage)
return mask;
QPainter painter(&mask);
painter.setTransform(pixelToDocument.inverted());
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.drawImage(selection.rect, *selection.coverage);
return mask;
}
QImage PixelAdjust::blend(const QImage &adjusted, const QImage &original, const SelectionClip &selection,
const QTransform &pixelToDocument, bool isMask)
{
const int width = adjusted.width(), height = adjusted.height();
if (original.size() != adjusted.size() || adjusted.format() != original.format())
throw std::logic_error("blend needs two images of one size and format");
const QImage mask = coverage(selection, width, height, pixelToDocument);
QImage result = BrushRaster::context(width, height, isMask);
const int channels = isMask ? 1 : 4;
for (int y = 0; y < height; ++y) {
const uchar *top = adjusted.constScanLine(y), *bottom = original.constScanLine(y), *weight = mask.constScanLine(y);
uchar *target = result.scanLine(y);
for (int x = 0; x < width; ++x) {
const int m = weight[x];
for (int channel = 0; channel < channels; ++channel) {
const int index = x * channels + channel;
target[index] = uchar((top[index] * m + bottom[index] * (255 - m) + 127) / 255);
}
}
}
return result;
}
QImage PixelAdjust::gaussianBlur(const QImage &image, double sigma, bool clamped)
{
const bool gray = image.format() == QImage::Format_Grayscale8;
if (!gray && image.format() != QImage::Format_RGBA8888_Premultiplied)
throw std::logic_error("a blur takes premultiplied RGBA or gray");
if (!(sigma > 0) || !std::isfinite(sigma))
throw std::logic_error("a blur needs a positive sigma");
const std::vector<float> weights = gaussian(sigma);
const size_t pixels = size_t(image.width()) * size_t(image.height());
QImage result = BrushRaster::context(image.width(), image.height(), gray);
// Allocated here, where a failure throws, before the workers run.
if (gray) {
const std::unique_ptr<float[]> across(new (std::nothrow) float[pixels]);
if (!across)
throw ExportError(ExportError::Kind::render);
blur<float>(image, weights, clamped, across.get(), result);
} else {
const std::unique_ptr<Float4[]> across(new (std::nothrow) Float4[pixels]);
if (!across)
throw ExportError(ExportError::Kind::render);
blur<Float4>(image, weights, clamped, across.get(), result);
}
return result;
}
QImage PixelAdjust::motionBlur(const QImage &image, double radius, double angle)
{
if (image.format() != QImage::Format_RGBA8888_Premultiplied)
throw std::logic_error("a motion blur takes premultiplied RGBA");
if (!(radius > 0) || !std::isfinite(radius) || !std::isfinite(angle))
throw std::logic_error("a motion blur needs a finite positive radius and angle");
// Measured on a dot: a Gaussian taper of its radius.
const std::vector<float> weights = gaussian(radius);
const int width = image.width(), height = image.height(), reach = int(weights.size()) - 1;
// Core Image's y points up: counterclockwise climbs the screen.
const double dx = std::cos(angle), dy = -std::sin(angle);
QImage result = BrushRaster::context(width, height, false);
const uchar *const in = image.constBits();
uchar *const out = result.bits();
const qsizetype inStride = image.bytesPerLine(), outStride = result.bytesPerLine();
// Bilinear, clear past the edges, as an unclamped image reads.
const auto sample = [&](double x, double y) {
const double left = std::floor(x), top = std::floor(y);
const float across = float(x - left), down = float(y - top);
Float4 sum{0, 0, 0, 0};
for (int j = 0; j < 2; ++j) {
const int row = int(top) + j;
if (row < 0 || row >= height)
continue;
for (int i = 0; i < 2; ++i) {
const int column = int(left) + i;
if (column >= 0 && column < width)
sum += (j ? down : 1 - down) * (i ? across : 1 - across) * load<Float4>(in + row * inStride + column * 4);
}
}
return sum;
};
std::vector<int> rows = rowNumbers(height);
PoolMap::blocking(rows, [&](int y) {
uchar *line = out + y * outStride;
for (int x = 0; x < width; ++x) {
Float4 sum = weights[0] * load<Float4>(in + y * inStride + x * 4);
for (int i = 1; i <= reach; ++i)
sum += weights[size_t(i)] * (sample(x + i * dx, y + i * dy) + sample(x - i * dx, y - i * dy));
store(sum, line + x * 4);
}
});
return result;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "Document/Selection.h"
#include <QImage>
#include <QTransform>
// Shared plumbing for whole-image adjustments.
namespace PixelAdjust {
// A small preview for the Layers panel, like an import's.
QImage thumbnail(const QImage &image);
// Selection coverage on the image's own pixel grid.
QImage coverage(const SelectionClip &selection, int width, int height, const QTransform &pixelToDocument);
// coverage × adjusted + (1 − coverage) × original.
QImage blend(const QImage &adjusted, const QImage &original, const SelectionClip &selection,
const QTransform &pixelToDocument, bool isMask);
// Core Image's Gaussian on stored values; `clamped` repeats the edge.
QImage gaussianBlur(const QImage &image, double sigma, bool clamped);
// Core Image's motion blur: a Gaussian taper along the angle.
QImage motionBlur(const QImage &image, double radius, double angle);
}
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff. Show more