12.1: feathered selections, Select Subject, Soft Light, the fixed-width font menu (Compositor 1.1.1–1.1.4)

This commit is contained in:
雷电芽衣 committed 2026-09-26 17:15:26 -04:00
1 parent 3e52fd90ee
commit ee34c338d4
42 files changed
+1087 -94

No files matched your search

+2 -2
View File
@@ -4,8 +4,8 @@
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;
return !m_selectionAmountOperation && !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)
+9
View File
@@ -56,6 +56,15 @@ struct SessionJobs {
std::optional<QPainterPath> path;
std::optional<QString> failure;
};
struct Subjecting {
SelectionMode mode;
QUuid documentID;
std::function<void()> done;
};
struct Subjected {
std::optional<QImage> mask;
std::optional<QString> failure;
};
// The last stroke's end, for a Shift-click's line.
struct LastBrushPoint {
QPointF point;
+5 -3
View File
@@ -16,6 +16,7 @@ EditorSession::EditorSession(QObject *parent) : QObject(parent), m_busyTimer(thi
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_subject, &QFutureWatcher<Subjected>::finished, this, &EditorSession::finishSubject);
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);
@@ -183,8 +184,8 @@ void EditorSession::cycleToolMode()
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;
return !m_selectionAmountOperation && !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()
@@ -241,7 +242,8 @@ void EditorSession::endEdit()
bool EditorSession::canEditLayers() const
{
return !m_textDraft && m_document && !m_brushStroke && !m_warpStroke && !m_isProjectBusy && !m_isImporting && !m_showsNewDocument && !m_showsImporter
return !m_selectionAmountOperation && !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;
}
+10
View File
@@ -139,6 +139,11 @@ public:
void setSelectionExpandAmount(int amount);
int selectionContractAmount() const { return m_selectionContractAmount; }
void setSelectionContractAmount(int amount);
int selectionFeatherAmount() const { return m_selectionFeatherAmount; }
void setSelectionFeatherAmount(int amount);
// The amount sheet's question, open while set.
std::optional<SelectionAmountOperation> selectionAmountOperation() const { return m_selectionAmountOperation; }
void setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation);
const std::optional<DocumentSelection> &selectionMoveOrigin() const { return m_selectionMoveOrigin; }
const WandSettings &wandSettings() const { return m_wandSettings; }
void setWandSettings(const WandSettings &settings);
@@ -267,6 +272,7 @@ public:
#include "Document/LayerFlip+Session.h"
#include "Document/Selection+Session.h"
#include "Document/MagicWand+Session.h"
#include "Document/SubjectRemoval+Session.h"
#include "Document/EditorSession+Brush.h"
#include "Document/CloneStamp+Session.h"
#include "Document/ColorPalette+Session.h"
@@ -356,6 +362,8 @@ private:
WandSettings m_wandSettings;
std::optional<Wanding> m_wanding;
QFutureWatcher<Wanded> m_wand;
std::optional<Subjecting> m_subjecting;
QFutureWatcher<Subjected> m_subject;
std::unique_ptr<BrushStroke> m_brushStroke;
std::unique_ptr<PixelMove> m_pixelMove;
BrushSettings m_brushSettings;
@@ -434,4 +442,6 @@ private:
bool m_selectionAntialiased = true;
int m_selectionExpandAmount = 1;
int m_selectionContractAmount = 1;
int m_selectionFeatherAmount = 2;
std::optional<SelectionAmountOperation> m_selectionAmountOperation;
};
+2 -2
View File
@@ -122,9 +122,9 @@ void EditorSession::mergeFloatingTransform(const TransformEdit &edit, const Floa
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};
moved = DocumentSelection{*path, current->antialiased, current->feather};
} else if (const std::optional<QTransform> shift = floatingSelectionTransform(edit); current && shift) {
moved = DocumentSelection{shift->map(current->path), current->antialiased};
moved = DocumentSelection{shift->map(current->path), current->antialiased, current->feather};
}
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)];
+2 -2
View File
@@ -6,9 +6,9 @@
#include <utility>
namespace {
const std::array<std::pair<LayerBlendMode, const char *>, 13> names{{
const std::array<std::pair<LayerBlendMode, const char *>, 14> names{{
{LayerBlendMode::normal, "Normal"}, {LayerBlendMode::multiply, "Multiply"}, {LayerBlendMode::screen, "Screen"},
{LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::darken, "Darken"}, {LayerBlendMode::lighten, "Lighten"},
{LayerBlendMode::overlay, "Overlay"}, {LayerBlendMode::softLight, "Soft Light"}, {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"},
+2 -2
View File
@@ -4,14 +4,14 @@
#include <optional>
enum class LayerBlendMode {
normal, multiply, screen, overlay, darken, lighten, difference,
normal, multiply, screen, overlay, softLight, 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::softLight, LayerBlendMode::darken, LayerBlendMode::lighten, LayerBlendMode::difference, LayerBlendMode::colorDodge,
LayerBlendMode::colorBurn, LayerBlendMode::hue, LayerBlendMode::saturation, LayerBlendMode::color,
LayerBlendMode::luminosity};
+6 -6
View File
@@ -1,5 +1,6 @@
#include "Document/Distort.h"
#include "Document/LayerMask.h"
#include "Document/PixelAdjust.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "IO/ImageExporter.h"
@@ -281,12 +282,11 @@ void EditorSession::addMask(bool revealing)
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);
// The selection's coverage, soft where it is feathered.
QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height,
BrushRaster::pixelToDocument(active->transform, width, height));
if (revealing)
image.invertPixels();
const LayerMask mask(LayerMask::assetFrom(image));
finishOpacityEdit();
beginEdit(QStringLiteral("Add Mask from Selection"));
+5
View File
@@ -89,6 +89,11 @@ std::optional<QPainterPath> MaskTracing::darkPixels(const QImage &image)
return trace(image, false, [](uchar value) { return value < 128; });
}
std::optional<QPainterPath> MaskTracing::whitePixels(const QImage &image)
{
return trace(image, false, [](uchar value) { return value >= 128; });
}
std::optional<QPainterPath> MaskTracing::opaquePixels(const QImage &image)
{
return trace(image, true, [](uchar value) { return value >= 128; });
+2
View File
@@ -6,5 +6,7 @@
// Raster coverage traced into an outline along exact pixel edges.
namespace MaskTracing {
std::optional<QPainterPath> darkPixels(const QImage &image);
// A mask's pixels at least half white: what it shows.
std::optional<QPainterPath> whitePixels(const QImage &image);
std::optional<QPainterPath> opaquePixels(const QImage &image);
}
+5
View File
@@ -31,6 +31,11 @@ public:
bool canModifySelection() const;
void expandSelection(int amount);
void contractSelection(int amount);
// Menu commands ask an amount; the bar applies directly.
void promptSelectionAmount(SelectionAmountOperation operation);
void confirmSelectionAmount(int amount);
// Softens the edge further each time, as blurs stack.
void featherSelection(int amount);
void selectAll();
void deselect();
void invertSelection();
+87 -12
View File
@@ -1,6 +1,7 @@
#include "Document/Selection.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/PixelAdjust.h"
#include <QPainterPathStroker>
#include <cmath>
#include <stdexcept>
@@ -40,6 +41,19 @@ QString rawValue(LassoKind kind)
throw std::logic_error("unknown lasso kind");
}
QString rawValue(SelectionAmountOperation operation)
{
switch (operation) {
case SelectionAmountOperation::expand:
return QStringLiteral("Expand");
case SelectionAmountOperation::contract:
return QStringLiteral("Contract");
case SelectionAmountOperation::feather:
return QStringLiteral("Feather");
}
throw std::logic_error("unknown selection amount operation");
}
QString rawValue(SelectionMode mode)
{
switch (mode) {
@@ -61,22 +75,31 @@ bool DocumentSelection::isEmpty() const
QImage DocumentSelection::coverage(int width, int height) const
{
QImage image = BrushRaster::context(width, height, true);
QPainter painter(&image);
fill(painter, path, antialiased);
return image;
{
QPainter painter(&image);
fill(painter, path, antialiased || feather > 0);
}
if (!(feather > 0))
return image;
// A feathered edge fades either side of the outline.
return PixelAdjust::gaussianBlur(image, feather / 2, true);
}
QRectF DocumentSelection::coverageBounds() const
{
// Four standard deviations keep the falloff past the outline.
const double reach = std::ceil(feather * 2);
return path.boundingRect().adjusted(-reach, -reach, reach, reach);
}
SelectionClip DocumentSelection::clip(QSizeF canvas) const
{
const QRect region = path.boundingRect().adjusted(-1, -1, 1, 1).toAlignedRect()
const QRect region = coverageBounds().adjusted(-1, -1, 1, 1).toAlignedRect()
.intersected(QRect(0, 0, int(canvas.width()), int(canvas.height())));
if (isEmpty() || region.isEmpty())
return SelectionClip{QRectF(), std::nullopt};
QImage image = BrushRaster::context(region.width(), region.height(), true);
QPainter painter(&image);
painter.translate(-region.x(), -region.y());
fill(painter, path, antialiased);
return SelectionClip{QRectF(region), image};
const DocumentSelection local{path.translated(-region.x(), -region.y()), antialiased, feather};
return SelectionClip{QRectF(region), local.coverage(region.width(), region.height())};
}
QRectF DragBox::rect(QPointF anchor, QPointF point, bool square, bool fromCenter)
@@ -162,6 +185,19 @@ void EditorSession::setSelectionContractAmount(int amount)
notify();
}
void EditorSession::setSelectionFeatherAmount(int amount)
{
m_selectionFeatherAmount = amount;
notify();
}
void EditorSession::setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation)
{
m_selectionAmountOperation = operation;
resumeFileRequests();
notify();
}
void EditorSession::beginLasso(QPointF point, SelectionMode mode)
{
// The Magic Wand selects with a click, never an outline.
@@ -344,7 +380,7 @@ void EditorSession::moveSelection(QSizeF offset)
return;
// Whole pixels, so edges stay crisp; not re-clipped.
m_document->selection = DocumentSelection{m_selectionMoveOrigin->path.translated(std::round(offset.width()), std::round(offset.height())),
m_selectionMoveOrigin->antialiased};
m_selectionMoveOrigin->antialiased, m_selectionMoveOrigin->feather};
notify();
}
@@ -380,6 +416,45 @@ void EditorSession::contractSelection(int amount)
resizeSelection(-amount, QStringLiteral("Contract Selection"));
}
void EditorSession::promptSelectionAmount(SelectionAmountOperation operation)
{
if (!canModifySelection())
return;
setSelectionAmountOperation(operation);
}
void EditorSession::confirmSelectionAmount(int amount)
{
const std::optional<SelectionAmountOperation> operation = m_selectionAmountOperation;
if (!operation || amount < 1 || amount > (*operation == SelectionAmountOperation::feather ? 250 : 500))
return;
setSelectionAmountOperation(std::nullopt);
switch (*operation) {
case SelectionAmountOperation::expand:
setSelectionExpandAmount(amount);
expandSelection(amount);
break;
case SelectionAmountOperation::contract:
setSelectionContractAmount(amount);
contractSelection(amount);
break;
case SelectionAmountOperation::feather:
setSelectionFeatherAmount(amount);
featherSelection(amount);
break;
}
}
void EditorSession::featherSelection(int amount)
{
const std::optional<DocumentSelection> current = selection();
if (!canModifySelection() || !current || amount <= 0)
return;
// Two soft edges spread less than their sum.
const double softened = std::sqrt(current->feather * current->feather + double(amount) * amount);
setSelection(DocumentSelection{current->path, current->antialiased, std::min(250.0, softened)}, QStringLiteral("Feather Selection"));
}
void EditorSession::resizeSelection(double delta, const QString &name)
{
const std::optional<DocumentSelection> current = selection();
@@ -392,7 +467,7 @@ void EditorSession::resizeSelection(double delta, const QString &name)
stroker.setJoinStyle(Qt::RoundJoin);
const QPainterPath band = stroker.createStroke(current->path);
const QPainterPath result = delta > 0 ? current->path.united(band).intersected(canvasPath(*m_document)) : current->path.subtracted(band);
setSelection(DocumentSelection{result, current->antialiased}, name);
setSelection(DocumentSelection{result, current->antialiased, current->feather}, name);
}
void EditorSession::selectAll()
@@ -414,5 +489,5 @@ void EditorSession::invertSelection()
const std::optional<DocumentSelection> current = selection();
if (!m_document || !current)
return;
setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased}, QStringLiteral("Inverse"));
setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased, current->feather}, QStringLiteral("Inverse"));
}
+8
View File
@@ -18,11 +18,15 @@ struct SelectionClip {
struct DocumentSelection {
QPainterPath path;
bool antialiased = true;
// How far the edge fades, in pixels; 0 is hard.
double feather = 0;
// An explicit empty selection: later edits touch nothing.
bool isEmpty() const;
// Gray coverage at document resolution, white where selected.
QImage coverage(int width, int height) const;
// The outline's box, grown by the feather's visible falloff.
QRectF coverageBounds() const;
SelectionClip clip(QSizeF canvas) const;
friend bool operator==(const DocumentSelection &, const DocumentSelection &) = default;
};
@@ -35,6 +39,10 @@ QString rawValue(LassoKind kind);
enum class SelectionMode { replace, add, subtract };
QString rawValue(SelectionMode mode);
// What Select's Expand, Contract and Feather ask an amount for.
enum class SelectionAmountOperation { expand, contract, feather };
QString rawValue(SelectionAmountOperation operation);
// A drag's box in whole pixels; shared with Shape.
namespace DragBox {
QRectF rect(QPointF anchor, QPointF point, bool square, bool fromCenter);
+1 -1
View File
@@ -49,7 +49,7 @@ std::optional<QRectF> EditorSession::selectionCopyRegion() const
return std::nullopt;
const QRectF canvas(QPointF(0, 0), m_document->size());
const std::optional<DocumentSelection> current = selection();
const QRectF bounds = current ? current->path.boundingRect() : canvas;
const QRectF bounds = current ? current->coverageBounds() : canvas;
// Path operations leave float noise: round with a tolerance.
constexpr double tolerance = 0.001;
const double minX = std::floor(bounds.left() + tolerance), minY = std::floor(bounds.top() + tolerance);
+2 -2
View File
@@ -234,7 +234,7 @@ PixelMove::PixelMove(std::shared_ptr<BrushStroke> raster, DocumentSelection orig
DocumentSelection PixelMove::movedSelection() const
{
return DocumentSelection{QTransform::fromTranslate(offset.width(), offset.height()).map(origin.path), origin.antialiased};
return DocumentSelection{QTransform::fromTranslate(offset.width(), offset.height()).map(origin.path), origin.antialiased, origin.feather};
}
// Ctrl-drag: starts moving the selected pixels; false with none.
@@ -292,7 +292,7 @@ std::optional<DocumentSelection> EditorSession::displayedSelection() const
const std::optional<DocumentSelection> current = selection();
if (m_transformEdit && current) {
if (const std::optional<QTransform> matrix = floatingSelectionTransform(*m_transformEdit))
return DocumentSelection{matrix->map(current->path), current->antialiased};
return DocumentSelection{matrix->map(current->path), current->antialiased, current->feather};
}
return current;
}
+8
View File
@@ -0,0 +1,8 @@
// Swift's SubjectRemoval extension: Select → Subject.
public:
bool canSelectSubject() const;
// The canvas's foreground, as Remove Background finds it, selected.
void selectSubject(SelectionMode mode, std::function<void()> done);
private:
void finishSubject();
+66
View File
@@ -1,10 +1,14 @@
#include "Document/SubjectRemoval.h"
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/MaskTracing.h"
#include "Document/GuidedMatte.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QMutex>
#include <QStandardPaths>
#include <QtConcurrent>
#include <algorithm>
#include <array>
#include <cmath>
@@ -236,3 +240,65 @@ QImage SubjectRemoval::run(const QImage &image, const FilterSettings &settings)
}
return result;
}
bool EditorSession::canSelectSubject() const
{
// canEditLayers reads the document and the busy project.
return canEditSelection();
}
void EditorSession::selectSubject(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);
};
if (!canSelectSubject()) {
finish();
return;
}
QImage shown;
try {
shown = BrushRaster::context(m_document->width, m_document->height, false);
QPainter painter(&shown);
drawLiveComposite(*m_document, painter);
} catch (const ExportError &error) {
qCWarning(lcApp) << "Select Subject cannot draw the canvas:" << error.what();
finish();
return;
}
setIsProjectBusy(true);
m_subjecting = Subjecting{mode, m_document->id, std::move(done)};
m_subject.setFuture(QtConcurrent::run([shown]() -> Subjected {
try {
return Subjected{SubjectRemoval::subjectMask(shown, std::nullopt, FilterSettings()), std::nullopt};
} catch (const SubjectRemovalError &error) {
return Subjected{std::nullopt, QString::fromUtf8(error.what())};
} catch (const ExportError &error) {
return Subjected{std::nullopt, QString::fromUtf8(error.what())};
}
}));
}
void EditorSession::finishSubject()
{
const Subjected result = m_subject.result();
const Subjecting pending = std::exchange(m_subjecting, std::nullopt).value();
setIsProjectBusy(false);
if (m_document && m_document->id == pending.documentID) {
if (result.failure) {
qCWarning(lcApp).noquote() << "Select Subject failed:" << *result.failure;
setBrushError(result.failure);
} else if (const std::optional<QPainterPath> traced = MaskTracing::whitePixels(*result.mask)) {
// White where the subject is: its outline is the selection.
const QTransform toDocument = BrushRaster::pixelToDocument(LayerTransform{.origin = QPointF(0, 0), .size = m_document->size()},
result.mask->width(), result.mask->height());
applySelection(toDocument.map(*traced), pending.mode, QStringLiteral("Select Subject"));
} else {
qCWarning(lcApp) << "Select Subject traced no outline";
}
}
if (pending.done)
QMetaObject::invokeMethod(this, pending.done, Qt::QueuedConnection);
}
+4 -1
View File
@@ -62,8 +62,11 @@ std::optional<LayerText> ImageLayer::liveText() const
void EditorSession::setTextDraft(std::optional<TextDraft> draft)
{
// Only a draft that ends frees waiting file requests.
const bool ended = m_textDraft && !draft;
m_textDraft = std::move(draft);
resumeFileRequests();
if (ended)
resumeFileRequests();
notify();
}