OmaPhoto 1.0.0: the Linux port of Compositor
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:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -0,0 +1,33 @@
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "Logging.h"
|
||||
#include "UI/OmarchyTheme.h"
|
||||
#include "UI/ProjectWorkspaceView.h"
|
||||
#include "UI/SliderSnap.h"
|
||||
#include <QApplication>
|
||||
#include <QUrl>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
qSetMessagePattern(QStringLiteral("%{time yyyy-MM-dd hh:mm:ss.zzz} %{type} %{category}: %{message}"));
|
||||
QApplication application(argc, argv);
|
||||
QApplication::setApplicationName(QStringLiteral("OmaPhoto"));
|
||||
QApplication::setApplicationVersion(QStringLiteral(OMAPHOTO_VERSION));
|
||||
// The desktop entry's name: icons and windows find each other.
|
||||
QGuiApplication::setDesktopFileName(QStringLiteral("io.github.ZacharyZhang_NY.OmaPhoto"));
|
||||
qCInfo(lcApp).noquote() << "OmaPhoto" << OMAPHOTO_VERSION << "on Qt" << qVersion()
|
||||
<< "platform" << QGuiApplication::platformName();
|
||||
// The desktop's colours, retinted when the theme switches.
|
||||
OmarchyTheme theme;
|
||||
// Slider knobs snap to a click on the track.
|
||||
SliderSnap::install();
|
||||
ProjectWorkspace workspace;
|
||||
ProjectWorkspaceView window(workspace);
|
||||
window.show();
|
||||
// Files named at launch, as Finder's Open With gives them.
|
||||
QList<QUrl> files;
|
||||
for (const QString &argument : QApplication::arguments().mid(1))
|
||||
files << QUrl::fromLocalFile(argument);
|
||||
if (!files.isEmpty())
|
||||
workspace.receive(files);
|
||||
return QApplication::exec();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#include "ContentView.h"
|
||||
#include "UI/EffectsSheet.h"
|
||||
#include "UI/FilterSheet.h"
|
||||
#include "UI/HueSaturationSheet.h"
|
||||
#include "UI/LevelsSheet.h"
|
||||
#include <QFileDialog>
|
||||
#include <QMessageBox>
|
||||
|
||||
void ContentView::synchronizePanels()
|
||||
{
|
||||
// A panel opens with its edit; close and Escape cancel.
|
||||
if (m_levelsShown != m_session.levels().has_value()) {
|
||||
m_levelsShown = m_session.levels().has_value();
|
||||
if (m_levelsShown) {
|
||||
m_levelsPanel.onClose = [this] { m_session.cancelLevels(); };
|
||||
m_levelsPanel.show(QStringLiteral("Levels"), new LevelsSheet(m_session));
|
||||
} else {
|
||||
m_levelsPanel.close();
|
||||
}
|
||||
}
|
||||
if (m_hueSaturationShown != m_session.hueSaturation().has_value()) {
|
||||
m_hueSaturationShown = m_session.hueSaturation().has_value();
|
||||
if (m_hueSaturationShown) {
|
||||
m_adjustmentPanel.onClose = [this] { m_session.cancelHueSaturation(); };
|
||||
m_adjustmentPanel.show(QStringLiteral("Hue/Saturation"), new HueSaturationSheet(m_session));
|
||||
} else {
|
||||
m_adjustmentPanel.close();
|
||||
}
|
||||
}
|
||||
if (m_filterShown != m_session.filterEdit().has_value()) {
|
||||
m_filterShown = m_session.filterEdit().has_value();
|
||||
if (m_filterShown) {
|
||||
m_filterPanel.onClose = [this] { m_session.cancelFilter(); };
|
||||
m_filterPanel.show(rawValue(m_session.filterEdit()->kind), new FilterSheet(m_session));
|
||||
} else {
|
||||
m_filterPanel.close();
|
||||
}
|
||||
}
|
||||
// Swift's onChange of the layers: a vanished effect's panel goes.
|
||||
if (const std::optional<LayerEffectSelection> editing = m_session.effectsEditing()) {
|
||||
const std::vector<ImageLayer> &layers = m_session.document() ? m_session.document()->layers : std::vector<ImageLayer>();
|
||||
const int index = indexOf(layers, editing->layerID);
|
||||
if (index < 0 || !layers[size_t(index)].effects || !layers[size_t(index)].effects->contains(editing->kind)) {
|
||||
if (m_session.colorPicker() && m_session.colorPicker()->target.kind == ColorPickerTarget::Kind::effect)
|
||||
m_session.closeColorPicker(false);
|
||||
m_session.setEffectsEditing(std::nullopt);
|
||||
m_session.setEffectsEditingOriginal(std::nullopt);
|
||||
}
|
||||
}
|
||||
// Swift's onChange of effectsEditing: a panel for each choice.
|
||||
if (m_effectsShown != m_session.effectsEditing()) {
|
||||
m_effectsShown = m_session.effectsEditing();
|
||||
if (m_effectsShown) {
|
||||
m_effectsPanel.onClose = [this] { m_session.finishEffectsEditing(false); };
|
||||
m_effectsPanel.show(rawValue(m_effectsShown->kind), new EffectsSheet(m_session, m_effectsShown->kind));
|
||||
} else {
|
||||
m_effectsPanel.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ContentView::showImporter()
|
||||
{
|
||||
if (!m_session.showsImporter()) {
|
||||
// Dismissed from elsewhere, as a cleared binding dismisses Swift's.
|
||||
if (QFileDialog *panel = std::exchange(m_importer, nullptr))
|
||||
panel->reject();
|
||||
return;
|
||||
}
|
||||
if (m_importer)
|
||||
return;
|
||||
m_importer = new QFileDialog(this);
|
||||
m_importer->setAttribute(Qt::WA_DeleteOnClose);
|
||||
m_importer->setFileMode(QFileDialog::ExistingFiles);
|
||||
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff)"));
|
||||
connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) {
|
||||
// Ours no longer: the flag must not reject it again.
|
||||
if (m_importer != panel)
|
||||
return;
|
||||
m_importer = nullptr;
|
||||
m_session.setShowsImporter(false);
|
||||
if (result == QDialog::Accepted)
|
||||
m_session.importImages(panel->selectedUrls());
|
||||
});
|
||||
m_importer->open();
|
||||
}
|
||||
|
||||
void ContentView::showAlert(QPointer<QMessageBox> &alert, const QString &title, const std::optional<QString> &message,
|
||||
const std::function<void()> &dismiss)
|
||||
{
|
||||
if (!message) {
|
||||
if (QMessageBox *shown = std::exchange(alert, nullptr))
|
||||
shown->reject();
|
||||
return;
|
||||
}
|
||||
// SwiftUI's alert shows its message as it stands.
|
||||
if (alert) {
|
||||
alert->setInformativeText(*message);
|
||||
return;
|
||||
}
|
||||
alert = new QMessageBox(this);
|
||||
alert->setAttribute(Qt::WA_DeleteOnClose);
|
||||
alert->setIcon(QMessageBox::Warning);
|
||||
alert->setText(title);
|
||||
alert->setInformativeText(*message);
|
||||
alert->addButton(QStringLiteral("OK"), QMessageBox::AcceptRole);
|
||||
connect(alert, &QDialog::finished, this, [&alert, shown = alert.data(), dismiss] {
|
||||
if (alert != shown)
|
||||
return;
|
||||
alert = nullptr;
|
||||
dismiss();
|
||||
});
|
||||
alert->open();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "ContentView.h"
|
||||
#include <QLocale>
|
||||
#include <map>
|
||||
|
||||
// The status bar's words: zoom, and each tool's hint.
|
||||
QString ContentView::percent(double zoom)
|
||||
{
|
||||
QString number = QLocale(QLocale::English, QLocale::UnitedStates).toString(zoom * 100, 'f', 1);
|
||||
if (number.endsWith(QLatin1String(".0")))
|
||||
number.chop(2);
|
||||
return number + QLatin1Char('%');
|
||||
}
|
||||
|
||||
// The kind two tools' icons and hints follow.
|
||||
ToolIconKind ContentView::iconKind(NavigationTool tool) const
|
||||
{
|
||||
if (tool == NavigationTool::marquee && m_session.marqueeKind() == LassoKind::ellipse)
|
||||
return ToolIconKind::ellipse;
|
||||
if (tool == NavigationTool::lasso && m_session.lassoKind() == LassoKind::polygonal)
|
||||
return ToolIconKind::polygonal;
|
||||
if (tool == NavigationTool::brush && m_session.brushMode() == BrushToolMode::erase)
|
||||
return ToolIconKind::eraser;
|
||||
return ToolIconKind::plain;
|
||||
}
|
||||
|
||||
QString ContentView::hint(NavigationTool tool, ToolIconKind kind, BlurToolMode smear, ShapeKind shape)
|
||||
{
|
||||
if (tool == NavigationTool::marquee && kind == ToolIconKind::ellipse)
|
||||
return QStringLiteral("Drag an ellipse · Shift add · Alt subtract · Shift again mid-drag circle · Drag inside to move · Delete clears · Ctrl+D deselect");
|
||||
if (tool == NavigationTool::lasso && kind == ToolIconKind::polygonal)
|
||||
return QStringLiteral("Click corners · Click start, double-click or Enter to close · Delete removes corner · Escape cancel");
|
||||
if (tool == NavigationTool::brush && kind == ToolIconKind::eraser)
|
||||
return QStringLiteral("Drag to erase · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan");
|
||||
if (tool == NavigationTool::blur)
|
||||
return (smear == BlurToolMode::blur ? QStringLiteral("Drag to soften")
|
||||
: smear == BlurToolMode::smudge ? QStringLiteral("Drag to smudge")
|
||||
: QStringLiteral("Drag to push pixels"))
|
||||
+ QStringLiteral(" · [ ] size · Shift-[ ] hardness · 1–0 strength · Space to pan");
|
||||
if (tool == NavigationTool::shape)
|
||||
return QStringLiteral("Drag to draw a shape on a new layer · Shift %1 · Alt from center · Shift-U or Tab for the next shape · Escape cancel · Space to pan")
|
||||
.arg(shape == ShapeKind::line ? QStringLiteral("45°") : shape == ShapeKind::rectangle ? QStringLiteral("square") : QStringLiteral("circle"));
|
||||
// Swift's last branch: the Eyedropper shows Zoom's words too.
|
||||
static const std::map<NavigationTool, const char *> hints = {
|
||||
{NavigationTool::marquee, "Drag a rectangle · Shift add · Alt subtract · Shift again mid-drag square · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
|
||||
{NavigationTool::wand, "Click to select similar colors · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
|
||||
{NavigationTool::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · Ctrl+D deselect"},
|
||||
{NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"},
|
||||
{NavigationTool::cloneStamp, "Alt-click to set the source · Drag to clone · [ ] size · Shift-[ ] hardness · 1–0 opacity · Space to pan"},
|
||||
{NavigationTool::spotHealing, "Drag over blemishes to heal · [ ] size · Shift-[ ] hardness · Escape cancel · Space to pan"},
|
||||
{NavigationTool::type, "Drag a text box · Click text to edit · Drag box handles to resize · Ctrl+Return finish · Escape cancel"},
|
||||
{NavigationTool::gradient, "Drag to draw · Drag ends to adjust · Shift 45° · 1–0 opacity · Enter apply · Escape cancel"},
|
||||
{NavigationTool::crop, "Drag to crop · Enter apply · Escape cancel · Space to pan"},
|
||||
{NavigationTool::move, "Drag to move · Handles to resize · Circle to rotate · 1–0 layer opacity · Space to pan"},
|
||||
{NavigationTool::hand, "Drag to pan · Pinch to zoom"},
|
||||
{NavigationTool::idle, "No tool selected · Press a tool's key to pick one · Space to pan"},
|
||||
{NavigationTool::eyedropper, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"},
|
||||
{NavigationTool::zoom, "Click to zoom in · Alt-click to zoom out · Drag right or left to zoom smoothly · Space to pan"},
|
||||
};
|
||||
return QString::fromUtf8(hints.at(tool));
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
#include "ContentView.h"
|
||||
#include "UI/ColorPaletteControls.h"
|
||||
#include "Logging.h"
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "IO/ImageFileDrop.h"
|
||||
#include "UI/LayersPanel.h"
|
||||
#include "UI/BrushControls.h"
|
||||
#include "UI/CropControls.h"
|
||||
#include "UI/GradientControls.h"
|
||||
#include "UI/ShapeControls.h"
|
||||
#include "UI/TypeControls.h"
|
||||
#include "UI/LassoControls.h"
|
||||
#include "UI/NavigationToolHeader.h"
|
||||
#include "UI/TransformInspector.h"
|
||||
#include "UI/NewCanvasSheet.h"
|
||||
#include "UI/ToolHeaderStyle.h"
|
||||
#include "UI/ToolIcons.h"
|
||||
#include <QCheckBox>
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QFrame>
|
||||
#include <QLocale>
|
||||
#include <QPainter>
|
||||
#include <QProgressBar>
|
||||
#include <QMouseEvent>
|
||||
#include <QScrollArea>
|
||||
#include <QSettings>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
|
||||
namespace {
|
||||
const NavigationTool railTools[] = {NavigationTool::move, NavigationTool::marquee, NavigationTool::lasso, NavigationTool::wand,
|
||||
NavigationTool::crop, NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::cloneStamp,
|
||||
NavigationTool::blur, NavigationTool::gradient, NavigationTool::shape, NavigationTool::type,
|
||||
NavigationTool::eyedropper, NavigationTool::hand, NavigationTool::zoom};
|
||||
|
||||
// Swift's Eyedropper bar: its title and the ring's checkbox.
|
||||
ToolHeaderBar *eyedropperBar(EditorSession &session, QWidget *parent)
|
||||
{
|
||||
auto *bar = new ToolHeaderBar(QStringLiteral("Eyedropper"), parent);
|
||||
auto *ring = new QCheckBox(QStringLiteral("Sample Ring"), bar);
|
||||
ring->setObjectName(QStringLiteral("sampleRing"));
|
||||
ring->setChecked(session.showsSampleRing());
|
||||
QObject::connect(ring, &QCheckBox::toggled, &session, &EditorSession::setShowsSampleRing);
|
||||
// It follows the session, which others may set.
|
||||
QObject::connect(&session, &EditorSession::changed, ring, [ring, &session] {
|
||||
const QSignalBlocker quiet(ring);
|
||||
ring->setChecked(session.showsSampleRing());
|
||||
});
|
||||
bar->row->setSpacing(16);
|
||||
bar->row->insertWidget(1, ring);
|
||||
return bar;
|
||||
}
|
||||
}
|
||||
|
||||
// A rail button: its tool's icon; a plate when chosen.
|
||||
class ToolButton : public QToolButton {
|
||||
public:
|
||||
ToolButton(NavigationTool tool, QWidget *parent) : QToolButton(parent), m_tool(tool)
|
||||
{
|
||||
setCheckable(true);
|
||||
setFixedSize(36, 36);
|
||||
setFocusPolicy(Qt::NoFocus);
|
||||
setToolTip(label(tool));
|
||||
setAccessibleName(label(tool));
|
||||
}
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
QColor ink = palette().color(QPalette::WindowText);
|
||||
if (isChecked()) {
|
||||
QColor plate = ink, edge = ink;
|
||||
plate.setAlphaF(0.12);
|
||||
edge.setAlphaF(0.14);
|
||||
painter.setPen(edge);
|
||||
painter.setBrush(plate);
|
||||
painter.drawRoundedRect(QRectF(0.5, 0.5, 35, 35), 7, 7);
|
||||
}
|
||||
ToolIcons::paint(painter, m_tool, QPointF(9, 9), ToolIcons::points, ink, m_kind);
|
||||
}
|
||||
|
||||
public:
|
||||
// The Marquee's and the Lasso's icons follow their kinds.
|
||||
void setKind(ToolIconKind kind)
|
||||
{
|
||||
if (kind == m_kind)
|
||||
return;
|
||||
m_kind = kind;
|
||||
update();
|
||||
}
|
||||
|
||||
private:
|
||||
const NavigationTool m_tool;
|
||||
ToolIconKind m_kind = ToolIconKind::plain;
|
||||
};
|
||||
|
||||
namespace {
|
||||
// Swift's PanelResizeEdge: a divider with an 8-point grip.
|
||||
class PanelResizeEdge : public QWidget {
|
||||
public:
|
||||
PanelResizeEdge(LayersPanel &panel, QWidget *parent) : QWidget(parent), m_panel(panel)
|
||||
{
|
||||
setObjectName(QStringLiteral("layersPanelEdge"));
|
||||
setFixedWidth(8);
|
||||
setCursor(Qt::SplitHCursor);
|
||||
setToolTip(QStringLiteral("Drag to resize the panel"));
|
||||
}
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.fillRect(QRect(width() / 2, 0, 1, height()), palette().color(QPalette::Mid));
|
||||
}
|
||||
void mousePressEvent(QMouseEvent *event) override
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
m_start = std::pair(event->globalPosition().x(), double(m_panel.width()));
|
||||
}
|
||||
void mouseMoveEvent(QMouseEvent *event) override
|
||||
{
|
||||
if (!m_start)
|
||||
return;
|
||||
// Dragged left, the panel widens; whole points, within range.
|
||||
const double wanted = std::round(m_start->second - (event->globalPosition().x() - m_start->first));
|
||||
const double width = std::min(LayersPanel::maximumWidth, std::max(LayersPanel::minimumWidth, wanted));
|
||||
if (width == m_panel.width())
|
||||
return;
|
||||
m_panel.setFixedWidth(int(width));
|
||||
ContentView::setLayersPanelWidth(width);
|
||||
}
|
||||
void mouseReleaseEvent(QMouseEvent *) override { m_start = std::nullopt; }
|
||||
|
||||
private:
|
||||
LayersPanel &m_panel;
|
||||
std::optional<std::pair<double, double>> m_start;
|
||||
};
|
||||
|
||||
// Swift's overlay: a 3-point accent ring inside the canvas.
|
||||
class DropRing : public QWidget {
|
||||
public:
|
||||
explicit DropRing(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
setObjectName(QStringLiteral("dropRing"));
|
||||
setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
hide();
|
||||
}
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.setPen(QPen(palette().color(QPalette::Highlight), 3));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawRoundedRect(QRectF(rect()).adjusted(4.5, 4.5, -4.5, -4.5), 8, 8);
|
||||
}
|
||||
};
|
||||
|
||||
QFrame *divider(QFrame::Shape shape, QWidget *parent)
|
||||
{
|
||||
auto *line = new QFrame(parent);
|
||||
line->setFrameShape(shape);
|
||||
line->setFrameShadow(QFrame::Plain);
|
||||
line->setForegroundRole(QPalette::Mid);
|
||||
return line;
|
||||
}
|
||||
|
||||
QLabel *statusText(QLabel *label)
|
||||
{
|
||||
QFont font = label->font();
|
||||
font.setPixelSize(11);
|
||||
label->setFont(font);
|
||||
label->setForegroundRole(QPalette::PlaceholderText);
|
||||
return label;
|
||||
}
|
||||
|
||||
// A row's widest item shrinks first; this ends in dots.
|
||||
class ElidedLabel : public QLabel {
|
||||
public:
|
||||
using QLabel::QLabel;
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override
|
||||
{
|
||||
// A widget's painter starts with its foreground role's pen.
|
||||
QPainter painter(this);
|
||||
painter.drawText(rect(), Qt::AlignRight | Qt::AlignVCenter, fontMetrics().elidedText(text(), Qt::ElideRight, width()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent)
|
||||
: QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout),
|
||||
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
|
||||
m_activity(statusText(new ElidedLabel(this))),
|
||||
m_spinner(new QProgressBar(this))
|
||||
{
|
||||
setMinimumSize(800, 520);
|
||||
m_column->setContentsMargins(0, 0, 0, 0);
|
||||
m_column->setSpacing(0);
|
||||
|
||||
auto *tools = new QWidget(this);
|
||||
auto *toolColumn = new QVBoxLayout(tools);
|
||||
// Room for the palette's overhang; the tools stay centred.
|
||||
toolColumn->setContentsMargins(7, 16, 7, 12);
|
||||
toolColumn->setSpacing(10);
|
||||
for (const NavigationTool tool : railTools) {
|
||||
auto *button = new ToolButton(tool, tools);
|
||||
// A refused click stays silent: set the buttons again.
|
||||
connect(button, &QToolButton::clicked, this, [this, tool] {
|
||||
m_session.selectTool(tool);
|
||||
synchronize();
|
||||
});
|
||||
toolColumn->addWidget(button, 0, Qt::AlignHCenter);
|
||||
m_toolButtons.push_back({tool, button});
|
||||
}
|
||||
// Swift's 8 points above the swatches, less their overhang.
|
||||
toolColumn->addSpacing(5);
|
||||
toolColumn->addWidget(new ColorPaletteControls(session, tools), 0, Qt::AlignHCenter);
|
||||
toolColumn->addStretch(1);
|
||||
// Too short a window scrolls the rail, not the bars.
|
||||
auto *rail = new QScrollArea(this);
|
||||
rail->setObjectName(QStringLiteral("toolRail"));
|
||||
rail->setWidget(tools);
|
||||
rail->setWidgetResizable(true);
|
||||
rail->setFixedWidth(56);
|
||||
rail->setFrameShape(QFrame::NoFrame);
|
||||
rail->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
rail->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
auto *canvas = new QWidget(this);
|
||||
canvas->setLayout(m_canvasSlot);
|
||||
// Swift's canvas fills its area: no layout gutter.
|
||||
m_canvasSlot->setContentsMargins(0, 0, 0, 0);
|
||||
// The welcome sits over the canvas, as Swift's ZStack.
|
||||
m_canvasSlot->addWidget(m_canvas, 0, 0);
|
||||
m_canvasSlot->addWidget(m_dropRing, 0, 0);
|
||||
setAcceptDrops(true);
|
||||
|
||||
auto *middle = new QHBoxLayout;
|
||||
middle->setSpacing(0);
|
||||
middle->addWidget(rail);
|
||||
middle->addWidget(divider(QFrame::VLine, this));
|
||||
middle->addWidget(canvas, 1);
|
||||
m_layersPanel->setFixedWidth(int(layersPanelWidth()));
|
||||
middle->addWidget(new PanelResizeEdge(*m_layersPanel, this));
|
||||
middle->addWidget(m_layersPanel);
|
||||
|
||||
m_zoom->setObjectName(QStringLiteral("zoomStatus"));
|
||||
m_zoom->setFixedWidth(62);
|
||||
m_dimensions->setObjectName(QStringLiteral("canvasDimensions"));
|
||||
m_colour->setObjectName(QStringLiteral("colourStatus"));
|
||||
m_activity->setObjectName(QStringLiteral("activityStatus"));
|
||||
m_spinner->setRange(0, 0);
|
||||
m_spinner->setTextVisible(false);
|
||||
m_spinner->setFixedSize(36, 6);
|
||||
auto *status = new QWidget(this);
|
||||
status->setObjectName(QStringLiteral("statusBar"));
|
||||
status->setFixedHeight(30);
|
||||
auto *statusRow = new QHBoxLayout(status);
|
||||
statusRow->setContentsMargins(18, 0, 18, 0);
|
||||
statusRow->setSpacing(16);
|
||||
statusRow->addWidget(m_zoom);
|
||||
statusRow->addWidget(m_dimensions);
|
||||
statusRow->addWidget(m_colour);
|
||||
statusRow->addWidget(m_spinner, 0, Qt::AlignRight);
|
||||
// The hint takes what is left, and shrinks first.
|
||||
statusRow->addWidget(m_activity, 1);
|
||||
|
||||
m_column->addWidget(divider(QFrame::HLine, this));
|
||||
m_column->addLayout(middle, 1);
|
||||
m_column->addWidget(divider(QFrame::HLine, this));
|
||||
m_column->addWidget(status);
|
||||
|
||||
// The filter panel follows the session by itself.
|
||||
connect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
|
||||
synchronize();
|
||||
}
|
||||
|
||||
// ~QWidget closes the window after members go; stop listening first.
|
||||
ContentView::~ContentView()
|
||||
{
|
||||
disconnect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
|
||||
}
|
||||
|
||||
// Another tab's layer, files or a picture, while free.
|
||||
bool ContentView::acceptsDrop(const QMimeData &data) const
|
||||
{
|
||||
if (!(data.hasFormat(ProjectWorkspace::layerType) || ImageFileDrop::holdsImages(data)))
|
||||
return false;
|
||||
// Swift's session guards; a workspace's are canSwitch's own.
|
||||
if (!m_workspace)
|
||||
return !m_session.levels() && !m_session.isProjectBusy() && !m_session.showsNewDocument() && !m_session.showsImporter()
|
||||
&& !m_session.renamingLayerID();
|
||||
return m_workspace->canSwitch() && m_workspace->canReceiveDrag(data, m_workspace->current().id);
|
||||
}
|
||||
|
||||
void ContentView::dragEnterEvent(QDragEnterEvent *event)
|
||||
{
|
||||
if (!acceptsDrop(*event->mimeData()))
|
||||
return;
|
||||
event->setDropAction(Qt::CopyAction);
|
||||
event->accept();
|
||||
m_dropRing->raise();
|
||||
m_dropRing->show();
|
||||
}
|
||||
|
||||
void ContentView::dragMoveEvent(QDragMoveEvent *event)
|
||||
{
|
||||
if (!acceptsDrop(*event->mimeData())) {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
event->setDropAction(Qt::CopyAction);
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void ContentView::dragLeaveEvent(QDragLeaveEvent *)
|
||||
{
|
||||
m_dropRing->hide();
|
||||
}
|
||||
|
||||
void ContentView::dropEvent(QDropEvent *event)
|
||||
{
|
||||
m_dropRing->hide();
|
||||
if (!acceptsDrop(*event->mimeData()))
|
||||
return;
|
||||
event->setDropAction(Qt::CopyAction);
|
||||
event->accept();
|
||||
// Over the canvas the drop names a document point.
|
||||
std::optional<QPointF> point;
|
||||
const QPointF inCanvas = m_canvas->mapFrom(this, event->position().toPoint());
|
||||
if (m_session.document() && m_canvas->rect().contains(inCanvas.toPoint()))
|
||||
point = m_session.viewport.documentPoint(inCanvas, m_session.document()->size());
|
||||
if (m_workspace)
|
||||
m_workspace->receiveProviders(*event->mimeData(), m_workspace->current().id, point);
|
||||
else
|
||||
ImageFileDrop::importProviders(*event->mimeData(), m_session, point);
|
||||
}
|
||||
|
||||
double ContentView::layersPanelWidth()
|
||||
{
|
||||
const double stored = QSettings().value(QStringLiteral("layersPanelWidth"), LayersPanel::defaultWidth).toDouble();
|
||||
// A stored width outside the range is the default, logged.
|
||||
if (stored >= LayersPanel::minimumWidth && stored <= LayersPanel::maximumWidth)
|
||||
return stored;
|
||||
qCWarning(lcApp) << "layersPanelWidth" << stored << "is out of range, using" << LayersPanel::defaultWidth;
|
||||
return LayersPanel::defaultWidth;
|
||||
}
|
||||
|
||||
void ContentView::setLayersPanelWidth(double width)
|
||||
{
|
||||
QSettings().setValue(QStringLiteral("layersPanelWidth"), width);
|
||||
}
|
||||
|
||||
// Shows what the session holds: every line compares, then sets.
|
||||
void ContentView::synchronize()
|
||||
{
|
||||
m_canvas->consumeFocusRequest(m_session.canvasFocusRequest());
|
||||
m_canvas->synchronizeDisplay();
|
||||
if (m_session.document() && !m_hadDocument)
|
||||
QMetaObject::invokeMethod(&m_session, &EditorSession::requestCanvasFocus, Qt::QueuedConnection);
|
||||
m_hadDocument = m_session.document().has_value();
|
||||
showHeader(m_session.tool());
|
||||
for (const auto &[tool, button] : m_toolButtons) {
|
||||
button->setChecked(m_session.tool() == tool);
|
||||
button->setKind(iconKind(tool));
|
||||
}
|
||||
const std::optional<CanvasDocument> &document = m_session.document();
|
||||
showWelcome(!document);
|
||||
m_zoom->setVisible(document.has_value());
|
||||
m_dimensions->setVisible(document.has_value());
|
||||
if (document) {
|
||||
const QLocale english(QLocale::English, QLocale::UnitedStates);
|
||||
m_zoom->setText(percent(m_session.viewport.zoom()));
|
||||
m_dimensions->setText(QStringLiteral("%1 × %2 px").arg(english.toString(document->width), english.toString(document->height)));
|
||||
}
|
||||
m_colour->setText(document ? QStringLiteral("sRGB · Transparent") : QStringLiteral("Ready when you are"));
|
||||
m_spinner->setVisible(m_session.showsBusy() || m_session.isImporting());
|
||||
m_activity->setText(m_session.showsBusy() ? QStringLiteral("Working…") : m_session.isImporting() ? QStringLiteral("Importing images…")
|
||||
: hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind()));
|
||||
showImporter();
|
||||
showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); });
|
||||
showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); });
|
||||
showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); });
|
||||
synchronizePanels();
|
||||
}
|
||||
|
||||
// Swift's `if document == nil` makes a fresh sheet.
|
||||
void ContentView::showWelcome(bool shown)
|
||||
{
|
||||
if (shown == (m_welcome != nullptr))
|
||||
return;
|
||||
delete m_welcome;
|
||||
m_welcome = nullptr;
|
||||
if (!shown)
|
||||
return;
|
||||
m_welcome = new NewCanvasSheet(
|
||||
m_session, [this](int width, int height) { m_session.createNewProject(width, height); },
|
||||
[this] {
|
||||
if (m_projects)
|
||||
m_projects->open();
|
||||
},
|
||||
this);
|
||||
m_canvasSlot->addWidget(m_welcome, 0, 0, Qt::AlignCenter);
|
||||
m_welcome->show();
|
||||
}
|
||||
|
||||
void ContentView::showHeader(NavigationTool tool)
|
||||
{
|
||||
// Shared bars: Hand and Zoom, selection tools, brush tools.
|
||||
const auto kind = [](NavigationTool each) {
|
||||
return each == NavigationTool::hand || each == NavigationTool::zoom ? NavigationTool::hand
|
||||
: isSelectionTool(each) ? NavigationTool::lasso
|
||||
: isBrushTool(each) ? NavigationTool::brush
|
||||
: each;
|
||||
};
|
||||
if (m_shownTool && kind(*m_shownTool) == kind(tool))
|
||||
return;
|
||||
m_shownTool = tool;
|
||||
delete m_header;
|
||||
// A tool without a bar yet keeps the bar's height.
|
||||
if (tool == NavigationTool::idle)
|
||||
m_header = new ToolHeaderBar(QStringLiteral("Select a tool"), this);
|
||||
else if (tool == NavigationTool::eyedropper)
|
||||
m_header = eyedropperBar(m_session, this);
|
||||
else if (kind(tool) == NavigationTool::hand)
|
||||
m_header = new NavigationToolHeader(m_session, this);
|
||||
else if (tool == NavigationTool::move)
|
||||
m_header = new TransformInspector(m_session, this);
|
||||
else if (isSelectionTool(tool))
|
||||
m_header = new LassoControls(m_session, this);
|
||||
else if (isBrushTool(tool))
|
||||
m_header = new BrushControls(m_session, this);
|
||||
else if (tool == NavigationTool::gradient)
|
||||
m_header = new GradientControls(m_session, this);
|
||||
else if (tool == NavigationTool::shape)
|
||||
m_header = new ShapeControls(m_session, this);
|
||||
else if (tool == NavigationTool::type)
|
||||
m_header = new TypeControls(m_session, this);
|
||||
else if (tool == NavigationTool::crop)
|
||||
m_header = new CropControls(m_session, this);
|
||||
else
|
||||
m_header = new ToolHeaderBar(QString(), this);
|
||||
m_header->setObjectName(QStringLiteral("toolHeader"));
|
||||
m_column->insertWidget(0, m_header);
|
||||
// A layout shows a late child only later: show now.
|
||||
m_header->show();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ProjectController.h"
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "UI/FloatingPanel.h"
|
||||
#include "UI/ToolIcons.h"
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QPointer>
|
||||
#include <QStackedLayout>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
class LayersPanel;
|
||||
class ToolButton;
|
||||
class ProjectWorkspace;
|
||||
class QFileDialog;
|
||||
class QMimeData;
|
||||
class QMessageBox;
|
||||
class QProgressBar;
|
||||
|
||||
// One project's editor area: bar, rail, canvas, status.
|
||||
class ContentView : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// Without a controller, Open project has nowhere to go.
|
||||
explicit ContentView(EditorSession &session, ProjectController *projects = nullptr, QWidget *parent = nullptr);
|
||||
~ContentView() override;
|
||||
|
||||
// The status bar's words for a tool, by its kind.
|
||||
static QString hint(NavigationTool tool, ToolIconKind kind = ToolIconKind::plain, BlurToolMode smear = BlurToolMode::liquify,
|
||||
ShapeKind shape = ShapeKind::rectangle);
|
||||
// Zoom as Swift prints it: percent, one decimal at most.
|
||||
static QString percent(double zoom);
|
||||
// The Layers panel's width, remembered across launches.
|
||||
static double layersPanelWidth();
|
||||
static void setLayersPanelWidth(double width);
|
||||
LayersPanel &layersPanel() const { return *m_layersPanel; }
|
||||
// Drops of layers from other tabs go to the workspace.
|
||||
void setWorkspace(ProjectWorkspace *workspace) { m_workspace = workspace; }
|
||||
bool acceptsDrop(const QMimeData &data) const;
|
||||
|
||||
protected:
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||
void dragLeaveEvent(QDragLeaveEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
|
||||
private:
|
||||
void synchronize();
|
||||
// Swift's onChange of levels: its panel opens and closes.
|
||||
void synchronizePanels();
|
||||
ToolIconKind iconKind(NavigationTool tool) const;
|
||||
void showHeader(NavigationTool tool);
|
||||
void showWelcome(bool shown);
|
||||
void showImporter();
|
||||
void showAlert(QPointer<QMessageBox> &alert, const QString &title, const std::optional<QString> &message,
|
||||
const std::function<void()> &dismiss);
|
||||
|
||||
EditorSession &m_session;
|
||||
const QPointer<ProjectController> m_projects;
|
||||
QVBoxLayout *const m_column;
|
||||
QWidget *m_header = nullptr;
|
||||
std::optional<NavigationTool> m_shownTool;
|
||||
std::vector<std::pair<NavigationTool, ToolButton *>> m_toolButtons;
|
||||
QGridLayout *const m_canvasSlot;
|
||||
CanvasView *const m_canvas;
|
||||
LayersPanel *const m_layersPanel;
|
||||
// An accent ring while a drop may land.
|
||||
QWidget *const m_dropRing;
|
||||
ProjectWorkspace *m_workspace = nullptr;
|
||||
// A document that appears hands the canvas the keys.
|
||||
bool m_hadDocument = false;
|
||||
QWidget *m_welcome = nullptr;
|
||||
QLabel *const m_zoom;
|
||||
QLabel *const m_dimensions;
|
||||
QLabel *const m_colour;
|
||||
QLabel *const m_activity;
|
||||
QProgressBar *const m_spinner;
|
||||
QPointer<QFileDialog> m_importer;
|
||||
QPointer<QMessageBox> m_importAlert;
|
||||
QPointer<QMessageBox> m_brushAlert;
|
||||
QPointer<QMessageBox> m_cropAlert;
|
||||
FloatingPanel m_levelsPanel{QStringLiteral("levelsPanel"), *this};
|
||||
bool m_levelsShown = false;
|
||||
FloatingPanel m_adjustmentPanel{QStringLiteral("adjustmentPanel"), *this};
|
||||
bool m_hueSaturationShown = false;
|
||||
FloatingPanel m_filterPanel{QStringLiteral("filterPanel"), *this};
|
||||
bool m_filterShown = false;
|
||||
FloatingPanel m_effectsPanel{QStringLiteral("effectsPanel"), *this};
|
||||
std::optional<LayerEffectSelection> m_effectsShown;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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> ¤t) 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> ¤t)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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> ¤t) const;
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
QString name;
|
||||
Snapshot before;
|
||||
Snapshot after;
|
||||
};
|
||||
|
||||
void trim(const std::optional<CanvasDocument> ¤t);
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user