Merge 13.11 (cc9bf95); Blur pieces and per-tile clone pieces on its grid sampling (13.16, part two)
This commit is contained in:
commit
c7e8bdfe8e
30 files changed
+910
-173
No files matched your search
+131
-47
@@ -55,7 +55,9 @@ private slots:
|
||||
void clearEdgesFadeAndClampedEdgesHold();
|
||||
void grayBlursAndBadInputIsRefused();
|
||||
void theSampleFollowsTheRadius();
|
||||
void aMaskSampleKeepsItsEdgeToneAndPlacement();
|
||||
void theSampleIsInTheLayersOwnPixels();
|
||||
void aHugeLayersSampleIsMadeCoarser();
|
||||
void aMaskSampleKeepsItsEdgeTone();
|
||||
void aBlurStrokeSoftensUnderTheBrushAsOneStep();
|
||||
void aBlurStrokeSoftensAMask();
|
||||
void aMaskBetweenPixelsTakesTheFraction();
|
||||
@@ -127,29 +129,129 @@ void BlurToolTests::grayBlursAndBadInputIsRefused()
|
||||
QVERIFY_THROWS_EXCEPTION(std::logic_error, PixelAdjust::gaussianBlur(step.convertToFormat(QImage::Format_RGB32), 2, true));
|
||||
}
|
||||
|
||||
// The expected sharp sample: `image` at `margin` in.
|
||||
QImage framed(const QImage &image, QSizeF size, int margin, bool mask, int tone = 0, double fit = 1)
|
||||
{
|
||||
QImage context = BrushRaster::context(int(std::ceil((size.width() + 2 * margin) * fit)), int(std::ceil((size.height() + 2 * margin) * fit)), mask);
|
||||
if (mask)
|
||||
context.fill(tone);
|
||||
QPainter painter(&context);
|
||||
BrushRaster::draw(image, QRectF(margin * fit, margin * fit, size.width() * fit, size.height() * fit), painter);
|
||||
painter.end();
|
||||
return context;
|
||||
}
|
||||
|
||||
// The expected softened sample, blurred whole.
|
||||
QImage sampled(const QImage &image, QSizeF size, int margin, double sigma, bool mask, int tone = 0, double fit = 1)
|
||||
{
|
||||
return PixelAdjust::gaussianBlur(framed(image, size, margin, mask, tone, fit), sigma * fit, mask);
|
||||
}
|
||||
|
||||
// The sample softened whole, through its pieces' function.
|
||||
QImage soft(const BrushStroke::Clone &sample)
|
||||
{
|
||||
return sample.render(sample.image.rect());
|
||||
}
|
||||
|
||||
void BlurToolTests::theSampleFollowsTheRadius()
|
||||
{
|
||||
EditorSession session;
|
||||
blurring(session, BrushSettings{.diameter = 60});
|
||||
const CanvasDocument document = session.document().value();
|
||||
blurring(session, BrushSettings{.diameter = 60, .blurRadius = 6});
|
||||
const QImage sharp = edge(60, 40);
|
||||
// The bar's Radius, within 0.5 and 50, whatever the size.
|
||||
for (const auto &[radius, sigma] : {std::pair(6.0, 6.0), std::pair(0.1, 0.5), std::pair(90.0, 50.0)}) {
|
||||
// The Radius, from 0.5, at most half the layer's side.
|
||||
for (const auto &[radius, sigma] : {std::pair(6.0, 6.0), std::pair(0.1, 0.5), std::pair(90.0, 30.0)}) {
|
||||
BrushSettings settings = session.brushSettings();
|
||||
settings.blurRadius = radius;
|
||||
settings.diameter = 3 * radius;
|
||||
settings.diameter = radius < 10 ? 90 : 5;
|
||||
session.setBrushSettings(settings);
|
||||
const BrushStroke::Clone sample = session.blurSample(document).value();
|
||||
QCOMPARE(sample.image, sharp);
|
||||
QCOMPARE(sample.render(sharp.rect()), PixelAdjust::gaussianBlur(sharp, sigma, false));
|
||||
const std::unique_ptr<BrushStroke> stroke = session.makeRasterEdit(session.activeLayer().value(), settings);
|
||||
const BrushStroke::Clone sample = session.blurSample(*stroke).value();
|
||||
const int margin = int(std::ceil(3 * sigma));
|
||||
QCOMPARE(sample.image, framed(sharp, QSizeF(60, 40), margin, false));
|
||||
QCOMPARE(soft(sample), sampled(sharp, QSizeF(60, 40), margin, sigma, false));
|
||||
QCOMPARE(sample.placed, QRectF(-margin, -margin, 60 + 2 * margin, 40 + 2 * margin));
|
||||
QVERIFY(sample.inGrid);
|
||||
}
|
||||
// No layer, nothing to soften.
|
||||
session.selectLayer(std::nullopt);
|
||||
QVERIFY(!session.blurSample(document));
|
||||
QVERIFY(!session.blurSample(document, true));
|
||||
// On a wide layer, the Radius stops at 50.
|
||||
EditorSession wide;
|
||||
wide.createDocument(400, 300);
|
||||
const QImage broad = edge(400, 300);
|
||||
wide.insert(ImportedImage(broad, broad, QStringLiteral("Edge")));
|
||||
wide.setBrushSettings(BrushSettings{.diameter = 60, .blurRadius = 80});
|
||||
const std::unique_ptr<BrushStroke> broadStroke = wide.makeRasterEdit(wide.activeLayer().value(), wide.brushSettings());
|
||||
QCOMPARE(wide.blurSample(*broadStroke).value().placed, QRectF(-150, -150, 700, 600));
|
||||
// A blank layer or missing mask has nothing to soften.
|
||||
session.addBlankLayer();
|
||||
const BrushSettings settings = session.brushSettings();
|
||||
QVERIFY(!session.blurSample(*session.makeRasterEdit(session.activeLayer().value(), settings)));
|
||||
QVERIFY(!session.blurSample(BrushStroke(session.activeLayer().value(), true, settings, QSizeF(60, 40))));
|
||||
// So a Blur stroke there never begins.
|
||||
session.beginBrush(QPointF(30, 20));
|
||||
QVERIFY(!session.brushStroke());
|
||||
}
|
||||
|
||||
void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
|
||||
void BlurToolTests::theSampleIsInTheLayersOwnPixels()
|
||||
{
|
||||
// Twice as fine as the canvas: the softening doubles.
|
||||
EditorSession session;
|
||||
blurring(session, BrushSettings{.diameter = 20, .blurRadius = 2});
|
||||
const QImage fine = edge(120, 80);
|
||||
session.insert(ImportedImage(fine, fine, QStringLiteral("Fine")));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {60, 40}}; });
|
||||
const BrushSettings settings = session.brushSettings();
|
||||
BrushStroke stroke(session.activeLayer().value(), false, settings, QSizeF(60, 40));
|
||||
QCOMPARE(stroke.sourceRect, QRectF(0, 0, 120, 80));
|
||||
BrushStroke::Clone sample = session.blurSample(stroke).value();
|
||||
QCOMPARE(soft(sample), sampled(fine, QSizeF(120, 80), 12, 4, false));
|
||||
QCOMPARE(sample.placed, QRectF(-12, -12, 144, 104));
|
||||
// Turned, the softening is the same in its own pixels.
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.rotation = 45; });
|
||||
BrushStroke turned(session.activeLayer().value(), false, settings, QSizeF(60, 40));
|
||||
sample = session.blurSample(turned).value();
|
||||
QCOMPARE(soft(sample), sampled(fine, QSizeF(120, 80), 12, 4, false));
|
||||
QCOMPARE(sample.placed, turned.sourceRect.adjusted(-12, -12, 12, 12));
|
||||
// A tiny layer caps the softening at half its side.
|
||||
session.setBrushSettings(BrushSettings{.diameter = 60, .blurRadius = 6});
|
||||
QImage tiny = BrushRaster::context(4, 2, false);
|
||||
tiny.fill(Qt::red);
|
||||
BrushStroke small(ImageLayer(ImportedImage(tiny, tiny, QStringLiteral("Tiny")), QPointF(10, 10)), false, settings, QSizeF(60, 40));
|
||||
sample = session.blurSample(small).value();
|
||||
QCOMPARE(soft(sample), sampled(tiny, QSizeF(4, 2), 6, 2, false));
|
||||
QCOMPARE(sample.placed, small.sourceRect.adjusted(-6, -6, 6, 6));
|
||||
}
|
||||
|
||||
void BlurToolTests::aHugeLayersSampleIsMadeCoarser()
|
||||
{
|
||||
// Sixteen million pixels at most, or four canvases.
|
||||
EditorSession session;
|
||||
session.createDocument(100, 100);
|
||||
const QImage huge = edge(4200, 3900);
|
||||
session.insert(ImportedImage(huge, huge, QStringLiteral("Huge")));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {0, 0}, .size = {420, 390}}; });
|
||||
session.setBrushSettings(BrushSettings{.diameter = 15, .blurRadius = 1.5});
|
||||
const BrushSettings settings = session.brushSettings();
|
||||
BrushStroke stroke(session.activeLayer().value(), false, settings, QSizeF(100, 100));
|
||||
QCOMPARE(stroke.sourceRect.size(), QSizeF(4200, 3900));
|
||||
// A tenth as large: softening 15, margin 45.
|
||||
const BrushStroke::Clone sample = session.blurSample(stroke).value();
|
||||
QCOMPARE(sample.placed, stroke.sourceRect.adjusted(-45, -45, 45, 45));
|
||||
const double fit = std::sqrt(16'000'000.0 / (4290.0 * 3990.0));
|
||||
QCOMPARE(sample.image.size(), QSize(4148, 3858));
|
||||
QCOMPARE(soft(sample), sampled(huge, QSizeF(4200, 3900), 45, 15, false, 0, fit));
|
||||
// A larger canvas lifts the budget: four of them.
|
||||
EditorSession roomy;
|
||||
roomy.createDocument(2100, 2100);
|
||||
roomy.insert(ImportedImage(huge, huge, QStringLiteral("Huge")));
|
||||
const QUuid roomyID = roomy.activeLayerID().value();
|
||||
rewrite(roomy, [&](ProjectSnapshot &snapshot) { record(snapshot, roomyID).transform = LayerTransform{.origin = {0, 0}, .size = {420, 390}}; });
|
||||
roomy.setBrushSettings(settings);
|
||||
BrushStroke whole(roomy.activeLayer().value(), false, settings, QSizeF(2100, 2100));
|
||||
QCOMPARE(roomy.blurSample(whole).value().image.size(), QSize(4290, 3990));
|
||||
}
|
||||
|
||||
void BlurToolTests::aMaskSampleKeepsItsEdgeTone()
|
||||
{
|
||||
EditorSession session;
|
||||
blurring(session, BrushSettings{.diameter = 20, .blurRadius = 2});
|
||||
@@ -157,7 +259,7 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
|
||||
// A white mask with a black bar, placed mid-layer.
|
||||
QImage mask(20, 20, QImage::Format_Grayscale8);
|
||||
mask.fill(255);
|
||||
for (int y = 0; y < 20; ++y)
|
||||
for (int y = 2; y < 18; ++y)
|
||||
for (int x = 8; x < 12; ++x)
|
||||
mask.scanLine(y)[x] = 0;
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) {
|
||||
@@ -165,36 +267,18 @@ void BlurToolTests::aMaskSampleKeepsItsEdgeToneAndPlacement()
|
||||
record(snapshot, id).maskPlacement = LayerTransform{.origin = {20, 10}, .size = {20, 20}};
|
||||
record(snapshot, id).maskLinked = false;
|
||||
});
|
||||
const BrushStroke::Clone clone = session.blurSample(session.document().value(), true).value();
|
||||
const QImage sample = clone.render(clone.image.rect());
|
||||
QCOMPARE(sample.format(), QImage::Format_Grayscale8);
|
||||
QImage expected(60, 40, QImage::Format_Grayscale8);
|
||||
BrushStroke stroke(session.activeLayer().value(), true, session.brushSettings(), QSizeF(60, 40));
|
||||
const BrushStroke::Clone sample = session.blurSample(stroke).value();
|
||||
QCOMPARE(sample.image.format(), QImage::Format_Grayscale8);
|
||||
// Past its pixels the mask keeps its edge's tone: white.
|
||||
expected.fill(255);
|
||||
QPainter painter(&expected);
|
||||
painter.drawImage(QRectF(20, 10, 20, 20), mask);
|
||||
painter.end();
|
||||
QCOMPARE(sample, PixelAdjust::gaussianBlur(expected, 2, true));
|
||||
QVERIFY(sample.constScanLine(20)[30] < 128 && sample.constScanLine(2)[2] == 255);
|
||||
// Turned, the black box turns with the mask.
|
||||
EditorSession turned;
|
||||
turned.createDocument(100, 60);
|
||||
const QImage wide = edge(100, 60);
|
||||
turned.insert(ImportedImage(wide, wide, QStringLiteral("Edge")));
|
||||
const QUuid wideID = turned.activeLayerID().value();
|
||||
QImage white(40, 40, QImage::Format_Grayscale8);
|
||||
white.fill(255);
|
||||
rewrite(turned, [&](ProjectSnapshot &snapshot) {
|
||||
setMask(snapshot, wideID, LayerMask::assetFrom(white));
|
||||
record(snapshot, wideID).maskPlacement = LayerTransform{.origin = {30, 10}, .size = {40, 40}, .rotation = 30};
|
||||
record(snapshot, wideID).maskLinked = false;
|
||||
});
|
||||
turned.setBrushSettings(BrushSettings{.diameter = 5, .blurRadius = 1.5});
|
||||
const BrushStroke::Clone sharp = turned.blurSample(turned.document().value(), true).value();
|
||||
const QImage soft = sharp.render(sharp.image.rect());
|
||||
// Past the true box, inside one turned the other way.
|
||||
QVERIFY2(soft.constScanLine(36)[73] > 240, qPrintable(QString::number(soft.constScanLine(36)[73])));
|
||||
QVERIFY2(soft.constScanLine(24)[27] > 240, qPrintable(QString::number(soft.constScanLine(24)[27])));
|
||||
QCOMPARE(soft(sample), sampled(mask, QSizeF(20, 20), 6, 2, true, 255));
|
||||
QCOMPARE(sample.placed, QRectF(-6, -6, 32, 32));
|
||||
QVERIFY(soft(sample).constScanLine(16)[16] < 128 && soft(sample).constScanLine(1)[1] == 255);
|
||||
// A black edge keeps black past it.
|
||||
mask.fill(0);
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); });
|
||||
BrushStroke black(session.activeLayer().value(), true, session.brushSettings(), QSizeF(60, 40));
|
||||
QCOMPARE(soft(session.blurSample(black).value()), sampled(mask, QSizeF(20, 20), 6, 2, true, 0));
|
||||
}
|
||||
|
||||
void BlurToolTests::aBlurStrokeSoftensUnderTheBrushAsOneStep()
|
||||
@@ -265,7 +349,7 @@ void BlurToolTests::aMaskBetweenPixelsTakesTheFraction()
|
||||
for (int y = 0; y < 20; ++y)
|
||||
for (int x = 0; x < 80; ++x)
|
||||
steps.scanLine(y)[x] = x < 21 ? 0 : 200;
|
||||
paint.setClone(BrushStroke::Clone{steps, QSizeF(0, 0), {}});
|
||||
paint.setClone(documentClone(steps, QSizeF(0, 0)));
|
||||
paint.isBlur = true;
|
||||
paint.append(QPointF(21, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
@@ -285,7 +369,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone()
|
||||
white.fill(255);
|
||||
layer.mask = LayerMask(LayerMask::assetFrom(white));
|
||||
BrushStroke paint(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
|
||||
paint.setClone(BrushStroke::Clone{white, QSizeF(0, 0), {}});
|
||||
paint.setClone(documentClone(white, QSizeF(0, 0)));
|
||||
paint.isBlur = true;
|
||||
paint.append(QPointF(0, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
@@ -296,7 +380,7 @@ void BlurToolTests::aMaskPastTheCanvasEdgeKeepsItsTone()
|
||||
black.fill(0);
|
||||
layer.mask = LayerMask(LayerMask::assetFrom(black));
|
||||
BrushStroke over(layer, true, brush(6, 1, 1, 1, 1), QSizeF(20, 20));
|
||||
over.setClone(BrushStroke::Clone{white, QSizeF(0, 0), {}});
|
||||
over.setClone(documentClone(white, QSizeF(0, 0)));
|
||||
over.isBlur = true;
|
||||
over.append(QPointF(0, 10));
|
||||
QCOMPARE(int(over.patches().at(0).image.constScanLine(10)[0]), 128);
|
||||
|
||||
@@ -10,6 +10,12 @@ inline BrushSettings brush(double diameter, double hardness, double red, double
|
||||
return BrushSettings{.diameter = diameter, .hardness = hardness, .red = red, .green = green, .blue = blue, .opacity = opacity};
|
||||
}
|
||||
|
||||
// A document-pixel clone, copied from `offset` away.
|
||||
inline BrushStroke::Clone documentClone(const QImage &image, QSizeF offset = QSizeF(0, 0))
|
||||
{
|
||||
return {image, QRectF(-offset.width(), -offset.height(), image.width(), image.height()), false, {}};
|
||||
}
|
||||
|
||||
inline ImageLayer blankLayer(int width, int height)
|
||||
{
|
||||
return ImageLayer(QStringLiteral("Layer 1"), QSizeF(width, height));
|
||||
|
||||
@@ -68,6 +68,7 @@ private slots:
|
||||
void theActiveLayerAloneOrEveryVisibleLayerIsSampled();
|
||||
void aMaskIsNeverCloned();
|
||||
void aScaledLayerSamplesThroughItsGrid();
|
||||
void aGridCloneIsScaledOntoItsPlace();
|
||||
void aLayerBetweenPixelsSamplesTheFraction();
|
||||
void aQuarterPixelWeighsItsNeighbours();
|
||||
void opacityAndTheSelectionLimitTheClone();
|
||||
@@ -249,7 +250,7 @@ void CloneStampTests::aScaledLayerSamplesThroughItsGrid()
|
||||
ImageLayer layer(ImportedImage(clear, clear, QStringLiteral("Scaled")), QPointF(0, 0));
|
||||
layer.transform.size = QSizeF(40, 20);
|
||||
BrushStroke paint(layer, false, brush(30, 1, 0, 0, 0), QSizeF(80, 20));
|
||||
paint.setClone(BrushStroke::Clone{edged(80, 20, 31), QSizeF(10, 0), {}});
|
||||
paint.setClone(documentClone(edged(80, 20, 31), QSizeF(10, 0)));
|
||||
paint.append(QPointF(21, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
// Grid pixel 10 sits at document 21, sampling 31.
|
||||
@@ -258,13 +259,25 @@ void CloneStampTests::aScaledLayerSamplesThroughItsGrid()
|
||||
QCOMPARE(pixel(tile, 11, 5), bluePixel);
|
||||
}
|
||||
|
||||
void CloneStampTests::aGridCloneIsScaledOntoItsPlace()
|
||||
{
|
||||
// Half as many pixels as their place: each spans two.
|
||||
ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20));
|
||||
BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(40, 20));
|
||||
paint.setClone(BrushStroke::Clone{edged(20, 10, 10), QRectF(0, 0, 40, 20), true, {}});
|
||||
paint.append(QPointF(15, 10));
|
||||
const QImage tile = paint.patches().at(0).image;
|
||||
QCOMPARE(pixel(tile, 17, 10), redPixel);
|
||||
QCOMPARE(pixel(tile, 22, 10), bluePixel);
|
||||
}
|
||||
|
||||
void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction()
|
||||
{
|
||||
// Half a pixel right: each grid centre falls between two.
|
||||
ImageLayer layer(QStringLiteral("Blank"), QSizeF(40, 20));
|
||||
layer.transform.origin = QPointF(0.5, 0);
|
||||
BrushStroke paint(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 20));
|
||||
paint.setClone(BrushStroke::Clone{edged(80, 20, 31), QSizeF(3, 0), {}});
|
||||
paint.setClone(documentClone(edged(80, 20, 31), QSizeF(3, 0)));
|
||||
paint.append(QPointF(28, 10));
|
||||
const QPointF centre = paint.pixelToDocument.map(QPointF(28.5, 10.5));
|
||||
QCOMPARE(centre, QPointF(28, 10.5));
|
||||
@@ -279,7 +292,7 @@ void CloneStampTests::aLayerBetweenPixelsSamplesTheFraction()
|
||||
QImage rows = BrushRaster::context(40, 40, false);
|
||||
rows.fill(QColor(255, 0, 0));
|
||||
QPainter(&rows).fillRect(QRect(0, 12, 40, 28), QColor(0, 0, 255));
|
||||
down.setClone(BrushStroke::Clone{rows, QSizeF(0, 0), {}});
|
||||
down.setClone(documentClone(rows, QSizeF(0, 0)));
|
||||
down.append(QPointF(20, 12));
|
||||
const QImage lowTile = down.patches().at(0).image;
|
||||
const int row = int(std::floor(down.pixelToDocument.inverted().map(QPointF(20, 12)).y()));
|
||||
@@ -297,7 +310,7 @@ void CloneStampTests::aQuarterPixelWeighsItsNeighbours()
|
||||
return std::abs(pixel[0] - red) <= 2 && pixel[1] == 0 && std::abs(pixel[2] - blue) <= 2 && pixel[3] == 255;
|
||||
};
|
||||
BrushStroke across(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40));
|
||||
across.setClone(BrushStroke::Clone{edged(80, 40, 31), QSizeF(0, 0), {}});
|
||||
across.setClone(documentClone(edged(80, 40, 31), QSizeF(0, 0)));
|
||||
across.append(QPointF(31, 12));
|
||||
QCOMPARE(across.pixelToDocument.map(QPointF(31.5, 12.5)), QPointF(30.75, 12.25));
|
||||
const std::vector<int> mixed = pixel(across.patches().at(0).image, 31, 12);
|
||||
@@ -307,7 +320,7 @@ void CloneStampTests::aQuarterPixelWeighsItsNeighbours()
|
||||
rows.fill(QColor(255, 0, 0));
|
||||
QPainter(&rows).fillRect(QRect(0, 12, 80, 28), QColor(0, 0, 255));
|
||||
BrushStroke down(layer, false, brush(20, 1, 0, 0, 0), QSizeF(80, 40));
|
||||
down.setClone(BrushStroke::Clone{rows, QSizeF(0, 0), {}});
|
||||
down.setClone(documentClone(rows, QSizeF(0, 0)));
|
||||
down.append(QPointF(20, 12));
|
||||
const std::vector<int> stacked = pixel(down.patches().at(0).image, 20, 12);
|
||||
QVERIFY2(near(stacked, 64, 191), qPrintable(QString("%1 %2").arg(stacked[0]).arg(stacked[2])));
|
||||
@@ -321,7 +334,7 @@ void CloneStampTests::opacityAndTheSelectionLimitTheClone()
|
||||
over.fill(QColor(255, 0, 0));
|
||||
BrushStroke half(ImageLayer(ImportedImage(under, under, QStringLiteral("Blue")), QPointF(0, 0)), false, brush(10, 1, 0, 0, 0, 0.5),
|
||||
QSizeF(40, 20));
|
||||
half.setClone(BrushStroke::Clone{over, QSizeF(0, 0), {}});
|
||||
half.setClone(documentClone(over, QSizeF(0, 0)));
|
||||
half.append(QPointF(10, 10));
|
||||
// Each byte is rounded once: 127.5 and 127.5 make 128.
|
||||
QCOMPARE(pixel(half.patches().at(0).image, 10, 10), (std::vector<int>{128, 0, 128, 255}));
|
||||
@@ -337,7 +350,7 @@ void CloneStampTests::opacityAndTheSelectionLimitTheClone()
|
||||
painter.setCompositionMode(QPainter::CompositionMode_Clear);
|
||||
painter.fillRect(QRect(0, 0, 40, 5), Qt::transparent);
|
||||
}
|
||||
bounded.setClone(BrushStroke::Clone{sample, QSizeF(0, 0), {}});
|
||||
bounded.setClone(documentClone(sample, QSizeF(0, 0)));
|
||||
bounded.append(QPointF(10, 8));
|
||||
const QImage tile = bounded.patches().at(0).image;
|
||||
QCOMPARE(pixel(tile, 8, 8), redPixel);
|
||||
|
||||
@@ -126,10 +126,15 @@ void LargeCanvasBrushTests::blurPiecesMatchTheWhole()
|
||||
BrushSettings settings = session.brushSettings();
|
||||
settings.blurRadius = 12;
|
||||
session.setBrushSettings(settings);
|
||||
const BrushStroke::Clone blur = session.blurSample(session.document().value(), mask).value();
|
||||
const ImageLayer layer = session.activeLayer().value();
|
||||
const std::unique_ptr<BrushStroke> stroke =
|
||||
mask ? std::make_unique<BrushStroke>(layer, true, settings, QSizeF(600, 600)) : session.makeRasterEdit(layer, settings);
|
||||
const BrushStroke::Clone blur = session.blurSample(*stroke).value();
|
||||
const QImage whole = blur.render(blur.image.rect());
|
||||
QCOMPARE(whole.size(), QSize(600, 600));
|
||||
for (const QRect part : {QRect(0, 0, 256, 256), QRect(250, 300, 260, 180), QRect(500, 530, 100, 70)}) {
|
||||
// The sample reaches three sigmas past the layer.
|
||||
QCOMPARE(whole.size(), QSize(672, 672));
|
||||
const int width = blur.image.width(), height = blur.image.height();
|
||||
for (const QRect part : {QRect(0, 0, 256, 256), QRect(250, 300, 260, 180), QRect(width - 100, height - 70, 100, 70)}) {
|
||||
const QImage piece = blur.render(part);
|
||||
QCOMPARE(piece.size(), part.size());
|
||||
QCOMPARE(piece, whole.copy(part));
|
||||
@@ -144,7 +149,7 @@ void LargeCanvasBrushTests::piecesAreCutOnceWhereTheBrushReaches()
|
||||
const ImageLayer layer(ImportedImage(image, image, QStringLiteral("Red")), QPointF(0, 0));
|
||||
BrushStroke stroke(layer, false, brush(20, 1, 0, 0, 0), QSizeF(1000, 1000));
|
||||
std::vector<QRect> parts;
|
||||
stroke.setClone(BrushStroke::Clone{image, QSizeF(0, 0), [&](const QRect &part) {
|
||||
stroke.setClone(BrushStroke::Clone{image, QRectF(0, 0, 1000, 1000), false, [&](const QRect &part) {
|
||||
parts.push_back(part);
|
||||
return image.copy(part);
|
||||
}});
|
||||
@@ -174,7 +179,7 @@ void LargeCanvasBrushTests::piecesAreCutOnceWhereTheBrushReaches()
|
||||
QImage blue = BrushRaster::context(200, 200, false);
|
||||
blue.fill(Qt::blue);
|
||||
parts.clear();
|
||||
past.setClone(BrushStroke::Clone{blue, QSizeF(0, 0), [&](const QRect &part) {
|
||||
past.setClone(BrushStroke::Clone{blue, QRectF(0, 0, 200, 200), false, [&](const QRect &part) {
|
||||
parts.push_back(part);
|
||||
return blue.copy(part);
|
||||
}});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "CanvasFixtures.h"
|
||||
#include "UI/NativeLayerList.h"
|
||||
|
||||
// Swift's NativeResolutionPaintTests: paint lands at the layer's resolution.
|
||||
class NativeResolutionPaintTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void erasingAScaledDownImageKeepsItsResolution();
|
||||
void erasingAnUnevenOrTurnedLayerKeepsItsResolution();
|
||||
void canvasShowsTheErasedEdgeSmoothlyAfterScalingBackUp();
|
||||
void paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution();
|
||||
void cloneStampCopiesTheLayersOwnDetail();
|
||||
void cloneStampCarriesTheOffsetIntoTheLayer();
|
||||
void blurKeepsTheLayersResolution();
|
||||
void layersPanelSaysHowFarALayerIsScaled();
|
||||
};
|
||||
|
||||
namespace {
|
||||
std::vector<int> channel(const QImage &image, int which)
|
||||
{
|
||||
const QImage bytes = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
std::vector<int> values;
|
||||
values.reserve(size_t(bytes.width()) * bytes.height());
|
||||
for (int y = 0; y < bytes.height(); ++y)
|
||||
for (int x = 0; x < bytes.width(); ++x)
|
||||
values.push_back(bytes.constScanLine(y)[x * 4 + which]);
|
||||
return values;
|
||||
}
|
||||
|
||||
// Where each row first drops below half alpha, from `start`.
|
||||
std::vector<int> edge(const std::vector<int> &values, int width, int first, int last, int start)
|
||||
{
|
||||
std::vector<int> outline;
|
||||
for (int y = first; y <= last; ++y) {
|
||||
int x = start;
|
||||
while (x < width && values[size_t(y) * width + x] >= 128)
|
||||
++x;
|
||||
outline.push_back(x);
|
||||
}
|
||||
return outline;
|
||||
}
|
||||
|
||||
int largestStep(const std::vector<int> &outline)
|
||||
{
|
||||
int largest = 0;
|
||||
for (size_t index = 1; index < outline.size(); ++index)
|
||||
largest = std::max(largest, std::abs(outline[index] - outline[index - 1]));
|
||||
return largest;
|
||||
}
|
||||
|
||||
// A red square of `source` pixels, shown `scale` times, centred.
|
||||
std::unique_ptr<EditorSession> session(double scale, int source = 1000)
|
||||
{
|
||||
auto made = std::make_unique<EditorSession>();
|
||||
made->createDocument(200, 200);
|
||||
made->insert(filled(source, source, qRgba(255, 0, 0, 255), QStringLiteral("Photo")));
|
||||
const QUuid id = made->activeLayerID().value();
|
||||
const double side = source * scale;
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {100 - side / 2, 100 - side / 2}, .size = {side, side}}; });
|
||||
made->selectTool(NavigationTool::brush);
|
||||
made->setBrushMode(BrushToolMode::erase);
|
||||
made->setBrushSettings(brush(40, 1, 0, 0, 0));
|
||||
return made;
|
||||
}
|
||||
|
||||
// Black and white two-pixel checks left, red right, at 10%.
|
||||
std::unique_ptr<EditorSession> checkerSession()
|
||||
{
|
||||
QImage image = BrushRaster::context(1000, 1000, false);
|
||||
QPainter painter(&image);
|
||||
painter.fillRect(QRect(500, 0, 500, 1000), Qt::red);
|
||||
painter.fillRect(QRect(0, 0, 500, 1000), Qt::white);
|
||||
for (int y = 0; y < 1000; y += 2)
|
||||
for (int x = (y / 2) % 2 == 0 ? 0 : 2; x < 500; x += 4)
|
||||
painter.fillRect(QRect(x, y, 2, 2), Qt::black);
|
||||
painter.end();
|
||||
auto made = std::make_unique<EditorSession>();
|
||||
made->createDocument(200, 200);
|
||||
made->insert(ImportedImage(image, image, QStringLiteral("Checker")));
|
||||
const QUuid id = made->activeLayerID().value();
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {50, 50}, .size = {100, 100}}; });
|
||||
return made;
|
||||
}
|
||||
|
||||
bool holdsBoth(const std::vector<int> &values)
|
||||
{
|
||||
return std::any_of(values.begin(), values.end(), [](int value) { return value < 30; })
|
||||
&& std::any_of(values.begin(), values.end(), [](int value) { return value > 225; });
|
||||
}
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::erasingAScaledDownImageKeepsItsResolution()
|
||||
{
|
||||
const auto made = session(0.1);
|
||||
made->beginBrush(QPointF(100, 100));
|
||||
made->finishBrush();
|
||||
QVERIFY(!made->brushError());
|
||||
const QImage image = made->activeLayer().value().asset.value().image();
|
||||
QCOMPARE(image.size(), QSize(1000, 1000));
|
||||
// A 40-pixel hole at 10%: 400 image pixels.
|
||||
const std::vector<int> values = channel(image, 3);
|
||||
QCOMPARE(values[500 * 1000 + 500], 0);
|
||||
QCOMPARE(values[500 * 1000 + 250], 255);
|
||||
QCOMPARE(values[500 * 1000 + 280], 255);
|
||||
QCOMPARE(values[500 * 1000 + 320], 0);
|
||||
// A coarse grid's outline would step by ten a row.
|
||||
QVERIFY(largestStep(edge(values, 1000, 330, 470, 250)) <= 4);
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::erasingAnUnevenOrTurnedLayerKeepsItsResolution()
|
||||
{
|
||||
// Swift's CPU fallback case: the twin's only path.
|
||||
for (const bool rotated : {false, true}) {
|
||||
const auto made = session(0.1);
|
||||
ImageLayer layer = made->activeLayer().value();
|
||||
layer.transform.size.setHeight(layer.transform.size.height() + 0.013);
|
||||
if (rotated)
|
||||
layer.transform.rotation = 30;
|
||||
BrushSettings settings = made->brushSettings();
|
||||
settings.erasing = true;
|
||||
BrushStroke stroke(layer, false, settings, QSizeF(200, 200));
|
||||
stroke.append(QPointF(100, 100));
|
||||
stroke.flush();
|
||||
const QImage image = stroke.paintSnapshot().asset.image();
|
||||
QCOMPARE(image.width(), 1000);
|
||||
const std::vector<int> values = channel(image, 3);
|
||||
QCOMPARE(values[500 * 1000 + 500], 0);
|
||||
QVERIFY2(largestStep(edge(values, 1000, 330, 470, 250)) <= 4, rotated ? "rotated" : "uneven");
|
||||
}
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::canvasShowsTheErasedEdgeSmoothlyAfterScalingBackUp()
|
||||
{
|
||||
Shown shown(QSize(400, 400), QSize(800, 600));
|
||||
shown.settle();
|
||||
EditorSession &session = shown.session;
|
||||
session.insert(filled(1000, 1000, qRgba(255, 0, 0, 255), QStringLiteral("Photo")));
|
||||
const QUuid id = session.activeLayerID().value();
|
||||
LayerTransform small{.origin = {180, 180}, .size = {40, 40}};
|
||||
small.sampling = LayerSampling::high;
|
||||
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = small; });
|
||||
session.zoom(1);
|
||||
shown.canvas->synchronizeDisplay();
|
||||
shown.canvas->grab();
|
||||
session.selectTool(NavigationTool::brush);
|
||||
session.setBrushMode(BrushToolMode::erase);
|
||||
session.setBrushSettings(brush(20, 1, 0, 0, 0));
|
||||
session.beginBrush(QPointF(200, 200));
|
||||
QVERIFY(session.finishBrushImmediately());
|
||||
shown.canvas->synchronizeDisplay();
|
||||
shown.canvas->grab();
|
||||
// Scaled back up as the Move tool would.
|
||||
LayerTransform large = session.activeLayer().value().transform;
|
||||
large.origin = {0, 0};
|
||||
large.size = {400, 400};
|
||||
session.selectTool(NavigationTool::move);
|
||||
session.beginTransform();
|
||||
session.previewTransform(large);
|
||||
session.commitTransform();
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const QImage view = shown.canvas->grab().toImage();
|
||||
// A 500-pixel hole: 200 document pixels, a point each.
|
||||
const QPointF point = session.viewport.viewPoint(QPointF(200, 200), shown.documentSize());
|
||||
const int cx = int(point.x()), cy = int(point.y());
|
||||
const auto erased = [&](int x, int y) {
|
||||
const QColor colour = view.pixelColor(x, y);
|
||||
return colour.redF() < 0.6 || colour.greenF() > 0.6;
|
||||
};
|
||||
QVERIFY(erased(cx, cy));
|
||||
QVERIFY(!erased(cx - 120, cy));
|
||||
std::vector<int> outline;
|
||||
for (int y = cy - 70; y <= cy - 5; ++y) {
|
||||
int x = cx - 120;
|
||||
while (x < cx && !erased(x, y))
|
||||
++x;
|
||||
outline.push_back(x);
|
||||
}
|
||||
QVERIFY(largestStep(outline) <= 3);
|
||||
// The arc's widest row reaches a hundred points out.
|
||||
QVERIFY(std::abs(outline.back() - (cx - 100)) <= 2);
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::paintingABlankLayerThatWasScaledDownPaintsAtDocumentResolution()
|
||||
{
|
||||
EditorSession made;
|
||||
made.createDocument(200, 200);
|
||||
made.addBlankLayer();
|
||||
const QUuid id = made.activeLayerID().value();
|
||||
rewrite(made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform = LayerTransform{.origin = {90, 90}, .size = {20, 20}}; });
|
||||
made.selectTool(NavigationTool::brush);
|
||||
made.setBrushSettings(brush(40, 1, 1, 0, 0));
|
||||
made.beginBrush(QPointF(100, 100));
|
||||
made.finishBrush();
|
||||
const ImageLayer layer = made.activeLayer().value();
|
||||
const QImage image = layer.asset.value().image();
|
||||
// A layer pixel per document pixel, however small it was.
|
||||
QVERIFY(std::abs(image.width() - layer.transform.size.width()) <= 1);
|
||||
QVERIFY(image.width() >= 38);
|
||||
QCOMPARE(layer.transform.size, QSizeF(image.size()));
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::cloneStampCopiesTheLayersOwnDetail()
|
||||
{
|
||||
const auto made = checkerSession();
|
||||
made->selectTool(NavigationTool::cloneStamp);
|
||||
made->setCloneSource(QPointF(70, 100));
|
||||
made->setBrushSettings(brush(10, 1, 0, 0, 0));
|
||||
made->beginBrush(QPointF(130, 100));
|
||||
made->finishBrush();
|
||||
QVERIFY(!made->brushError());
|
||||
const QImage image = made->activeLayer().value().asset.value().image();
|
||||
QCOMPARE(image.width(), 1000);
|
||||
// The checks, too fine to see at 10%, arrive whole.
|
||||
const std::vector<int> values = channel(image, 1);
|
||||
QVERIFY(holdsBoth(std::vector<int>(values.begin() + 500 * 1000 + 790, values.begin() + 500 * 1000 + 810)));
|
||||
// Copied pixel for pixel, 600 image pixels to the left.
|
||||
for (int x = 790; x < 810; ++x)
|
||||
QCOMPARE(values[500 * 1000 + x], values[500 * 1000 + x - 600]);
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::cloneStampCarriesTheOffsetIntoTheLayer()
|
||||
{
|
||||
// Flipped, the source lies the other way in pixels.
|
||||
const auto made = checkerSession();
|
||||
const QUuid id = made->activeLayerID().value();
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.flipX = true; });
|
||||
BrushStroke stroke(made->activeLayer().value(), false, brush(10, 1, 0, 0, 0), QSizeF(200, 200));
|
||||
QCOMPARE(stroke.gridRect(QRectF(0, 0, 1000, 1000), QSizeF(-60, 0)), QRectF(-600, 0, 1000, 1000));
|
||||
made->selectTool(NavigationTool::cloneStamp);
|
||||
made->setCloneSource(QPointF(130, 100));
|
||||
made->setBrushSettings(brush(10, 1, 0, 0, 0));
|
||||
made->beginBrush(QPointF(70, 100));
|
||||
made->finishBrush();
|
||||
const std::vector<int> values = channel(made->activeLayer().value().asset.value().image(), 1);
|
||||
for (int x = 790; x < 810; ++x)
|
||||
QCOMPARE(values[500 * 1000 + x], values[500 * 1000 + x - 600]);
|
||||
// Turned a quarter, across the canvas is down the pixels.
|
||||
rewrite(*made, [&](ProjectSnapshot &snapshot) {
|
||||
record(snapshot, id).transform.flipX = false;
|
||||
record(snapshot, id).transform.rotation = 90;
|
||||
});
|
||||
BrushStroke turned(made->activeLayer().value(), false, brush(10, 1, 0, 0, 0), QSizeF(200, 200));
|
||||
const QRectF shifted = turned.gridRect(turned.sourceRect, QSizeF(6, 0));
|
||||
QCOMPARE(shifted.topLeft() - turned.sourceRect.topLeft(), QPointF(0, 60));
|
||||
QCOMPARE(shifted.size(), turned.sourceRect.size());
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::blurKeepsTheLayersResolution()
|
||||
{
|
||||
const auto made = checkerSession();
|
||||
made->setBlurMode(BlurToolMode::blur);
|
||||
made->selectTool(NavigationTool::blur);
|
||||
made->setBrushSettings(brush(20, 1, 0, 0, 0));
|
||||
made->beginBrush(QPointF(100, 100));
|
||||
QVERIFY(made->brushStroke());
|
||||
made->finishBrush();
|
||||
QVERIFY(!made->brushError());
|
||||
const QImage image = made->activeLayer().value().asset.value().image();
|
||||
QCOMPARE(image.width(), 1000);
|
||||
const std::vector<int> values = channel(image, 1);
|
||||
// Under the brush the checks go gray; far off, not.
|
||||
for (int x = 480; x < 490; ++x)
|
||||
QVERIFY(values[500 * 1000 + x] > 40 && values[500 * 1000 + x] < 215);
|
||||
QVERIFY(holdsBoth(std::vector<int>(values.begin() + 100 * 1000 + 100, values.begin() + 100 * 1000 + 104)));
|
||||
}
|
||||
|
||||
void NativeResolutionPaintTests::layersPanelSaysHowFarALayerIsScaled()
|
||||
{
|
||||
const auto made = session(0.05);
|
||||
ImageLayer layer = made->activeLayer().value();
|
||||
QCOMPARE(sizeLabel(layer), QString("50 × 50 px · 5%"));
|
||||
layer.transform.size = QSizeF(1000, 1000);
|
||||
QCOMPARE(sizeLabel(layer), QString("1000 × 1000 px"));
|
||||
layer.transform.size = QSizeF(25, 25);
|
||||
QCOMPARE(sizeLabel(layer), QString("25 × 25 px · 2.5%"));
|
||||
// Across the width; under a twentieth percent shows none.
|
||||
layer.transform.size = QSizeF(1000.45, 300);
|
||||
QCOMPARE(sizeLabel(layer), QString("1000 × 300 px"));
|
||||
layer.transform.size = QSizeF(1000.55, 1000);
|
||||
QCOMPARE(sizeLabel(layer), QString("1001 × 1000 px · 100.1%"));
|
||||
// Rounded to a whole number, no decimal shows.
|
||||
layer.transform.size = QSizeF(499.9, 300);
|
||||
QCOMPARE(sizeLabel(layer), QString("500 × 300 px · 50%"));
|
||||
layer.transform.size = QSizeF(2333, 2000);
|
||||
QCOMPARE(sizeLabel(layer), QString("2333 × 2000 px · 233.3%"));
|
||||
// A blank layer has no pixels to measure against.
|
||||
QCOMPARE(sizeLabel(ImageLayer(QStringLiteral("Blank"), QSizeF(30, 20))), QString("30 × 20 px"));
|
||||
// The row shows it.
|
||||
NativeLayerList list(*made);
|
||||
list.resize(300, 200);
|
||||
list.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&list));
|
||||
QCOMPARE(list.cells().at(0)->findChild<QLabel *>("layerDimensions")->text(), QString("50 × 50 px · 5%"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(NativeResolutionPaintTests)
|
||||
#include "NativeResolutionPaintTests.moc"
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "BrushFixtures.h"
|
||||
#include "SelectionFixtures.h"
|
||||
|
||||
// Why a stroke is refused, as Photoshop explains it.
|
||||
class PaintRefusalTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void eachRefusalSaysWhy();
|
||||
void theGradientAndTheSmearSayWhyToo();
|
||||
void aBusyEditorOrAnotherToolSaysNothing();
|
||||
};
|
||||
|
||||
namespace {
|
||||
std::unique_ptr<EditorSession> makeSession()
|
||||
{
|
||||
auto session = std::make_unique<EditorSession>();
|
||||
session->createDocument(100, 80, true);
|
||||
session->selectTool(NavigationTool::brush);
|
||||
return session;
|
||||
}
|
||||
|
||||
// The press's error, cleared for the next.
|
||||
std::optional<QString> pressed(EditorSession &session)
|
||||
{
|
||||
session.beginBrush(QPointF(10, 10));
|
||||
const std::optional<QString> error = session.brushError();
|
||||
session.setBrushError(std::nullopt);
|
||||
return error;
|
||||
}
|
||||
|
||||
void emptySelection(EditorSession &session)
|
||||
{
|
||||
session.applySelection(rectPath(QRectF(0, 0, 10, 10)), SelectionMode::replace, "Select");
|
||||
session.applySelection(rectPath(QRectF(0, 0, 100, 80)), SelectionMode::subtract, "Subtract");
|
||||
QVERIFY(session.selection().value().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void PaintRefusalTests::eachRefusalSaysWhy()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
const QUuid layer = session->activeLayerID().value();
|
||||
QCOMPARE(session->paintRefusal(), std::nullopt);
|
||||
// Two layers, ahead of everything else.
|
||||
session->addGroup();
|
||||
const QUuid folder = session->activeLayerID().value();
|
||||
session->selectLayers({layer, folder}, folder);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("Several layers are selected. Select just one to paint on it."));
|
||||
session->selectLayer(folder);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Folder 1” is a folder, which has no pixels of its own. Paint on a layer inside it, or on the "
|
||||
"folder’s mask."));
|
||||
// Its mask paints; turned off, the mask says so.
|
||||
session->addLayerMask();
|
||||
QVERIFY(session->isMaskSelected() && session->canPaint());
|
||||
session->toggleLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("The layer mask is turned off. Shift-click its thumbnail to turn it on, then paint."));
|
||||
session->toggleLayerMask();
|
||||
session->selectLayer(layer);
|
||||
session->toggleLayerVisibility(layer);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Layer 1” is hidden, or inside a hidden folder. Show it to paint on it."));
|
||||
session->toggleLayerVisibility(layer);
|
||||
// Inside a hidden folder too.
|
||||
QVERIFY(session->placeLayer(layer, folder));
|
||||
session->toggleLayerVisibility(folder);
|
||||
session->selectLayer(layer);
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Layer 1” is hidden, or inside a hidden folder. Show it to paint on it."));
|
||||
session->toggleLayerVisibility(folder);
|
||||
QVERIFY(session->canPaint());
|
||||
session->addLayerMask();
|
||||
QVERIFY(session->isMaskSelected());
|
||||
session->toggleLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("The layer mask is turned off. Shift-click its thumbnail to turn it on, then paint."));
|
||||
// The pixels still paint under a mask turned off.
|
||||
session->selectLayerTarget(layer, false);
|
||||
QCOMPARE(session->paintRefusal(), std::nullopt);
|
||||
session->toggleLayerMask();
|
||||
session->addAdjustment(AdjustmentKind::levels);
|
||||
session->setAdjustmentEditingID(std::nullopt);
|
||||
const QUuid adjustment = session->activeLayerID().value();
|
||||
session->selectLayerTarget(adjustment, false);
|
||||
QVERIFY(!session->isMaskSelected());
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("“Levels” is an adjustment layer, with no pixels to paint. Paint on its mask instead."));
|
||||
// Its mask paints, so the empty selection is why.
|
||||
if (!session->activeLayer().value().mask)
|
||||
session->addLayerMask();
|
||||
session->selectLayerTarget(adjustment, true);
|
||||
emptySelection(*session);
|
||||
QCOMPARE(pressed(*session),
|
||||
std::optional<QString>("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere."));
|
||||
session->deselect();
|
||||
session->selectLayer(layer);
|
||||
emptySelection(*session);
|
||||
QCOMPARE(pressed(*session),
|
||||
std::optional<QString>("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere."));
|
||||
QVERIFY(!session->brushStroke());
|
||||
session->deselect();
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QVERIFY(session->brushStroke() && !session->brushError());
|
||||
session->cancelBrush();
|
||||
}
|
||||
|
||||
void PaintRefusalTests::theGradientAndTheSmearSayWhyToo()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
session->insert(filledRed());
|
||||
emptySelection(*session);
|
||||
const QString empty("Nothing is selected, so there’s nowhere to paint. Choose Select › Deselect (Ctrl+D) to paint anywhere.");
|
||||
session->selectTool(NavigationTool::gradient);
|
||||
session->beginGradient(QPointF(5, 5));
|
||||
QCOMPARE(session->brushError(), std::optional(empty));
|
||||
QVERIFY(!session->gradientEdit());
|
||||
session->setBrushError(std::nullopt);
|
||||
session->selectTool(NavigationTool::blur);
|
||||
session->setBlurMode(BlurToolMode::smudge);
|
||||
QCOMPARE(pressed(*session), std::optional(empty));
|
||||
// The mask's own words come first.
|
||||
session->deselect();
|
||||
session->addLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("Smudge and Liquify work on a layer's pixels, not its mask."));
|
||||
session->toggleLayerMask();
|
||||
QCOMPARE(pressed(*session), std::optional<QString>("Smudge and Liquify work on a layer's pixels, not its mask."));
|
||||
}
|
||||
|
||||
void PaintRefusalTests::aBusyEditorOrAnotherToolSaysNothing()
|
||||
{
|
||||
const auto session = makeSession();
|
||||
emptySelection(*session);
|
||||
session->setIsProjectBusy(true);
|
||||
QCOMPARE(session->paintRefusal(), std::nullopt);
|
||||
session->setBrushError(QStringLiteral("Earlier"));
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QCOMPARE(session->brushError(), std::nullopt);
|
||||
session->setIsProjectBusy(false);
|
||||
// Spot Healing never paints a mask, and says nothing.
|
||||
session->deselect();
|
||||
session->addLayerMask();
|
||||
session->toggleLayerMask();
|
||||
session->selectTool(NavigationTool::spotHealing);
|
||||
session->setBrushError(QStringLiteral("Earlier"));
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QCOMPARE(session->brushError(), std::optional<QString>("Earlier"));
|
||||
session->selectTool(NavigationTool::move);
|
||||
session->beginBrush(QPointF(10, 10));
|
||||
QCOMPARE(session->brushError(), std::optional<QString>("Earlier"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(PaintRefusalTests)
|
||||
#include "PaintRefusalTests.moc"
|
||||
@@ -121,17 +121,22 @@ void SelectMenuTests::selectAllInAFieldIsTheFields()
|
||||
QVERIFY(!bar.session().selection().has_value());
|
||||
QTest::keyClick(editor, Qt::Key_Escape);
|
||||
QTRY_VERIFY(!bar.session().renamingLayerID().has_value());
|
||||
// The list holds the keys: its rows, as Swift's table.
|
||||
// The list holds the keys: the canvas, never every layer.
|
||||
bar.session().addBlankLayer();
|
||||
NativeLayerList *list = bar.window.findChild<NativeLayerList *>();
|
||||
list->setFocus();
|
||||
QTRY_VERIFY(list->hasFocus());
|
||||
const std::vector<ImageLayer> &layers = bar.session().document().value().layers;
|
||||
bar.action("selectAll").trigger();
|
||||
QCOMPARE(bar.session().selectedLayerIDs(), (QSet<QUuid>{layers[0].id, layers[1].id}));
|
||||
QCOMPARE(bar.session().activeLayerID(), std::optional(layers[1].id));
|
||||
QVERIFY(!bar.session().selection().has_value());
|
||||
// Elsewhere it selects the canvas.
|
||||
const QSet<QUuid> selected = bar.session().selectedLayerIDs();
|
||||
const std::optional<QUuid> active = bar.session().activeLayerID();
|
||||
QCOMPARE(selected.size(), 1);
|
||||
QTest::keyClick(list, Qt::Key_A, Qt::ControlModifier);
|
||||
QCOMPARE(bar.session().selection().value().path.boundingRect(), QRectF(0, 0, 50, 50));
|
||||
QCOMPARE(bar.session().history.undoName(), QString("Select All"));
|
||||
QCOMPARE(bar.session().selectedLayerIDs(), selected);
|
||||
QCOMPARE(bar.session().activeLayerID(), active);
|
||||
QVERIFY(list->hasFocus());
|
||||
// Elsewhere it selects the canvas too.
|
||||
bar.session().deselect();
|
||||
bar.window.findChild<CanvasView *>()->setFocus();
|
||||
QTRY_VERIFY(!list->hasFocus());
|
||||
bar.action("selectAll").trigger();
|
||||
|
||||
@@ -11,6 +11,7 @@ private slots:
|
||||
void focusLossEndsAMarqueeDraftAndAnOutlineMove();
|
||||
void shiftDeleteBeginsAContentAwareFill();
|
||||
void aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem();
|
||||
void shiftKeepsMovedPixelsOnALine();
|
||||
};
|
||||
|
||||
void SelectionCanvasTests::marqueeDragsSelectShiftAddsAltSubtractsAndAFreshShiftSquares()
|
||||
@@ -322,5 +323,37 @@ void SelectionCanvasTests::aCtrlDragMovesSelectedPixelsAndCtrlArrowsNudgeThem()
|
||||
QVERIFY(session.lassoDraft().has_value() && !session.pixelMove());
|
||||
}
|
||||
|
||||
void SelectionCanvasTests::shiftKeepsMovedPixelsOnALine()
|
||||
{
|
||||
Canvas shown;
|
||||
EditorSession &session = shown.session;
|
||||
QImage red = BrushRaster::context(400, 300, false);
|
||||
red.fill(Qt::red);
|
||||
session.insert(ImportedImage(red, red, "Red"));
|
||||
session.applySelection(rectPath(QRectF(20, 20, 40, 40)), SelectionMode::replace, "Select");
|
||||
shown.canvas->synchronizeDisplay();
|
||||
const auto box = [&] { return session.displayedSelection().value().path.boundingRect(); };
|
||||
shown.press(QPointF(40, 40), Qt::ControlModifier);
|
||||
QVERIFY(session.pixelMove());
|
||||
// Across: the drag went further that way.
|
||||
shown.move(QPointF(100, 55), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(80, 20, 40, 40));
|
||||
// Down, then a tie keeps across.
|
||||
shown.move(QPointF(50, 100), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(20, 80, 40, 40));
|
||||
shown.move(QPointF(10, 10), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(-10, 20, 40, 40));
|
||||
// Lengths, not signs: left beats a shorter down.
|
||||
shown.move(QPointF(0, 50), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QCOMPARE(box(), QRectF(-20, 20, 40, 40));
|
||||
// Without Shift both ways.
|
||||
shown.move(QPointF(60, 55), Qt::ControlModifier);
|
||||
QCOMPARE(box(), QRectF(40, 35, 40, 40));
|
||||
shown.move(QPointF(70, 45), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
shown.release(QPointF(70, 45), Qt::ControlModifier | Qt::ShiftModifier);
|
||||
QTRY_VERIFY(!session.pixelMove());
|
||||
QCOMPARE(session.selection().value().path.boundingRect(), QRectF(50, 20, 40, 40));
|
||||
}
|
||||
|
||||
QTEST_MAIN(SelectionCanvasTests)
|
||||
#include "SelectionCanvasTests.moc"
|
||||
@@ -11,6 +11,7 @@ private slots:
|
||||
void polygonalCornersCanBeRemovedAndClosed();
|
||||
void antialiasingControlsEdgeCoverage();
|
||||
void selectAllInverseAndToolSwitchCancelsDraft();
|
||||
void inverseOfEverythingDeselects();
|
||||
void cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode();
|
||||
void draggingMovesTheOutlineInWholePixelsAsOneUndo();
|
||||
void movingOffCanvasAndBackKeepsTheWholeShape();
|
||||
@@ -180,6 +181,23 @@ void SelectionTests::selectAllInverseAndToolSwitchCancelsDraft()
|
||||
QCOMPARE(session->history.undoCount(), count + 1);
|
||||
}
|
||||
|
||||
// Swift's: Select All then Inverse leaves nothing selected.
|
||||
void SelectionTests::inverseOfEverythingDeselects()
|
||||
{
|
||||
const auto session = selectionSession();
|
||||
session->selectAll();
|
||||
session->invertSelection();
|
||||
QVERIFY(!session->selection().has_value());
|
||||
QCOMPARE(session->history.undoName(), QString("Inverse"));
|
||||
session->undo();
|
||||
QVERIFY(!session->selection().value().isEmpty());
|
||||
// Everything but a corner inverts to that corner.
|
||||
lasso(*session, square(0, 0, 10));
|
||||
session->invertSelection();
|
||||
session->invertSelection();
|
||||
QCOMPARE(bounds(*session), QRectF(0, 0, 10, 10));
|
||||
}
|
||||
|
||||
void SelectionTests::cursorBadgeFollowsModifiersButKeepsAnOutlinesStartingMode()
|
||||
{
|
||||
const auto session = selectionSession();
|
||||
|
||||
@@ -146,6 +146,7 @@ compositor_test(ProjectManifestTests)
|
||||
compositor_test(NativeLayerListTests)
|
||||
compositor_test(LayerContextMenuTests)
|
||||
compositor_test(LayerContextMenuEdgeTests)
|
||||
compositor_test(NativeResolutionPaintTests)
|
||||
compositor_test(NavigationToolHeaderTests)
|
||||
compositor_test(NumericScrubTests)
|
||||
compositor_test(ScrubbableLabelsTests)
|
||||
@@ -293,6 +294,7 @@ compositor_test(LayerFlipTests)
|
||||
compositor_test(LayerMergeTests)
|
||||
compositor_test(PixelEditTests)
|
||||
compositor_test(PixelInvertTests)
|
||||
compositor_test(PaintRefusalTests)
|
||||
compositor_test(PixelMoveTests)
|
||||
compositor_test(SelectionTests)
|
||||
compositor_test(SelectionFeatherTests)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "UI/LayerIcons.h"
|
||||
#include "UI/TransformInspector.h"
|
||||
#include <QtTest>
|
||||
#include <cmath>
|
||||
|
||||
// The Move tool's bar: fields, settings, Apply and Cancel.
|
||||
namespace {
|
||||
@@ -59,6 +60,7 @@ private slots:
|
||||
void theLockIconFollowsThePalette();
|
||||
void theFieldsTakeTheSpareRoom();
|
||||
void heldKeysShowFlippedInTheBar();
|
||||
void cancelAndApplyShowOnlyWhileAnEditWaits();
|
||||
};
|
||||
|
||||
void TransformInspectorTests::theFieldsShowTheLayerAndTypingPreviews()
|
||||
@@ -341,7 +343,7 @@ void TransformInspectorTests::theFieldsTakeTheSpareRoom()
|
||||
const QRect natural = flip.geometry();
|
||||
// Swift's ScrollView takes the room; its content keeps its size.
|
||||
bar.header->resize(1600, bar.header->height());
|
||||
QTRY_VERIFY(bar.control<QPushButton>("applyTransform").geometry().right() > 1600 - 40);
|
||||
QTRY_VERIFY(bar.control<QWidget>("pendingTransform").geometry().right() > 1600 - 40);
|
||||
QCOMPARE(flip.geometry(), natural);
|
||||
QCOMPARE(flip.width(), flip.sizeHint().width());
|
||||
}
|
||||
@@ -415,5 +417,52 @@ void TransformInspectorTests::heldKeysShowFlippedInTheBar()
|
||||
QTest::keyRelease(&x, Qt::Key_Control, Qt::NoModifier);
|
||||
}
|
||||
|
||||
void TransformInspectorTests::cancelAndApplyShowOnlyWhileAnEditWaits()
|
||||
{
|
||||
Bar bar;
|
||||
QVERIFY(bar.active);
|
||||
QWidget &buttons = bar.control<QWidget>("pendingTransform");
|
||||
auto *fade = qobject_cast<QGraphicsOpacityEffect *>(buttons.graphicsEffect());
|
||||
QVERIFY(fade);
|
||||
QPushButton &apply = bar.control<QPushButton>("applyTransform");
|
||||
QPushButton &cancel = bar.control<QPushButton>("cancelTransform");
|
||||
const auto seen = [&](bool shown) {
|
||||
return fade->opacity() == (shown ? 1.0 : 0.0) && buttons.testAttribute(Qt::WA_TransparentForMouseEvents) == !shown
|
||||
&& apply.focusPolicy() == (shown ? Qt::StrongFocus : Qt::NoFocus) && cancel.focusPolicy() == apply.focusPolicy();
|
||||
};
|
||||
QVERIFY(seen(false));
|
||||
// SwiftUI's easeOut over 0.12 seconds; 12 points apart.
|
||||
auto *fadeIn = bar.header->findChild<QPropertyAnimation *>();
|
||||
QVERIFY(fadeIn && fadeIn->duration() == 120 && fadeIn->easingCurve().type() == QEasingCurve::BezierSpline);
|
||||
// CSS's ease-out, 0, 0, 0.58, 1: about 0.685 halfway.
|
||||
QVERIFY(std::abs(fadeIn->easingCurve().valueForProgress(0.5) - 0.685) < 0.01);
|
||||
QCOMPARE(apply.geometry().left() - cancel.geometry().right() - 1, 12);
|
||||
// A handle drag applies itself: nothing waits.
|
||||
bar.session.beginTransform(false);
|
||||
QVERIFY(apply.isEnabled());
|
||||
QTest::qWait(200);
|
||||
QVERIFY(seen(false));
|
||||
// Unseen, the click falls through; Return still applies.
|
||||
const QPoint middle = apply.mapTo(bar.header.get(), apply.rect().center());
|
||||
QTest::mouseClick(bar.header->windowHandle(), Qt::LeftButton, Qt::NoModifier, middle);
|
||||
QVERIFY(bar.session.transformEdit().has_value());
|
||||
QTest::keyClick(bar.header.get(), Qt::Key_Return);
|
||||
QVERIFY(!bar.session.transformEdit().has_value());
|
||||
// Typed values wait: the buttons fade in, a click applies.
|
||||
bar.session.beginTransform();
|
||||
QVERIFY(fade->opacity() < 1);
|
||||
QTRY_VERIFY(seen(true));
|
||||
QVERIFY(apply.isVisible() && apply.isEnabled());
|
||||
QTest::mouseClick(bar.header->windowHandle(), Qt::LeftButton, Qt::NoModifier, middle);
|
||||
QVERIFY(!bar.session.transformEdit().has_value());
|
||||
QTRY_VERIFY(seen(false));
|
||||
// Waiting again, Cancel drops it.
|
||||
bar.session.beginTransform();
|
||||
QTRY_VERIFY(seen(true));
|
||||
QTest::mouseClick(bar.header->windowHandle(), Qt::LeftButton, Qt::NoModifier, cancel.mapTo(bar.header.get(), cancel.rect().center()));
|
||||
QVERIFY(!bar.session.transformEdit().has_value());
|
||||
QTRY_VERIFY(seen(false));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TransformInspectorTests)
|
||||
#include "TransformInspectorTests.moc"
|
||||
Reference in new issue
Block a user