Merge branch 'main' into port-d
This commit is contained in:
commit
7c6265af77
141 files changed
+5822
-682
No files matched your search
@@ -3,6 +3,7 @@
|
||||
#include "IO/ImageExporter.h"
|
||||
#include <QtConcurrent>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <numeric>
|
||||
#include <stdexcept>
|
||||
|
||||
@@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask)
|
||||
return surface;
|
||||
}
|
||||
|
||||
QImage BrushRaster::copy(const QImage &image)
|
||||
{
|
||||
QImage result = context(image.width(), image.height(), false);
|
||||
if (image.format() != QImage::Format_RGBA8888_Premultiplied) {
|
||||
QPainter painter(&result);
|
||||
draw(image, QRectF(0, 0, image.width(), image.height()), painter);
|
||||
return result;
|
||||
}
|
||||
for (int y = 0; y < image.height(); ++y)
|
||||
std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body)
|
||||
{
|
||||
const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2;
|
||||
const qsizetype size = (count + bands - 1) / bands;
|
||||
try {
|
||||
std::vector<int> indices(static_cast<size_t>(bands));
|
||||
std::iota(indices.begin(), indices.end(), 0);
|
||||
PoolMap::blocking(indices, [&](int band) {
|
||||
const qsizetype start = band * size;
|
||||
if (start < count)
|
||||
body(start, std::min(size, count - start));
|
||||
});
|
||||
} catch (const std::bad_alloc &) {
|
||||
// Out of memory fails as a context would.
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
}
|
||||
}
|
||||
|
||||
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
|
||||
{
|
||||
context.save();
|
||||
|
||||
@@ -46,6 +46,10 @@ struct BrushSettings {
|
||||
// Shared raster drawing; coverage is copied without color conversion.
|
||||
namespace BrushRaster {
|
||||
QImage context(int width, int height, bool mask);
|
||||
// A colour context holding `image`; its own layout copies bytewise.
|
||||
QImage copy(const QImage &image);
|
||||
// Runs `body` over `count` pixels, band by band: start, length.
|
||||
void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body);
|
||||
// Copies `image`, or its `source` part, into `rect`.
|
||||
void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF());
|
||||
// A solid colour through gray coverage, and a clip, 1:1.
|
||||
|
||||
@@ -79,25 +79,34 @@ bool CameraRawCurveSettings::adjusts() const
|
||||
|| !isLinear(green) || !isLinear(blue);
|
||||
}
|
||||
|
||||
double CameraRawCurveSettings::parametric(double tone) const
|
||||
// Bends tones below `lower` by `low`, above `upper` by `high`.
|
||||
double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high)
|
||||
{
|
||||
const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
|
||||
double amount, lo, hi;
|
||||
if (tone < shadow)
|
||||
amount = shadows, lo = 0, hi = shadow;
|
||||
else if (tone < dark)
|
||||
amount = darks, lo = shadow, hi = dark;
|
||||
else if (tone < light)
|
||||
amount = lights, lo = dark, hi = light;
|
||||
else
|
||||
amount = highlights, lo = light, hi = 1;
|
||||
const double span = std::max(0.02, hi - lo);
|
||||
const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
|
||||
// A tone in its region weighs 0 to 1, unfloored.
|
||||
return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
|
||||
// Fitted to Photoshop: Darks −51 dips 0.1 there.
|
||||
const double strength = 1.66;
|
||||
if (tone < lower)
|
||||
return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength));
|
||||
if (tone > upper) {
|
||||
const double rest = 1 - upper;
|
||||
return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength));
|
||||
}
|
||||
return tone;
|
||||
}
|
||||
|
||||
std::vector<float> CameraRawCurveSettings::lumaTable() const
|
||||
double CameraRawCurveSettings::parametric(double tone) const
|
||||
{
|
||||
if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0)
|
||||
return tone;
|
||||
// Gamma bends, smoothed as Image › Curves smooths.
|
||||
std::vector<CurvePoint> anchors;
|
||||
for (int index = 0; index <= 32; ++index) {
|
||||
const double x = index / 32.0;
|
||||
anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)});
|
||||
}
|
||||
return point(tone, anchors);
|
||||
}
|
||||
|
||||
std::vector<float> CameraRawCurveSettings::toneTable() const
|
||||
{
|
||||
std::vector<float> table(256);
|
||||
for (int index = 0; index < 256; ++index)
|
||||
@@ -262,10 +271,10 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs
|
||||
const CameraRawCurveSettings shape = curve.normalized();
|
||||
const CameraRawMixerSettings mixing = mixer.normalized();
|
||||
const CameraRawGradingSettings grade = grading.normalized();
|
||||
const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
|
||||
const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
|
||||
blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
|
||||
gradeFloats = grade.gradeFloats();
|
||||
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(),
|
||||
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(),
|
||||
blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(),
|
||||
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize);
|
||||
}
|
||||
@@ -33,15 +33,19 @@ struct CameraRawCurveSettings {
|
||||
static std::vector<CurvePoint> strongContrast();
|
||||
static bool isLinear(const std::vector<CurvePoint> &points);
|
||||
bool adjusts() const;
|
||||
// Lifts or lowers the region a tone belongs to.
|
||||
// Photoshop's parametric curve: gamma bends, smoothed as Curves.
|
||||
double parametric(double tone) const;
|
||||
std::vector<float> lumaTable() const;
|
||||
// Applied to red, green and blue alike.
|
||||
std::vector<float> toneTable() const;
|
||||
std::vector<float> channelTable(const std::vector<CurvePoint> &points) const;
|
||||
CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
|
||||
// Swift's region(for:): the parametric amount a tone moves.
|
||||
double ®ion(double tone);
|
||||
CameraRawCurveSettings normalized() const;
|
||||
friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
|
||||
|
||||
private:
|
||||
static double bend(double tone, double lower, double low, double upper, double high);
|
||||
};
|
||||
|
||||
// One picked colour and how far its adjustment reaches.
|
||||
|
||||
+62
-5
@@ -1,17 +1,28 @@
|
||||
#include "Document/Dither.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Rendering/PoolMap.h"
|
||||
#include <QFontMetricsF>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QTextBoundaryFinder>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include <set>
|
||||
extern "C" {
|
||||
#include "DitherPixels.h"
|
||||
}
|
||||
|
||||
// The kernel's bands, where Swift's kernel calls dispatch_apply.
|
||||
extern "C" void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context)
|
||||
{
|
||||
std::vector<size_t> indices(bands);
|
||||
std::iota(indices.begin(), indices.end(), size_t(0));
|
||||
PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); });
|
||||
}
|
||||
|
||||
// Swift's Dither.swift: the settings, the chunky pixels, the glyphs.
|
||||
namespace {
|
||||
// Swift's Characters: the text's graphemes.
|
||||
@@ -53,6 +64,7 @@ QString rawValue(DitherStyle style)
|
||||
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
|
||||
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
|
||||
case DitherStyle::ascii: return QStringLiteral("ASCII");
|
||||
case DitherStyle::scanlines: return QStringLiteral("Scanlines (CRT)");
|
||||
}
|
||||
throw std::logic_error("unknown dither style");
|
||||
}
|
||||
@@ -80,7 +92,12 @@ bool isHalftone(DitherStyle style)
|
||||
|
||||
bool drawsMarks(DitherStyle style)
|
||||
{
|
||||
return !hasTones(style);
|
||||
return !hasTones(style) && style != DitherStyle::scanlines;
|
||||
}
|
||||
|
||||
bool usesPixelSize(DitherStyle style)
|
||||
{
|
||||
return style != DitherStyle::ascii && style != DitherStyle::scanlines;
|
||||
}
|
||||
|
||||
QString rawValue(DitherPixelShape shape)
|
||||
@@ -105,6 +122,10 @@ DitherSettings DitherSettings::normalized() const
|
||||
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
|
||||
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
|
||||
result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14));
|
||||
result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4));
|
||||
result.glow = clamp(glow, 0, 100, 35);
|
||||
result.dots = clamp(dots, 0, 100, 0);
|
||||
result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0);
|
||||
result.angle = clamp(angle, -90, 90, 45);
|
||||
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
|
||||
result.diffusion = clamp(diffusion, 0, 100, 100);
|
||||
@@ -125,8 +146,8 @@ DitherSettings DitherSettings::normalized() const
|
||||
QImage DitherSettings::apply(const QImage &image) const
|
||||
{
|
||||
const DitherSettings settings = normalized();
|
||||
// ASCII draws at full resolution: chunks would blur its letters.
|
||||
const int block = settings.style == DitherStyle::ascii ? 1 : int(settings.pixelSize);
|
||||
// ASCII and Scanlines draw full size: chunks would blur them.
|
||||
const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1;
|
||||
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
if (source.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
@@ -153,6 +174,8 @@ QImage DitherSettings::apply(const QImage &image) const
|
||||
}
|
||||
}
|
||||
const QImage dithered = settings.dither(working);
|
||||
if (settings.style == DitherStyle::scanlines && settings.glow > 0)
|
||||
return settings.glowing(dithered);
|
||||
if (block <= 1)
|
||||
return dithered;
|
||||
// Blown back up without smoothing.
|
||||
@@ -171,9 +194,42 @@ QImage DitherSettings::apply(const QImage &image) const
|
||||
return full;
|
||||
}
|
||||
|
||||
// The lines' light blurred wide and added back: phosphor bloom.
|
||||
QImage DitherSettings::glowing(const QImage &image) const
|
||||
{
|
||||
// Blurred small, scaled back up: same light, less work.
|
||||
const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4));
|
||||
const int width = image.width(), height = image.height();
|
||||
// Swift clamps first: edges repeat outward before shrinking.
|
||||
const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step;
|
||||
QImage padded = BrushRaster::context(width + 2 * pad, height + 2 * pad, false);
|
||||
for (int y = 0; y < padded.height(); ++y) {
|
||||
const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1));
|
||||
uchar *out = padded.scanLine(y);
|
||||
for (int x = 0; x < padded.width(); ++x)
|
||||
std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4);
|
||||
}
|
||||
const int smallWidth = (padded.width() + step - 1) / step, smallHeight = (padded.height() + step - 1) / step;
|
||||
const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
||||
if (small.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true)
|
||||
.scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
|
||||
.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
QImage result = image.copy();
|
||||
if (blurred.isNull() || result.isNull())
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
// The scaled light's own rows, cropped to the image.
|
||||
QImage bloom = BrushRaster::context(width, height, false);
|
||||
for (int y = 0; y < height; ++y)
|
||||
std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y));
|
||||
dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5));
|
||||
return result;
|
||||
}
|
||||
|
||||
QImage DitherSettings::dither(const QImage &image) const
|
||||
{
|
||||
const int cell = int(cellSize);
|
||||
const int cell = int(style == DitherStyle::scanlines ? lineSpacing : cellSize);
|
||||
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs();
|
||||
const bool two = colors == DitherColors::twoColors;
|
||||
const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0};
|
||||
@@ -184,7 +240,8 @@ QImage DitherSettings::dither(const QImage &image) const
|
||||
.contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0,
|
||||
.originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
|
||||
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(),
|
||||
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())};
|
||||
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size()), .dots = float(dots / 100),
|
||||
.wobble = float(wobble)};
|
||||
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), ¶ms) == 0;
|
||||
});
|
||||
if (failed)
|
||||
|
||||
+15
-4
@@ -6,10 +6,10 @@
|
||||
#include <vector>
|
||||
|
||||
// Filter › Dither's looks, in the panel's order and DitherPixels.h's.
|
||||
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii };
|
||||
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
|
||||
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
|
||||
DitherStyle::patterns, DitherStyle::ascii};
|
||||
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii, scanlines };
|
||||
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
|
||||
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
|
||||
DitherStyle::patterns, DitherStyle::ascii, DitherStyle::scanlines};
|
||||
QString rawValue(DitherStyle style);
|
||||
// Swift's groups: diffusion, ordered, halftone, marks.
|
||||
int ditherGroup(DitherStyle style);
|
||||
@@ -19,6 +19,8 @@ bool diffuses(DitherStyle style);
|
||||
bool hasTones(DitherStyle style);
|
||||
bool isHalftone(DitherStyle style);
|
||||
bool drawsMarks(DitherStyle style);
|
||||
// ASCII and Scanlines draw at full resolution, not chunky.
|
||||
bool usesPixelSize(DitherStyle style);
|
||||
|
||||
// A chunky pixel: a square, or a dot on dark.
|
||||
enum class DitherPixelShape { square, dot };
|
||||
@@ -35,6 +37,8 @@ struct DitherSettings {
|
||||
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
|
||||
static constexpr double textSizeLow = 6, textSizeHigh = 64;
|
||||
static constexpr double levelsLow = 2, levelsHigh = 8;
|
||||
static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32;
|
||||
static constexpr double wobbleLow = 0, wobbleHigh = 64;
|
||||
static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); }
|
||||
DitherStyle style = DitherStyle::atkinson;
|
||||
// Each dithered pixel covers this many layer pixels a side.
|
||||
@@ -44,6 +48,12 @@ struct DitherSettings {
|
||||
double cellSize = 8;
|
||||
// ASCII's line height in layer pixels; letters about 0.6 wide.
|
||||
double textSize = 14;
|
||||
// Scanlines: the lines' distance apart, in layer pixels.
|
||||
double lineSpacing = 4;
|
||||
// Glow and dots 0–100%; wobble sideways in pixels.
|
||||
double glow = 35;
|
||||
double dots = 0;
|
||||
double wobble = 0;
|
||||
// The halftone screen's angle, in degrees.
|
||||
double angle = 45;
|
||||
// Tones per channel; 2 is 1-bit.
|
||||
@@ -76,4 +86,5 @@ struct DitherSettings {
|
||||
|
||||
private:
|
||||
QImage dither(const QImage &image) const;
|
||||
QImage glowing(const QImage &image) const;
|
||||
};
|
||||
@@ -104,8 +104,8 @@ struct SessionJobs {
|
||||
FilterSettings settings;
|
||||
// Camera Raw's scope of the grade itself.
|
||||
std::optional<CameraRawScope> scope = std::nullopt;
|
||||
// Its grid's version: a newer grid drops it.
|
||||
quint64 sourceVersion = 0;
|
||||
// The grown layer it was made from: it shows there.
|
||||
std::optional<LayerTransform> placement = std::nullopt;
|
||||
};
|
||||
struct FilterMade {
|
||||
std::optional<ImportedImage> asset;
|
||||
|
||||
@@ -75,7 +75,8 @@ struct CanvasDocument {
|
||||
|
||||
QSizeF size() const { return QSizeF(width, height); }
|
||||
static std::optional<int> validDimension(const QString &value);
|
||||
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
|
||||
// Drawing order and what shows (LayerOrder), kept between asks.
|
||||
LayerOrder::Result hierarchy() const;
|
||||
QSet<QUuid> effectiveVisibleIDs() const;
|
||||
QHash<QUuid, double> effectiveOpacities() const;
|
||||
std::vector<ImageLayer> renderLayers() const;
|
||||
|
||||
@@ -27,7 +27,7 @@ std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
|
||||
void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path)
|
||||
{
|
||||
m_collapsedGroupIDs.clear();
|
||||
m_isMaskSelected = false;
|
||||
setIsMaskSelected(false);
|
||||
m_cropRect.reset();
|
||||
m_guideDrag = std::nullopt;
|
||||
m_transformEdit = std::nullopt;
|
||||
@@ -78,7 +78,7 @@ void EditorSession::setProjectPath(std::optional<QString> path)
|
||||
void EditorSession::clearProject()
|
||||
{
|
||||
m_collapsedGroupIDs.clear();
|
||||
m_isMaskSelected = false;
|
||||
setIsMaskSelected(false);
|
||||
m_cropRect.reset();
|
||||
m_transformEdit = std::nullopt;
|
||||
m_guideDrag = std::nullopt;
|
||||
|
||||
@@ -257,8 +257,8 @@ LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const
|
||||
if (const std::optional<LayerTransform> pending = pendingTransform(layer))
|
||||
return *pending;
|
||||
// Content-Aware Fill past the edge previews on the grown layer.
|
||||
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
|
||||
return *m_filterEdit->grownTransform;
|
||||
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
|
||||
return *m_filterEdit->preparedTransform;
|
||||
return layer.transform;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ void EditorSession::setActiveLayerID(std::optional<QUuid> id)
|
||||
{
|
||||
// Another layer: its pixels are the target again.
|
||||
if (id != m_activeLayerID)
|
||||
m_isMaskSelected = false;
|
||||
setIsMaskSelected(false);
|
||||
m_activeLayerID = id;
|
||||
m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>();
|
||||
notify();
|
||||
@@ -234,7 +234,7 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
|
||||
m_document = snapshot.document;
|
||||
setActiveLayerID(snapshot.activeLayerID);
|
||||
const std::optional<ImageLayer> active = activeLayer();
|
||||
m_isMaskSelected = keepsMaskTarget && active && active->mask;
|
||||
setIsMaskSelected(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";
|
||||
@@ -381,3 +381,18 @@ void EditorSession::requestCanvasFocus()
|
||||
++m_canvasFocusRequest;
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setIsMaskSelected(bool value)
|
||||
{
|
||||
m_isMaskSelected = value;
|
||||
if (!value)
|
||||
m_viewsMaskAlone = false;
|
||||
}
|
||||
|
||||
void EditorSession::setViewsMaskAlone(bool value)
|
||||
{
|
||||
if (m_viewsMaskAlone == value)
|
||||
return;
|
||||
m_viewsMaskAlone = value;
|
||||
notify();
|
||||
}
|
||||
@@ -93,10 +93,11 @@ public:
|
||||
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; }
|
||||
|
||||
// Alt-click: the targeted mask alone, gray, on the canvas.
|
||||
bool viewsMaskAlone() const { return m_viewsMaskAlone; }
|
||||
void setViewsMaskAlone(bool value);
|
||||
// A blend mode tried on by hovering; nothing is saved.
|
||||
struct BlendPreview {
|
||||
QUuid layerID;
|
||||
@@ -132,7 +133,6 @@ public:
|
||||
// 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);
|
||||
@@ -350,6 +350,7 @@ private:
|
||||
std::optional<BlendPreview> m_blendPreview;
|
||||
std::optional<QUuid> m_opacityEditLayerID;
|
||||
bool m_isMaskSelected = false;
|
||||
bool m_viewsMaskAlone = false;
|
||||
std::optional<QUuid> m_activeLayerID;
|
||||
bool m_isProjectBusy = false;
|
||||
bool m_showsNewDocument = false;
|
||||
|
||||
@@ -80,10 +80,6 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
|
||||
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
|
||||
try {
|
||||
edit.growForBlur();
|
||||
edit.preparedPreview.reset();
|
||||
// A waiting job moves to the grid its version names.
|
||||
if (edit.pending)
|
||||
edit.pending = edit.previewJob();
|
||||
} catch (const ProjectError &error) {
|
||||
setBrushError(QString::fromUtf8(error.what()));
|
||||
} catch (const ExportError &error) {
|
||||
@@ -111,6 +107,7 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
|
||||
return;
|
||||
}
|
||||
edit.pending = edit.previewJob();
|
||||
edit.pendingTransform = edit.grownTransform;
|
||||
renderFilterPreview();
|
||||
}
|
||||
|
||||
@@ -127,22 +124,22 @@ void EditorSession::renderFilterPreview()
|
||||
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
|
||||
m_filterPreviewFor = edit.id;
|
||||
m_filterRendering = true;
|
||||
m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered {
|
||||
m_filterPreview.setFuture(QtConcurrent::run([job, placement = edit.pendingTransform]() -> Filtered {
|
||||
try {
|
||||
if (job.kind == FilterKind::cameraRaw) {
|
||||
auto [image, scope] = CameraRawScope::preview(job);
|
||||
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version};
|
||||
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), placement};
|
||||
}
|
||||
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version};
|
||||
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, placement};
|
||||
} catch (const ContentFillError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
} catch (const SubjectRemovalError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
} catch (const ExportError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
} catch (const ProjectError &error) {
|
||||
// Settings past their bounds: Black & White, Color Balance.
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -153,18 +150,17 @@ void EditorSession::finishFilterPreview()
|
||||
{
|
||||
m_filterRendering = false;
|
||||
const Filtered result = m_filterPreview.result();
|
||||
// A cancelled edit takes nothing; an older grid renders again.
|
||||
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) {
|
||||
m_filterEdit->preparing = false;
|
||||
notify();
|
||||
} else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
|
||||
// A cancelled edit takes nothing.
|
||||
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
|
||||
FilterEdit &edit = *m_filterEdit;
|
||||
edit.preparing = false;
|
||||
edit.previewError = result.failure;
|
||||
if (result.scope)
|
||||
edit.rawPanel.scope = result.scope;
|
||||
// Made before the layer grew, it shows there till replaced.
|
||||
if (edit.preview || isAutomatic(edit.kind)) {
|
||||
edit.preparedPreview = result.image;
|
||||
edit.preparedTransform = result.placement;
|
||||
edit.preparedSettings = result.settings;
|
||||
++m_brushRevision;
|
||||
}
|
||||
@@ -351,7 +347,7 @@ void EditorSession::finishFilterCommit()
|
||||
mask.isEnabled = true;
|
||||
mask.placement = std::nullopt;
|
||||
m_document->layers[index].mask = mask;
|
||||
m_isMaskSelected = true;
|
||||
setIsMaskSelected(true);
|
||||
endEdit();
|
||||
finish();
|
||||
return;
|
||||
|
||||
@@ -272,7 +272,6 @@ void FilterEdit::grow(const QRectF &extent)
|
||||
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
|
||||
{
|
||||
const QSize size = source.size();
|
||||
++previewSourceVersion;
|
||||
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
|
||||
// Noise, grain and dither preview whole: enlarged, they look coarse.
|
||||
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
|
||||
|
||||
@@ -171,10 +171,12 @@ public:
|
||||
// Add Noise's grain, fixed while the panel is open.
|
||||
const quint32 seed = QRandomGenerator::global()->generate();
|
||||
std::optional<QImage> preparedPreview;
|
||||
// Where the prepared preview sits; none: the layer's own place.
|
||||
std::optional<LayerTransform> preparedTransform;
|
||||
// The grown layer `pending` is made from.
|
||||
std::optional<LayerTransform> pendingTransform;
|
||||
// The settings the prepared preview was made with.
|
||||
std::optional<FilterSettings> preparedSettings;
|
||||
// Moves with each new grid: an older render lands nothing.
|
||||
quint64 previewSourceVersion = 0;
|
||||
std::optional<FilterJob> pending;
|
||||
CameraRawPanel rawPanel;
|
||||
// Vignette on an empty layer: the canvas it fills.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Rendering/PoolMap.h"
|
||||
#include "Document/HueSaturation.h"
|
||||
#include "Document/PixelAdjust.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include <QPainter>
|
||||
#include <QtConcurrent>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
extern "C" {
|
||||
#include "LevelsPixels.h"
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct HSL {
|
||||
@@ -48,38 +49,38 @@ HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness
|
||||
return {clamp(color.red), clamp(color.green), clamp(color.blue)};
|
||||
}
|
||||
|
||||
// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back.
|
||||
void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows)
|
||||
// The last tables built: building costs more than applying.
|
||||
struct CubeCache {
|
||||
std::mutex lock;
|
||||
std::deque<std::pair<HueSaturationSettings, std::vector<float>>> cubes;
|
||||
};
|
||||
|
||||
CubeCache &cubeCache()
|
||||
{
|
||||
constexpr int size = HueSaturationFilter::dimension, last = size - 1;
|
||||
PoolMap::blocking(rows, [&](int y) {
|
||||
uchar *pixel = bits + y * stride;
|
||||
for (int x = 0; x < width; ++x, pixel += 4) {
|
||||
const float alpha = pixel[3];
|
||||
if (alpha == 0) {
|
||||
std::fill_n(pixel, 3, uchar(0));
|
||||
continue;
|
||||
static CubeCache cache;
|
||||
return cache;
|
||||
}
|
||||
|
||||
std::vector<float> built(const HueSaturationSettings &settings)
|
||||
{
|
||||
const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(settings);
|
||||
constexpr int dimension = HueSaturationFilter::dimension;
|
||||
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
|
||||
size_t index = 0;
|
||||
const double step = dimension - 1;
|
||||
for (int blue = 0; blue < dimension; ++blue) {
|
||||
for (int green = 0; green < dimension; ++green) {
|
||||
for (int red = 0; red < dimension; ++red) {
|
||||
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(red / step, green / step, blue / step, settings, response);
|
||||
values[index] = float(color.red);
|
||||
values[index + 1] = float(color.green);
|
||||
values[index + 2] = float(color.blue);
|
||||
values[index + 3] = 1;
|
||||
index += 4;
|
||||
}
|
||||
float place[3], fraction[3];
|
||||
int low[3];
|
||||
for (int channel = 0; channel < 3; ++channel) {
|
||||
place[channel] = std::min(1.0f, pixel[channel] / alpha) * last;
|
||||
low[channel] = std::min(int(place[channel]), last - 1);
|
||||
fraction[channel] = place[channel] - float(low[channel]);
|
||||
}
|
||||
float out[3] = {0, 0, 0};
|
||||
for (int corner = 0; corner < 8; ++corner) {
|
||||
const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1);
|
||||
const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1])
|
||||
* ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]);
|
||||
const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)];
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
out[channel] += share * entry[channel];
|
||||
}
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha));
|
||||
}
|
||||
});
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,22 +105,20 @@ std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(c
|
||||
|
||||
std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings)
|
||||
{
|
||||
const std::vector<HueResponse> response = hueResponse(settings);
|
||||
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
|
||||
size_t index = 0;
|
||||
const double step = dimension - 1;
|
||||
for (int blue = 0; blue < dimension; ++blue) {
|
||||
for (int green = 0; green < dimension; ++green) {
|
||||
for (int red = 0; red < dimension; ++red) {
|
||||
const Color color = adjust(red / step, green / step, blue / step, settings, response);
|
||||
values[index] = float(color.red);
|
||||
values[index + 1] = float(color.green);
|
||||
values[index + 2] = float(color.blue);
|
||||
values[index + 3] = 1;
|
||||
index += 4;
|
||||
}
|
||||
CubeCache &cache = cubeCache();
|
||||
{
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
for (const auto &[known, values] : cache.cubes) {
|
||||
if (known == settings)
|
||||
return values;
|
||||
}
|
||||
}
|
||||
std::vector<float> values = built(settings);
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
std::erase_if(cache.cubes, [&](const auto &entry) { return entry.first == settings; });
|
||||
cache.cubes.emplace_front(settings, values);
|
||||
if (cache.cubes.size() > 8)
|
||||
cache.cubes.pop_back();
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -144,8 +143,7 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
|
||||
hue = std::fmod(hue + sampled.shift, 360);
|
||||
if (hue < 0)
|
||||
hue += 360;
|
||||
// Multiplied, so neutral grays stay neutral.
|
||||
saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100)));
|
||||
saturation = adjustedSaturation(saturation, sampled.saturation);
|
||||
}
|
||||
// Toward white above zero, black below, reaching either at 100.
|
||||
const double amount = std::min(1.0, std::max(-1.0, lightnessAmount));
|
||||
@@ -153,6 +151,15 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
|
||||
return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness)));
|
||||
}
|
||||
|
||||
double HueSaturationFilter::adjustedSaturation(double saturation, double amount)
|
||||
{
|
||||
const double share = std::min(1.0, std::max(-1.0, amount / 100));
|
||||
// Multiplied both ways, so neutral grays stay neutral.
|
||||
if (!(share > 0))
|
||||
return std::max(0.0, saturation * (1 + share));
|
||||
return share >= 1 ? (saturation > 0 ? 1 : 0) : std::min(1.0, saturation / (1 - share));
|
||||
}
|
||||
|
||||
double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings)
|
||||
{
|
||||
double shift = 0;
|
||||
@@ -166,23 +173,19 @@ double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &
|
||||
|
||||
AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job)
|
||||
{
|
||||
const int width = job.image.width(), height = job.image.height();
|
||||
QImage result = BrushRaster::context(width, height, false);
|
||||
{
|
||||
QPainter painter(&result);
|
||||
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
|
||||
}
|
||||
// Across the cores: it runs over the view each frame.
|
||||
QImage result = BrushRaster::copy(job.image);
|
||||
std::vector<float> table;
|
||||
std::vector<int> rows;
|
||||
try {
|
||||
table = cube(job.settings);
|
||||
rows.resize(size_t(height));
|
||||
} catch (const std::bad_alloc &) {
|
||||
// Out of memory fails as a context would.
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
}
|
||||
std::iota(rows.begin(), rows.end(), 0);
|
||||
applyCube(result.bits(), result.bytesPerLine(), width, table, rows);
|
||||
// The lookup unpremultiplies around itself.
|
||||
uchar *const pixels = result.bits();
|
||||
BrushRaster::inBands(qsizetype(result.width()) * result.height(),
|
||||
[&](qsizetype start, qsizetype length) { cube_apply(pixels + start * 4, size_t(length), table.data(), dimension); });
|
||||
// Swift blends over the image drawn again: the same pixels.
|
||||
if (job.selection)
|
||||
result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false);
|
||||
|
||||
@@ -131,6 +131,8 @@ std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings);
|
||||
std::vector<float> cube(const HueSaturationSettings &settings);
|
||||
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings);
|
||||
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response);
|
||||
// Photoshop's: down toward gray, up by dividing by what's left.
|
||||
double adjustedSaturation(double saturation, double amount);
|
||||
// The hue a spectrum swatch becomes.
|
||||
double shiftedHue(double hue, const HueSaturationSettings &settings);
|
||||
}
|
||||
|
||||
@@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region,
|
||||
FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()};
|
||||
// Swift's region origin, counted in the image's own pixels.
|
||||
if (region)
|
||||
job.noiseOrigin = region->topLeft() * (image.width() / region->width());
|
||||
job.noiseOrigin = QPointF(region->left() * image.width() / std::max(1.0, region->width()),
|
||||
region->top() * image.height() / std::max(1.0, region->height()));
|
||||
return PixelFilter::run(job);
|
||||
}
|
||||
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
|
||||
|
||||
@@ -97,7 +97,7 @@ void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing)
|
||||
finishEffectsEditing(false);
|
||||
// Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here.
|
||||
selectLayer(id);
|
||||
m_isMaskSelected = false;
|
||||
setIsMaskSelected(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());
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/LayerGroups.h"
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
using Children = std::map<std::optional<QUuid>, std::vector<const LayerOrder::Node *>>;
|
||||
|
||||
void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool shown, LayerOrder::Result &result)
|
||||
{
|
||||
if (depth > 64)
|
||||
return;
|
||||
const auto found = children.find(parent);
|
||||
if (found == children.end())
|
||||
return;
|
||||
for (const LayerOrder::Node *node : found->second) {
|
||||
const bool effective = shown && node->isVisible;
|
||||
result.order.push_back(node->id);
|
||||
if (effective) {
|
||||
result.visible.insert(node->id);
|
||||
if (!node->isGroup)
|
||||
result.drawn.push_back(node->id);
|
||||
}
|
||||
if (node->isGroup)
|
||||
visit(children, node->id, depth + 1, effective, result);
|
||||
}
|
||||
}
|
||||
|
||||
// The last nodes asked about and their answer.
|
||||
struct Known {
|
||||
std::mutex lock;
|
||||
std::optional<std::pair<std::vector<LayerOrder::Node>, LayerOrder::Result>> last;
|
||||
};
|
||||
|
||||
Known &known()
|
||||
{
|
||||
static Known value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
LayerOrder::Result LayerOrder::resolve(const std::vector<ImageLayer> &layers)
|
||||
{
|
||||
std::vector<Node> nodes;
|
||||
nodes.reserve(layers.size());
|
||||
for (const ImageLayer &layer : layers)
|
||||
nodes.push_back(Node{layer.id, layer.parentID, layer.isGroup, layer.isVisible});
|
||||
Known &cache = known();
|
||||
{
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
if (cache.last && cache.last->first == nodes)
|
||||
return cache.last->second;
|
||||
}
|
||||
Children children;
|
||||
for (const Node &node : nodes)
|
||||
children[node.parentID].push_back(&node);
|
||||
Result result;
|
||||
visit(children, std::nullopt, 0, true, result);
|
||||
const std::lock_guard<std::mutex> held(cache.lock);
|
||||
cache.last.emplace(std::move(nodes), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
LayerOrder::Result CanvasDocument::hierarchy() const
|
||||
{
|
||||
return LayerOrder::resolve(layers);
|
||||
}
|
||||
|
||||
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
|
||||
{
|
||||
return hierarchy().visible;
|
||||
}
|
||||
|
||||
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
|
||||
{
|
||||
// Each layer's opacity and folder, no whole layers copied.
|
||||
QHash<QUuid, std::pair<double, std::optional<QUuid>>> folders;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (folders.contains(layer.id))
|
||||
throw std::logic_error("two layers share an id");
|
||||
folders.insert(layer.id, {layer.opacity, layer.parentID});
|
||||
}
|
||||
QHash<QUuid, double> result;
|
||||
for (auto entry = folders.cbegin(); entry != folders.cend(); ++entry) {
|
||||
result.insert(entry.key(), LayerOpacity::effective(entry->first, entry->second, [&folders](QUuid id) {
|
||||
return folders.contains(id) ? std::optional(folders.value(id)) : std::nullopt;
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<ImageLayer> CanvasDocument::renderLayers() const
|
||||
{
|
||||
const std::vector<QUuid> order = hierarchy().drawn;
|
||||
if (order.empty())
|
||||
return {};
|
||||
std::map<QUuid, size_t> index;
|
||||
for (size_t place = 0; place < layers.size(); ++place) {
|
||||
if (!index.emplace(layers[place].id, place).second)
|
||||
throw std::logic_error("two layers share an id");
|
||||
}
|
||||
std::vector<ImageLayer> result;
|
||||
result.reserve(order.size());
|
||||
for (const QUuid id : order)
|
||||
result.push_back(layers[index.at(id)]);
|
||||
return result;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ public:
|
||||
void selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary);
|
||||
void extendSelection(QUuid id);
|
||||
void groupSelectedLayers();
|
||||
// The active layer is a folder: something to unwrap.
|
||||
bool canUngroupLayers() const;
|
||||
void ungroupLayers();
|
||||
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);
|
||||
|
||||
@@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRe
|
||||
});
|
||||
}
|
||||
|
||||
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
|
||||
{
|
||||
std::map<QUuid, ImageLayer> byID;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (!byID.emplace(layer.id, layer).second)
|
||||
throw std::logic_error("two layers share an id");
|
||||
}
|
||||
QHash<QUuid, double> result;
|
||||
for (const ImageLayer &layer : layers)
|
||||
result.insert(layer.id, layer.effectiveOpacity(byID));
|
||||
return result;
|
||||
}
|
||||
|
||||
ProjectLayerRecord ImageLayer::hierarchyRecord() const
|
||||
{
|
||||
const QString file = id.toString(QUuid::WithoutBraces).toUpper();
|
||||
@@ -159,40 +146,6 @@ ProjectLayerRecord ImageLayer::hierarchyRecord() const
|
||||
.text = typed ? std::optional(typed->style) : std::nullopt};
|
||||
}
|
||||
|
||||
std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const
|
||||
{
|
||||
std::vector<ProjectLayerRecord> records;
|
||||
for (const ImageLayer &layer : layers)
|
||||
records.push_back(layer.hierarchyRecord());
|
||||
return LayerHierarchy::entries(records);
|
||||
}
|
||||
|
||||
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
|
||||
{
|
||||
QSet<QUuid> result;
|
||||
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
|
||||
if (entry.visible)
|
||||
result.insert(entry.layer.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<ImageLayer> CanvasDocument::renderLayers() const
|
||||
{
|
||||
QHash<QUuid, const ImageLayer *> byID;
|
||||
for (const ImageLayer &layer : layers) {
|
||||
if (byID.contains(layer.id))
|
||||
throw std::logic_error("two layers share an id");
|
||||
byID.insert(layer.id, &layer);
|
||||
}
|
||||
std::vector<ImageLayer> result;
|
||||
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
|
||||
if (entry.visible && entry.layer.isGroup != true)
|
||||
result.push_back(*byID.value(entry.layer.id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary)
|
||||
{
|
||||
dropEffectSelection();
|
||||
@@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers()
|
||||
}
|
||||
std::vector<QUuid> ordered;
|
||||
std::vector<Chain> chains;
|
||||
for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) {
|
||||
if (rootIDs.contains(entry.layer.id)) {
|
||||
ordered.push_back(entry.layer.id);
|
||||
chains.push_back(ancestors(entry.layer.id));
|
||||
for (const QUuid id : m_document->hierarchy().order) {
|
||||
if (rootIDs.contains(id)) {
|
||||
ordered.push_back(id);
|
||||
chains.push_back(ancestors(id));
|
||||
}
|
||||
}
|
||||
// The nearest folder they all share; the root when none.
|
||||
@@ -317,6 +270,44 @@ void EditorSession::groupSelectedLayers()
|
||||
endEdit();
|
||||
}
|
||||
|
||||
bool EditorSession::canUngroupLayers() const
|
||||
{
|
||||
const std::optional<ImageLayer> active = activeLayer();
|
||||
return canEditLayers() && active && active->isGroup;
|
||||
}
|
||||
|
||||
// The folder's children take its place, in order.
|
||||
void EditorSession::ungroupLayers()
|
||||
{
|
||||
if (!canUngroupLayers())
|
||||
return;
|
||||
const ImageLayer group = activeLayer().value();
|
||||
std::vector<ImageLayer> children;
|
||||
for (const ImageLayer &layer : m_document->layers) {
|
||||
if (layer.parentID == group.id) {
|
||||
children.push_back(layer);
|
||||
children.back().parentID = group.parentID;
|
||||
}
|
||||
}
|
||||
std::vector<ImageLayer> layers;
|
||||
QSet<QUuid> childIDs;
|
||||
for (const ImageLayer &child : children)
|
||||
childIDs.insert(child.id);
|
||||
for (const ImageLayer &layer : m_document->layers) {
|
||||
if (layer.id == group.id)
|
||||
layers.insert(layers.end(), children.begin(), children.end());
|
||||
else if (!childIDs.contains(layer.id))
|
||||
layers.push_back(layer);
|
||||
}
|
||||
releaseDetachedClipping(layers);
|
||||
finishOpacityEdit();
|
||||
beginEdit(QStringLiteral("Ungroup Layers"));
|
||||
m_document->layers = layers;
|
||||
selectLayers(childIDs, children.empty() ? std::nullopt : std::optional(children.front().id));
|
||||
m_collapsedGroupIDs.remove(group.id);
|
||||
endEdit();
|
||||
}
|
||||
|
||||
std::vector<LayerHierarchy::Entry> EditorSession::layerRows() const
|
||||
{
|
||||
std::vector<ProjectLayerRecord> records;
|
||||
|
||||
@@ -23,3 +23,26 @@ namespace LayerOpacity {
|
||||
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
|
||||
double effective(double own, std::optional<QUuid> parent, const Node &folder);
|
||||
}
|
||||
|
||||
struct ImageLayer;
|
||||
|
||||
// Swift's LayerOrder: the hierarchy from ids, folders and visibility alone.
|
||||
namespace LayerOrder {
|
||||
struct Node {
|
||||
QUuid id;
|
||||
std::optional<QUuid> parentID;
|
||||
bool isGroup;
|
||||
bool isVisible;
|
||||
friend bool operator==(const Node &, const Node &) = default;
|
||||
};
|
||||
struct Result {
|
||||
// Every layer and folder, as LayerHierarchy::entries lists them.
|
||||
std::vector<QUuid> order;
|
||||
// Visible, in folders that are.
|
||||
QSet<QUuid> visible;
|
||||
// The layers that show, folders left out, bottom to top.
|
||||
std::vector<QUuid> drawn;
|
||||
};
|
||||
// Kept until a node changes: the canvas asks each event.
|
||||
Result resolve(const std::vector<ImageLayer> &layers);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "Document/EditorSession.h"
|
||||
|
||||
// Swift's mask shown alone, an Alt-click on its thumbnail.
|
||||
std::optional<ImageLayer> EditorSession::maskAloneLayer() const
|
||||
{
|
||||
const std::optional<ImageLayer> layer = activeLayer();
|
||||
if (!m_viewsMaskAlone || !m_isMaskSelected || !layer || !layer->mask)
|
||||
return std::nullopt;
|
||||
return layer;
|
||||
}
|
||||
|
||||
void EditorSession::toggleMaskAlone(QUuid id)
|
||||
{
|
||||
const std::optional<ImageLayer> shown = maskAloneLayer();
|
||||
const bool showing = shown && shown->id == id;
|
||||
selectLayerTarget(id, true);
|
||||
if (m_activeLayerID != id || !m_isMaskSelected)
|
||||
return;
|
||||
m_viewsMaskAlone = !showing;
|
||||
notify();
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public:
|
||||
std::optional<QImage> maskDistortPreview(const ImageLayer &layer) const;
|
||||
// The selection made a mask; without one, a solid mask.
|
||||
void addMask(bool revealing = true);
|
||||
// The layer whose mask the canvas shows alone, if any.
|
||||
std::optional<ImageLayer> maskAloneLayer() const;
|
||||
// Alt-click on a mask thumbnail: alone, or the composite again.
|
||||
void toggleMaskAlone(QUuid id);
|
||||
|
||||
private:
|
||||
// Swift's didSet: the pixels targeted end the mask view.
|
||||
void setIsMaskSelected(bool value);
|
||||
void commitMaskTransform();
|
||||
@@ -262,7 +262,7 @@ void EditorSession::selectLayerTarget(QUuid id, bool mask)
|
||||
resolveGradient();
|
||||
selectLayer(id);
|
||||
const std::optional<ImageLayer> active = activeLayer();
|
||||
m_isMaskSelected = mask && active && active->mask;
|
||||
setIsMaskSelected(mask && active && active->mask);
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -286,14 +286,15 @@ void EditorSession::addMask(bool revealing)
|
||||
// The selection's coverage, soft where it is feathered.
|
||||
QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height,
|
||||
BrushRaster::pixelToDocument(active->transform, width, height));
|
||||
if (revealing)
|
||||
// Revealing, white inside; hiding, black inside.
|
||||
if (!revealing)
|
||||
image.invertPixels();
|
||||
const LayerMask mask(LayerMask::assetFrom(image));
|
||||
finishOpacityEdit();
|
||||
beginEdit(QStringLiteral("Add Mask from Selection"));
|
||||
beginEdit(revealing ? QStringLiteral("Reveal Selection") : QStringLiteral("Hide Selection"));
|
||||
m_document->layers[size_t(index)].mask = mask;
|
||||
m_document->selection = std::nullopt;
|
||||
m_isMaskSelected = true;
|
||||
setIsMaskSelected(true);
|
||||
endEdit();
|
||||
} catch (const std::runtime_error &error) {
|
||||
qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what();
|
||||
@@ -312,7 +313,7 @@ void EditorSession::addLayerMask(bool revealing)
|
||||
if (layer.id == active->id)
|
||||
layer.mask = LayerMask::solid(revealing);
|
||||
}
|
||||
m_isMaskSelected = true;
|
||||
setIsMaskSelected(true);
|
||||
endEdit();
|
||||
}
|
||||
|
||||
@@ -341,7 +342,7 @@ void EditorSession::deleteLayerMask()
|
||||
if (layer.id == active->id)
|
||||
layer.mask = std::nullopt;
|
||||
}
|
||||
m_isMaskSelected = false;
|
||||
setIsMaskSelected(false);
|
||||
endEdit();
|
||||
}
|
||||
|
||||
@@ -371,7 +372,7 @@ void EditorSession::copyMask(QUuid source, QUuid target)
|
||||
beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask"));
|
||||
find(target)->mask = mask;
|
||||
selectLayer(target);
|
||||
m_isMaskSelected = true;
|
||||
setIsMaskSelected(true);
|
||||
endEdit();
|
||||
}
|
||||
|
||||
@@ -392,7 +393,7 @@ std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageL
|
||||
if (!layer.mask)
|
||||
return std::nullopt;
|
||||
// Previewed on a grown layer, the mask keeps its bounds.
|
||||
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
|
||||
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
|
||||
return layer.mask->placement.value_or(layer.transform);
|
||||
if (m_transformEdit && m_transformEdit->group) {
|
||||
const TransformGroup &group = *m_transformEdit->group;
|
||||
|
||||
@@ -104,15 +104,17 @@ QImage LevelsFilter::run(const LevelsJob &job)
|
||||
{
|
||||
if (job.settings.isIdentity())
|
||||
return job.image;
|
||||
QImage context = drawn(job.image);
|
||||
QImage context = BrushRaster::copy(job.image);
|
||||
// On the stack: a heap failure would escape the catch.
|
||||
std::array<float, 3 * 256> tables;
|
||||
for (size_t channel = 0; channel < 3; ++channel) {
|
||||
for (size_t value = 0; value <= 255; ++value)
|
||||
tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
|
||||
}
|
||||
// The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice).
|
||||
levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data());
|
||||
// The kernel unpremultiplies edges itself; bands use every core.
|
||||
uchar *const pixels = context.bits();
|
||||
BrushRaster::inBands(qsizetype(context.width()) * context.height(),
|
||||
[&](qsizetype start, qsizetype length) { levels_apply(pixels + start * 4, size_t(length), tables.data()); });
|
||||
if (job.selection)
|
||||
return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false);
|
||||
return context;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QTimer>
|
||||
#include <algorithm>
|
||||
|
||||
const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row");
|
||||
|
||||
@@ -96,6 +97,20 @@ void ProjectWorkspace::select(QUuid id)
|
||||
emit changed();
|
||||
}
|
||||
|
||||
void ProjectWorkspace::moveTab(QUuid id, int index)
|
||||
{
|
||||
const auto from = std::ranges::find_if(m_tabs, [id](const std::shared_ptr<ProjectTab> &tab) { return tab->id == id; });
|
||||
if (from == m_tabs.end())
|
||||
return;
|
||||
const auto target = std::clamp<std::ptrdiff_t>(index, 0, std::ptrdiff_t(m_tabs.size()) - 1);
|
||||
if (target == from - m_tabs.begin())
|
||||
return;
|
||||
const std::shared_ptr<ProjectTab> tab = *from;
|
||||
m_tabs.erase(from);
|
||||
m_tabs.insert(m_tabs.begin() + target, tab);
|
||||
emit changed();
|
||||
}
|
||||
|
||||
void ProjectWorkspace::newCanvas()
|
||||
{
|
||||
if (!canSwitch())
|
||||
@@ -220,14 +235,40 @@ bool ProjectWorkspace::finishTextEditing()
|
||||
return true;
|
||||
}
|
||||
|
||||
void ProjectWorkspace::settlePendingEdits(std::function<void()> done)
|
||||
{
|
||||
// Canvas edits are applied; dialogs cancelled, as their Cancel.
|
||||
const std::shared_ptr<ProjectTab> settled = tab(current().id);
|
||||
settled->session.commitGradient([self = QPointer<ProjectWorkspace>(this), settled, done] {
|
||||
settled->session.finishPixelMove([self, settled, done] {
|
||||
EditorSession &session = settled->session;
|
||||
session.cancelFilter();
|
||||
session.cancelHueSaturation();
|
||||
session.cancelLevels();
|
||||
session.cancelColorRange();
|
||||
session.setSelectionAmountOperation(std::nullopt);
|
||||
session.closeColorPicker(false);
|
||||
if (self)
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectWorkspace::confirmQuit(std::function<void(bool)> done)
|
||||
{
|
||||
if (!finishTextEditing() || !canSwitch()) {
|
||||
if (m_isManaging || !finishTextEditing()) {
|
||||
finish(done, false);
|
||||
return;
|
||||
}
|
||||
setManaging(true);
|
||||
askNext(quitOrder(), 0, std::move(done));
|
||||
settlePendingEdits([this, done] {
|
||||
if (!canSwitch()) {
|
||||
qCWarning(lcApp) << "Quit refused: the project is still busy";
|
||||
finish(done, false);
|
||||
return;
|
||||
}
|
||||
setManaging(true);
|
||||
askNext(quitOrder(), 0, done);
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectWorkspace::askNext(std::vector<std::shared_ptr<ProjectTab>> order, size_t index, std::function<void(bool)> done)
|
||||
|
||||
@@ -36,12 +36,16 @@ public:
|
||||
bool canSwitch() const;
|
||||
ProjectTab &addTab(bool reuseEmpty = true);
|
||||
void select(QUuid id);
|
||||
// A dragged tab lands at `index`, clamped; chrome, never undone.
|
||||
void moveTab(QUuid id, int index);
|
||||
void newCanvas();
|
||||
// Each `done` runs from the event loop, never inline.
|
||||
void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {});
|
||||
void close(QUuid id, std::function<void()> done = {});
|
||||
void removeTab(QUuid id);
|
||||
std::vector<std::shared_ptr<ProjectTab>> quitOrder() const;
|
||||
// Applies canvas edits and cancels dialogs before a quit.
|
||||
void settlePendingEdits(std::function<void()> done);
|
||||
void confirmQuit(std::function<void(bool)> done);
|
||||
void closeWindow(QWidget *closing);
|
||||
void receive(const QList<QUrl> &urls, std::optional<QUuid> destination = std::nullopt,
|
||||
|
||||
Reference in new issue
Block a user