Liquify keeps pixels sharp, Smudge without ghost copies, Blur's own Radius, the largest Smear commits (13.16, part one)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 17:48:08 -04:00
1 parent b50657bcdb
commit cbd8612014
15 files changed
+543 -51

No files matched your search

+2 -2
View File
@@ -11,8 +11,8 @@ std::optional<QImage> EditorSession::blurSample(const CanvasDocument &document,
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));
// The bar's Radius, whatever the brush's size.
const double sigma = std::min(50.0, std::max(0.5, m_brushSettings.blurRadius));
try {
if (mask) {
if (!layer->mask)
+1 -1
View File
@@ -92,7 +92,7 @@ BrushStroke::Grid BrushStroke::grid(const ImageLayer &layer, bool mask, const Br
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
if (!std::isfinite(settings.diameter) || settings.diameter < 1 || settings.diameter > maxDiameter
|| !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);
+4
View File
@@ -34,6 +34,8 @@ struct BrushSettings {
double opacity = 1;
// 0–100: the brush trails the pointer on this string.
double smoothing = 0;
// Blur: how far it softens, in canvas pixels.
double blurRadius = 5;
// Erase clears the layer's pixels instead of painting on them.
bool erasing = false;
// Spot healing takes nearby pixels instead of the colour.
@@ -120,6 +122,8 @@ public:
// The undo name when the stroke's kind says none.
std::optional<QString> editName;
static constexpr int tileSize = 256;
// Size stops at 2000; the app's own commits run wider.
static constexpr double maxDiameter = 2100;
// Soft-tip deposition rate, shared with the continuous integral.
static double spacingFraction(double hardness);
+58 -16
View File
@@ -39,6 +39,18 @@ WarpStroke::WarpStroke(const ImageLayer &layer, const QImage &image, const Layer
{
QPainter painter(&m_context);
LayerRenderer::draw(image, transform, transform.center(), painter, {});
painter.end();
if (mode != BlurToolMode::liquify)
return;
// Resampled at every dab, pixels soften; offsets keep them sharp.
m_original = m_context.copy();
if (m_original.isNull())
throw ExportError(ExportError::Kind::render);
try {
m_offsets.assign(size_t(width) * size_t(height) * 2, 0);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
int WarpStroke::radius() const
@@ -68,7 +80,8 @@ void WarpStroke::append(QPointF point)
}
const QPointF from = *m_last;
const double distance = std::hypot(point.x() - from.x(), point.y() - from.y());
const double spacing = std::max(1.0, diameter * (mode == BlurToolMode::smudge ? 0.08 : 0.025));
// Smudge dabs a pixel apart smear without echoes.
const double spacing = std::max(1.0, diameter * (mode == BlurToolMode::smudge ? 0.005 : 0.025));
if (distance < spacing)
return;
const int steps = int(std::ceil(distance / spacing));
@@ -131,10 +144,11 @@ void WarpStroke::smudge(QPointF center)
float *c = m_carried.data() + (size_t(dy + r) * size_t(side) + size_t(dx + r)) * 4;
for (int k = 0; k < 4; ++k) {
const float under = float(p[k]);
const float painted = under + (c[k] - under) * w;
// What was under the brush last dab, at the strength.
const float painted = under + (c[k] - under) * w * keep;
p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round(painted))));
// The brush picks up what it left; weaker, more so.
c[k] = painted + (c[k] - painted) * keep;
// It carries what it just left, nothing older: no ghosts.
c[k] = painted;
}
}
}
@@ -152,14 +166,16 @@ void WarpStroke::push(QPointF a, QPointF b)
if (x0 > x1 || y0 > y1)
return;
const int cw = x1 - x0 + 1, ch = y1 - y0 + 1;
if (m_scratch.size() < size_t(cw) * size_t(ch) * 4)
m_scratch.assign(size_t(cw) * size_t(ch) * 4, 0);
if (m_scratch.size() < size_t(cw) * size_t(ch) * 2)
m_scratch.assign(size_t(cw) * size_t(ch) * 2, 0);
for (int y = 0; y < ch; ++y) {
const auto row = m_offsets.begin() + std::ptrdiff_t((size_t(y + y0) * size_t(width) + size_t(x0)) * 2);
std::copy(row, row + std::ptrdiff_t(size_t(cw) * 2), m_scratch.begin() + std::ptrdiff_t(size_t(y) * size_t(cw) * 2));
}
uchar *pixels = m_context.bits();
const qsizetype stride = m_context.bytesPerLine();
for (int y = 0; y < ch; ++y) {
const uchar *row = pixels + (y + y0) * stride + x0 * 4;
std::copy(row, row + size_t(cw) * 4, m_scratch.begin() + std::ptrdiff_t(size_t(y) * size_t(cw) * 4));
}
const uchar *original = m_original.constBits();
const qsizetype originalStride = m_original.bytesPerLine();
const float invR = 1 / float(diameter / 2);
const float *s = m_scratch.data();
for (int dy = -r; dy <= r; ++dy) {
@@ -173,19 +189,36 @@ void WarpStroke::push(QPointF a, QPointF b)
const float w = weight(std::sqrt(float(dx * dx + dy * dy)) * invR);
if (!(w > 0))
continue;
// Bilinear from the old pixels, behind the brush's travel.
// Bilinear from the old offsets, behind the brush's travel.
const float sx = std::min(float(cw - 1), std::max(0.0f, float(x - x0) - moveX * w));
const float sy = std::min(float(ch - 1), std::max(0.0f, float(y - y0) - moveY * w));
const int ix = std::min(cw - 2, int(sx)), iy = std::min(ch - 2, int(sy));
if (ix < 0 || iy < 0)
continue;
const float fx = sx - float(ix), fy = sy - float(iy);
uchar *p = pixels + y * stride + x * 4;
const size_t s00 = (size_t(iy) * size_t(cw) + size_t(ix)) * 4, s10 = s00 + 4, s01 = s00 + size_t(cw) * 4, s11 = s01 + 4;
for (int k = 0; k < 4; ++k) {
const size_t s00 = (size_t(iy) * size_t(cw) + size_t(ix)) * 2, s10 = s00 + 2, s01 = s00 + size_t(cw) * 2, s11 = s01 + 2;
float moved[2];
for (int k = 0; k < 2; ++k) {
const float top = s[s00 + k] + (s[s10 + k] - s[s00 + k]) * fx;
const float bottom = s[s01 + k] + (s[s11 + k] - s[s01 + k]) * fx;
p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round(top + (bottom - top) * fy))));
moved[k] = top + (bottom - top) * fy - (k == 0 ? moveX : moveY) * w;
}
float *offset = m_offsets.data() + (size_t(y) * size_t(width) + size_t(x)) * 2;
offset[0] = moved[0];
offset[1] = moved[1];
// The untouched layer there, held to its edges, sampled once.
const float sourceX = std::clamp(float(x) + moved[0], 0.0f, float(width - 1));
const float sourceY = std::clamp(float(y) + moved[1], 0.0f, float(height - 1));
const int jx = std::max(0, std::min(int(sourceX), width - 2)), jy = std::max(0, std::min(int(sourceY), height - 2));
const float gx = sourceX - float(jx), gy = sourceY - float(jy);
const int kx = std::min(jx + 1, width - 1), ky = std::min(jy + 1, height - 1);
const uchar *top0 = original + jy * originalStride, *bottom0 = original + ky * originalStride;
const uchar *c00 = top0 + jx * 4, *c10 = top0 + kx * 4, *c01 = bottom0 + jx * 4, *c11 = bottom0 + kx * 4;
uchar *p = pixels + y * stride + x * 4;
for (int k = 0; k < 4; ++k) {
const float a = float(c00[k]) / 255, b = float(c10[k]) / 255, c = float(c01[k]) / 255, d = float(c11[k]) / 255;
const float top = a + (b - a) * gx, bottom = c + (d - c) * gx;
p[k] = uchar(std::max(0.0f, std::min(255.0f, std::round((top + (bottom - top) * gy) * 255))));
}
}
}
@@ -238,8 +271,17 @@ void EditorSession::finishWarp()
stroke->clone = BrushStroke::Clone{warp->image(), QSizeF(0, 0)};
stroke->replacesWithClone = true;
stroke->editName = rawValue(warp->mode);
for (const QPointF step : warp->points())
// A point every twentieth of the tip covers every dab.
const double spacing = std::max(1.0, warp->diameter * 0.05);
const std::vector<QPointF> &points = warp->points();
std::optional<QPointF> kept;
for (size_t index = 0; index < points.size(); ++index) {
const QPointF step = points[index];
if (kept && index + 1 < points.size() && std::hypot(step.x() - kept->x(), step.y() - kept->y()) < spacing)
continue;
stroke->append(step);
kept = step;
}
stroke->flush();
if (!stroke->patches().empty())
commitPaintSnapshot(*stroke);
+5 -1
View File
@@ -41,7 +41,7 @@ private:
float weight(float u) const;
void pickUp(QPointF center);
void smudge(QPointF center);
// Forward warp: pixels follow the brush, fading to the rim.
// Forward warp on offsets; pixels drawn afresh from the original.
void push(QPointF from, QPointF to);
QImage m_context;
@@ -49,5 +49,9 @@ private:
std::optional<QPointF> m_last;
// Smudge: the colour the brush carries, a (2r+1)² RGBA square.
std::vector<float> m_carried;
// Liquify: the layer as found, and each pixel's source offset.
QImage m_original;
std::vector<float> m_offsets;
// Liquify: the dab's offsets as they were before it.
std::vector<float> m_scratch;
};
+56 -2
View File
@@ -1,5 +1,6 @@
#include "UI/BrushControls.h"
#include "Document/EditorSession.h"
#include "UI/ColorPickerSheet.h"
#include "UI/ColorPaletteControls.h"
#include "UI/LassoControls.h"
#include "UI/NumericScrub.h"
@@ -25,6 +26,22 @@ const QString modeTip = QStringLiteral("Paint with the foreground color (B), or
const QString blurTip = QStringLiteral("Liquify pushes pixels · Blur softens · Smudge drags color along");
const QString sampleTip = QStringLiteral("Copy from the active layer only, or from every visible layer as shown");
// Swift's fractionLength(0...1): one place at most, none trailing.
QString oneDecimal(double value, QLocale locale)
{
locale.setNumberOptions(QLocale::OmitGroupSeparator);
QString text = locale.toString(value, 'f', 1);
const QString zero = locale.decimalPoint() + locale.zeroDigit();
if (text.endsWith(zero))
text.chop(zero.size());
return text;
}
double radius(double value)
{
return std::min(50.0, std::max(0.5, value));
}
QLabel *label(const QString &text, QWidget *parent)
{
auto *made = new QLabel(text, parent);
@@ -53,6 +70,22 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent)
[this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.opacity = value / 1000.0; }); })),
m_opacity(new SelectionAmountField(session, 1, 100, [this] { return m_session.brushSettings().opacity * 100; },
[this](double percent) { changeBrush(m_session, [percent](BrushSettings &brush) { brush.opacity = percent / 100; }); }, this)),
m_radiusLabel(label(QStringLiteral("Radius"), this)),
m_radiusSlider(slider(QStringLiteral("blurRadiusSlider"), 50, 2000,
[this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.blurRadius = value / 100.0; }); })),
m_radius(new PickerField([this] {
bool number = false;
const double typed = m_radius->locale().toDouble(m_radius->text(), &number);
if (m_radius->isModified() && number)
changeBrush(m_session, [typed](BrushSettings &brush) { brush.blurRadius = std::isfinite(typed) ? radius(typed) : 5; });
m_radius->setModified(false);
m_radius->setText(oneDecimal(m_session.brushSettings().blurRadius, m_radius->locale()));
}, [this](int step) {
changeBrush(m_session, [step](BrushSettings &brush) { brush.blurRadius = radius(brush.blurRadius + step); });
m_radius->setModified(false);
m_radius->setText(oneDecimal(m_session.brushSettings().blurRadius, m_radius->locale()));
}, this)),
m_radiusUnit(label(QStringLiteral("px"), this)),
m_smoothingLabel(label(QStringLiteral("Smoothing"), this)),
m_smoothingSlider(slider(QStringLiteral("brushSmoothingSlider"), 0, 1000,
[this](int value) { changeBrush(m_session, [value](BrushSettings &brush) { brush.smoothing = value / 10.0; }); })),
@@ -79,6 +112,19 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent)
m_opacity->setObjectName(QStringLiteral("brushOpacity"));
m_opacity->setFixedWidth(42);
m_opacity->setToolTip(QStringLiteral("Press 1–9 for 10–90%, 0 for 100%"));
m_radius->setObjectName(QStringLiteral("blurRadius"));
m_radius->setFixedWidth(42);
m_radius->setFont(ToolHeaderStyle::controlFont());
m_radius->setAccessibleName(QStringLiteral("Radius"));
m_radius->setPlaceholderText(QStringLiteral("Radius"));
m_radius->setToolTip(QStringLiteral("How far the blur softens, in pixels"));
// The slider covers everyday radii; typing or scrubbing reaches 50.
new NumericScrub(m_radiusLabel, {.sensitivity = 0.1, .low = 0.5, .high = 50, .step = std::nullopt,
.value = [this] { return m_session.brushSettings().blurRadius; }, .set = [this](double value) {
changeBrush(m_session, [value](BrushSettings &brush) { brush.blurRadius = value; });
m_radius->setModified(false);
m_radius->setText(oneDecimal(value, m_radius->locale()));
}});
m_smoothing->setObjectName(QStringLiteral("brushSmoothing"));
m_smoothing->setFixedWidth(42);
m_smoothing->setToolTip(QStringLiteral("The brush trails the pointer on a string this long, so a shaky hand still draws a smooth line"));
@@ -118,7 +164,7 @@ BrushControls::BrushControls(EditorSession &session, QWidget *parent)
widgets.insert(widgets.end(), {m_aligned, m_thisLayer, m_allLayers});
widgets.insert(widgets.end(), {size, m_size, label(QStringLiteral("px"), this), hardness,
m_hardnessSlider, m_hardness, label(QStringLiteral("%"), this), m_opacityLabel, m_opacitySlider, m_opacity,
label(QStringLiteral("%"), this), m_smoothingLabel, m_smoothingSlider, m_smoothing, m_paintLabel, m_maskPaint, m_colourLabel, m_colour});
label(QStringLiteral("%"), this), m_radiusLabel, m_radiusSlider, m_radius, m_radiusUnit, m_smoothingLabel, m_smoothingSlider, m_smoothing, m_paintLabel, m_maskPaint, m_colourLabel, m_colour});
for (QWidget *widget : widgets)
row->insertWidget(row->count() - 1, widget);
row->addWidget(m_cloneNote);
@@ -179,11 +225,19 @@ void BrushControls::synchronize()
m_size->sync(int(std::lround(brush.diameter)));
m_hardness->sync(int(std::lround(brush.hardness * 100)));
m_opacity->sync(int(std::lround(brush.opacity * 100)));
const bool blurring = tool == NavigationTool::blur && m_session.blurMode() == BlurToolMode::blur;
for (QWidget *widget : std::initializer_list<QWidget *>{m_radiusLabel, m_radiusSlider, m_radius, m_radiusUnit})
widget->setVisible(blurring);
// A field being typed in keeps its typing.
if (!m_radius->isModified())
m_radius->setText(oneDecimal(brush.blurRadius, m_radius->locale()));
for (QWidget *widget : std::initializer_list<QWidget *>{m_smoothingLabel, m_smoothingSlider, m_smoothing})
widget->setVisible(tool == NavigationTool::brush);
m_smoothing->sync(int(std::lround(brush.smoothing)));
// The sliders show the session's numbers without writing back.
const QSignalBlocker hardness(m_hardnessSlider), opacity(m_opacitySlider), smoothing(m_smoothingSlider);
const QSignalBlocker hardness(m_hardnessSlider), opacity(m_opacitySlider), smoothing(m_smoothingSlider), reach(m_radiusSlider);
// Past 20 the slider rests at its end.
m_radiusSlider->setValue(int(std::lround(brush.blurRadius * 100)));
m_hardnessSlider->setValue(int(std::lround(brush.hardness * 1000)));
m_opacitySlider->setValue(int(std::lround(brush.opacity * 1000)));
m_smoothingSlider->setValue(int(std::lround(brush.smoothing * 10)));
+6
View File
@@ -9,6 +9,7 @@ class QComboBox;
class QLabel;
class QSlider;
class QToolButton;
class PickerField;
class SelectionAmountField;
class SwatchButton;
@@ -44,6 +45,11 @@ private:
QLabel *const m_opacityLabel;
QSlider *const m_opacitySlider;
SelectionAmountField *const m_opacity;
// Blur's own Radius, apart from Strength.
QLabel *const m_radiusLabel;
QSlider *const m_radiusSlider;
PickerField *const m_radius;
QLabel *const m_radiusUnit;
// Paint and Erase only; other tools have their own feel.
QLabel *const m_smoothingLabel;
QSlider *const m_smoothingSlider;