Magic tool Object mode (Compositor 1.1.8)

This commit is contained in:
雷电芽衣 committed 2026-09-26 23:24:35 -04:00
1 parent 78bfa2445d
commit 3d56688ce4
39 files changed
+1193 -134

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ void CompositorUITestsLaunchTests::testLaunch()
// The welcome is up: Swift's launch test waits for it.
QVERIFY(window.findChild<QWidget *>("createCanvas")->isVisible());
QCoreApplication::processEvents();
QVERIFY(window.grab().save(QStringLiteral("LaunchScreen.png")));
QVERIFY(window.grab().save(QCoreApplication::applicationDirPath() + QStringLiteral("/LaunchScreen.png")));
}
void CompositorUITestsLaunchTests::testWindowKeepsItsMinimumSize()
+7 -1
View File
@@ -141,7 +141,7 @@ void ContentViewStatusTests::everyToolHasItsHint()
{NavigationTool::move, "Drag to move · Handles to resize · Circle to rotate · 1–0 layer opacity · Space to pan"},
{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::lasso, "Drag to select · Drag inside to move · Shift add · Alt subtract · Delete clears · Alt+Backspace/Ctrl+Backspace fill · 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::wand, "Click to select similar colors · Tab for Object · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"},
{NavigationTool::crop, "Drag to crop · Enter apply · Escape cancel · Space to pan"},
{NavigationTool::brush, "Drag to paint · [ ] size · Shift-[ ] hardness · 1–0 opacity · Escape cancel · Space to pan"},
{NavigationTool::spotHealing, "Drag over blemishes to heal · [ ] size · Shift-[ ] hardness · Escape cancel · Space to pan"},
@@ -159,6 +159,7 @@ void ContentViewStatusTests::everyToolHasItsHint()
QCOMPARE(ContentView::hint(NavigationTool::move, ToolIconKind::eraser), ContentView::hint(NavigationTool::move));
QCOMPARE(ContentView::hint(NavigationTool::lasso, ToolIconKind::ellipse), ContentView::hint(NavigationTool::lasso));
QCOMPARE(ContentView::hint(NavigationTool::marquee, ToolIconKind::polygonal), ContentView::hint(NavigationTool::marquee));
QCOMPARE(ContentView::hint(NavigationTool::lasso, ToolIconKind::object), ContentView::hint(NavigationTool::lasso));
EditorSession session;
session.createDocument(8, 8);
ContentView view(session);
@@ -166,6 +167,11 @@ void ContentViewStatusTests::everyToolHasItsHint()
session.selectTool(tool);
QCOMPARE(find<QLabel>(view, "activityStatus").text(), QString::fromUtf8(words));
}
// Magic's words follow its mode.
session.selectTool(NavigationTool::wand);
session.setWandMode(WandMode::object);
QCOMPARE(find<QLabel>(view, "activityStatus").text(),
QString("Click an object to select its outline · Tab for Wand · Shift add · Alt subtract · Drag inside to move · Ctrl-drag moves pixels · Delete clears · Ctrl+D deselect"));
// The Shape's Shift follows its kind.
session.selectTool(NavigationTool::shape);
session.setShapeKind(ShapeKind::ellipse);
+9 -2
View File
@@ -32,7 +32,7 @@ const std::pair<NavigationTool, const char *> labelled[] = {
{NavigationTool::move, "Move / Transform (V)"},
{NavigationTool::marquee, "Marquee (M)"},
{NavigationTool::lasso, "Lasso (L)"},
{NavigationTool::wand, "Magic Wand (W)"},
{NavigationTool::wand, "Magic (W) · Tab switches Wand and Object"},
{NavigationTool::crop, "Crop (C)"},
{NavigationTool::brush, "Brush (B) · Eraser (E)"},
{NavigationTool::spotHealing, "Spot Healing Brush (J)"},
@@ -167,6 +167,13 @@ void ContentViewTests::theRailHoldsEveryToolInSwiftsOrder()
QVERIFY(brush->grab().toImage() != painting);
session.setBrushMode(BrushToolMode::paint);
QCOMPARE(brush->grab().toImage(), painting);
// Magic's shows the object icon in Object mode.
QToolButton *const magic = buttons[3];
const QImage colours = magic->grab().toImage();
session.setWandMode(WandMode::object);
QVERIFY(magic->grab().toImage() != colours);
session.setWandMode(WandMode::wand);
QCOMPARE(magic->grab().toImage(), colours);
}
void ContentViewTests::theBarFollowsTheTool()
@@ -236,7 +243,7 @@ void ContentViewTests::theBarFollowsTheTool()
QCOMPARE(bar().title->text(), QString("Lasso"));
session.selectTool(NavigationTool::wand);
QVERIFY(bar().property("kept").toBool());
QCOMPARE(bar().title->text(), QString("Magic Wand"));
QCOMPARE(bar().title->text(), QString("Magic"));
QCOMPARE(bar().height(), 42);
QCOMPARE(bar().height(), 42);
}
+98 -1
View File
@@ -33,6 +33,7 @@ private slots:
void expandContractAndTheirAmountsNeedASelection();
void deselectTheEmptyWordsAndAntialiasFollowTheSession();
void theWandsControlsWriteItsSettings();
void magicsModesAndObjectControlsWriteTheirSettings();
};
void LassoControlsTests::theBarShowsTheToolsKindsAndSwitchesThem()
@@ -63,7 +64,7 @@ void LassoControlsTests::theBarShowsTheToolsKindsAndSwitchesThem()
QVERIFY(bar.button("marqueeEllipse").isChecked() && !bar.button("marqueeRectangle").isChecked());
QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible());
session.selectTool(NavigationTool::wand);
QCOMPARE(bar.controls.title->text(), QString("Magic Wand"));
QCOMPARE(bar.controls.title->text(), QString("Magic"));
QVERIFY(!bar.button("marqueeRectangle").isVisible() && !bar.button("lassoFreehand").isVisible());
QVERIFY(find<QCheckBox>(bar.controls, "selectionAntialiased").isVisible());
QCOMPARE(bar.controls.height(), 42);
@@ -254,6 +255,10 @@ void LassoControlsTests::theWandsControlsWriteItsSettings()
QTest::keyClicks(&tolerance, "0");
QTest::keyClick(&tolerance, Qt::Key_Down);
QCOMPARE(session.wandSettings().tolerance, 0);
// A field that stays above zero takes no sign.
tolerance.selectAll();
QTest::keyClicks(&tolerance, "-");
QCOMPARE(tolerance.text(), QString("0"));
QTest::keyClick(&tolerance, Qt::Key_Return);
// The other three write their fields and read them back.
size.setCurrentIndex(2);
@@ -271,5 +276,97 @@ void LassoControlsTests::theWandsControlsWriteItsSettings()
QVERIFY(bar.button("wandThisLayer").isChecked() && contiguous.isChecked());
}
void LassoControlsTests::magicsModesAndObjectControlsWriteTheirSettings()
{
Bar bar;
EditorSession &session = *bar.session;
auto &edge = find<QLineEdit>(bar.controls, "objectEdge");
auto &antialias = find<QCheckBox>(bar.controls, "selectionAntialiased");
QToolButton &wand = bar.button("magicWandMode"), &object = bar.button("magicObjectMode");
// The modes show with Magic alone, labelled as Swift's.
QVERIFY(!wand.isVisible() && !object.isVisible());
session.selectTool(NavigationTool::wand);
QVERIFY(wand.isVisible() && object.isVisible() && wand.isChecked() && !object.isChecked());
QCOMPARE(wand.text(), QString("Wand"));
QCOMPARE(object.text(), QString("Object"));
QCOMPARE(object.toolTip(), QString("Press Tab to switch between Wand and Object"));
QCOMPARE(antialias.toolTip(), QString("Smooth selection edges; turn off for hard pixel edges"));
QVERIFY(!edge.isVisible() && !bar.button("objectThisLayer").isVisible());
// Magic takes no outline, so a choice only switches.
session.beginLasso(QPointF(5, 5), SelectionMode::replace);
QVERIFY(!session.lassoDraft());
QTest::mouseClick(&object, Qt::LeftButton);
QCOMPARE(session.wandMode(), WandMode::object);
QVERIFY(object.isChecked() && !wand.isChecked());
// Object shows its own controls and hides the wand's.
QVERIFY(edge.isVisible() && bar.button("objectThisLayer").isVisible() && bar.button("objectAllLayers").isVisible());
QVERIFY(find<QLabel>(bar.controls, "objectEdgeLabel").isVisible());
QVERIFY(!find<QLineEdit>(bar.controls, "wandTolerance").isVisible() && !find<QCheckBox>(bar.controls, "wandContiguous").isVisible());
QVERIFY(!bar.button("wandThisLayer").isVisible());
QCOMPARE(antialias.toolTip(), QString("Smooth the detected object outline; turn off for the raw pixel mask"));
QCOMPARE(bar.button("objectAllLayers").toolTip(), QString("Analyze the active layer only, or every visible layer as shown"));
QCOMPARE(edge.toolTip(), QString("Positive values tighten the detected mask inward; negative values expand it outward"));
QVERIFY(bar.button("objectAllLayers").isChecked() && !bar.button("objectThisLayer").isChecked());
QCOMPARE(edge.text(), QString("0"));
// The source writes its field alone.
QTest::mouseClick(&bar.button("objectThisLayer"), Qt::LeftButton);
QVERIFY(!session.objectSelectionSettings().sampleAllLayers);
QTest::mouseClick(&bar.button("objectAllLayers"), Qt::LeftButton);
QVERIFY(session.objectSelectionSettings().sampleAllLayers);
// The edge takes a sign, clamped to ten either way.
edge.setFocus();
QTRY_VERIFY(edge.hasFocus());
edge.selectAll();
QTest::keyClicks(&edge, "-");
QCOMPARE(session.objectSelectionSettings().edgeOffset, 0);
// A sign leads the digits, never follows them.
QTest::keyClicks(&edge, "-");
QCOMPARE(edge.text(), QString("-"));
QTest::keyClicks(&edge, "4");
QCOMPARE(session.objectSelectionSettings().edgeOffset, -4);
QTest::keyClicks(&edge, "0");
QCOMPARE(session.objectSelectionSettings().edgeOffset, -10);
edge.selectAll();
QTest::keyClicks(&edge, "12");
QCOMPARE(session.objectSelectionSettings().edgeOffset, 10);
edge.selectAll();
edge.insert(QStringLiteral("-99999999999999999999"));
QCOMPARE(session.objectSelectionSettings().edgeOffset, -10);
edge.selectAll();
edge.insert(QStringLiteral("99999999999999999999"));
QCOMPARE(session.objectSelectionSettings().edgeOffset, 10);
edge.selectAll();
QTest::keyClick(&edge, Qt::Key_Backspace);
QTest::keyClicks(&edge, "a+");
QCOMPARE(edge.text(), QString());
QCOMPARE(session.objectSelectionSettings().edgeOffset, 10);
QTest::keyClicks(&edge, "3");
QTest::keyClick(&edge, Qt::Key_Down, Qt::ShiftModifier);
QCOMPARE(session.objectSelectionSettings().edgeOffset, -7);
QTest::keyClick(&edge, Qt::Key_Down, Qt::ShiftModifier);
QCOMPARE(session.objectSelectionSettings().edgeOffset, -10);
QTest::keyClick(&edge, Qt::Key_Up);
QCOMPARE(session.objectSelectionSettings().edgeOffset, -9);
QVERIFY(session.objectSelectionSettings().sampleAllLayers);
QTest::keyClick(&edge, Qt::Key_Return);
// The session's own changes show; Wand brings its controls back.
ObjectSelectionSettings other;
other.sampleAllLayers = false;
other.edgeOffset = 6;
session.setObjectSelectionSettings(other);
QCOMPARE(edge.text(), QString("6"));
QVERIFY(bar.button("objectThisLayer").isChecked());
session.cycleToolMode();
QVERIFY(wand.isChecked() && !edge.isVisible() && find<QLineEdit>(bar.controls, "wandTolerance").isVisible());
session.setWandMode(WandMode::object);
QVERIFY(object.isChecked() && !wand.isChecked());
QTest::mouseClick(&object, Qt::LeftButton);
QTest::mouseClick(&wand, Qt::LeftButton);
QCOMPARE(session.wandMode(), WandMode::wand);
// The Lasso shows neither.
session.selectTool(NavigationTool::lasso);
QVERIFY(!wand.isVisible() && !edge.isVisible());
}
QTEST_MAIN(LassoControlsTests)
#include "LassoControlsTests.moc"
+475
View File
@@ -0,0 +1,475 @@
#include "SelectionCanvasFixtures.h"
#include "Document/BrushStroke.h"
#include "Document/MagicWand.h"
#include "Document/ObjectSelection.h"
#include "Rendering/SelectionIcons.h"
#include "UI/NativeLayerList.h"
#include <QPainter>
// The Magic tool's Object mode: regions, edges, outlines, clicks.
namespace {
// Red discs on white, or flat white.
QImage discs(const std::vector<std::pair<QPointF, double>> &shapes, QSize size = QSize(400, 300))
{
QImage image = BrushRaster::context(size.width(), size.height(), false);
image.fill(Qt::white);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(217, 26, 26));
for (const auto &[centre, radius] : shapes)
painter.drawEllipse(centre, radius, radius);
return image;
}
// Pixels an outline covers at half coverage or more.
int covered(const std::optional<QPainterPath> &path, QSize size)
{
const QImage coverage = DocumentSelection{path.value(), false}.coverage(size.width(), size.height());
int count = 0;
for (int y = 0; y < size.height(); ++y) {
for (int x = 0; x < size.width(); ++x)
count += coverage.constScanLine(y)[x] >= 128;
}
return count;
}
bool inside(const std::optional<QPainterPath> &path, QPointF point)
{
return path.value().contains(point);
}
std::unique_ptr<EditorSession> withDisc()
{
auto session = std::make_unique<EditorSession>();
session->createDocument(400, 300);
const QImage image = discs({{QPointF(200, 150), 80}});
session->insert(ImportedImage(image, image, QStringLiteral("Disc")));
session->selectTool(NavigationTool::wand);
session->setWandMode(WandMode::object);
return session;
}
void selectObject(EditorSession &session, QPointF point, SelectionMode mode)
{
bool done = false;
session.selectObject(point, mode, [&done] { done = true; });
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
}
// Points of the path, a subpath's close included.
int points(const QPainterPath &path)
{
return path.elementCount();
}
}
class ObjectSelectionTests : public QObject {
Q_OBJECT
private slots:
void aRegionTakesTheEightConnectedCellsUnderTheClick();
void edgesStepARingAPixelAndStopAtTen();
void smoothingRoundsStairsAndKeepsSubpathsAndTheirRule();
void theObjectUnderTheClickIsTracedAndTheBackgroundIsNone();
void theEdgeOffsetTightensAndLoosensTheOutline();
void theEdgeFollowsTheImageNotTheModelsGrid();
void theSessionSelectsCombinesAndNamesTheStep();
void theSessionRefusesAndReadsTheLayerItIsTold();
void theModeSwitchesByTabAndWKeepsIt();
void theCanvasClicksPicksAgainInsideAndShowsTheObjectCursor();
};
void ObjectSelectionTests::aRegionTakesTheEightConnectedCellsUnderTheClick()
{
const std::vector<uchar> mask{
1, 1, 0, 0, 1,
0, 1, 0, 0, 1,
0, 0, 1, 0, 0,
1, 0, 0, 0, 1,
};
const std::vector<uchar> first = ObjectSelection::region(mask, 5, 4, 0, 0).value();
QCOMPARE(first, (std::vector<uchar>{
1, 1, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 0, 0,
}));
// One region from any cell, the diagonal included.
QCOMPARE(ObjectSelection::region(mask, 5, 4, 2, 2).value(), first);
QCOMPARE(ObjectSelection::region(mask, 5, 4, 4, 1).value(), (std::vector<uchar>{
0, 0, 0, 0, 1,
0, 0, 0, 0, 1,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
}));
QCOMPARE(ObjectSelection::region(mask, 5, 4, 4, 3).value(), (std::vector<uchar>{
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 1,
}));
// Background holds no region.
QVERIFY(!ObjectSelection::region(mask, 5, 4, 3, 0));
}
void ObjectSelectionTests::edgesStepARingAPixelAndStopAtTen()
{
std::vector<uchar> block(49);
for (int y = 2; y <= 4; ++y) {
for (int x = 2; x <= 4; ++x)
block[size_t(y * 7 + x)] = 255;
}
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 0), block);
std::vector<uchar> centre(49);
centre[24] = 255;
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 1), centre);
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, 2), std::vector<uchar>(49));
std::vector<uchar> grown(49);
for (int y = 1; y <= 5; ++y) {
for (int x = 1; x <= 5; ++x)
grown[size_t(y * 7 + x)] = 255;
}
QCOMPARE(ObjectSelection::adjusted(block, 7, 7, -1), grown);
// Past ten steps either way, ten.
std::vector<uchar> dot(25 * 25);
dot[12 * 25 + 12] = 255;
const std::vector<uchar> far = ObjectSelection::adjusted(dot, 25, 25, -20);
QCOMPARE(far, ObjectSelection::adjusted(dot, 25, 25, -10));
QCOMPARE(std::count(far.begin(), far.end(), uchar(255)), 21 * 21);
QCOMPARE(far[2 * 25 + 2], uchar(255));
QCOMPARE(far[1 * 25 + 12], uchar(0));
std::vector<uchar> full(25 * 25, 255);
QCOMPARE(ObjectSelection::adjusted(far, 25, 25, 20), ObjectSelection::adjusted(far, 25, 25, 10));
// The image's edge counts as inside: a full mask keeps.
QCOMPARE(ObjectSelection::adjusted(full, 25, 25, 3), full);
}
void ObjectSelectionTests::smoothingRoundsStairsAndKeepsSubpathsAndTheirRule()
{
// A staircase triangle and a holed square, traced.
std::vector<uchar> mask(40 * 40);
for (int y = 0; y < 40; ++y) {
for (int x = 0; x < 40; ++x)
mask[size_t(y * 40 + x)] = (x <= y && y < 20) || (x >= 24 && x < 36 && y >= 24 && y < 36 && !(x >= 28 && x < 32 && y >= 28 && y < 32)) ? 255 : 0;
}
const QPainterPath traced = MagicWand::outline(mask, 40, 40).value();
QVERIFY(points(traced) > 40);
const QPainterPath smooth = ObjectSelection::smoothed(traced);
QCOMPARE(smooth.fillRule(), traced.fillRule());
// Three subpaths, each simplified, then cut three times.
const QList<QPolygonF> parts = smooth.toSubpathPolygons();
QCOMPARE(parts.size(), 3);
QCOMPARE(parts[0].size(), 3 * 8 + 1);
QCOMPARE(parts[1].size(), 4 * 8 + 1);
QCOMPARE(parts[2].size(), 4 * 8 + 1);
// The stair is gone: the hypotenuse is straight.
QVERIFY(smooth.contains(QPointF(8, 12)) && !smooth.contains(QPointF(12, 8)));
// Corners round off; the hole stays a hole.
QVERIFY(smooth.contains(QPointF(30, 25)) && !smooth.contains(QPointF(30, 30)));
QVERIFY(!smooth.contains(QPointF(24.3, 24.3)));
QVERIFY(traced.contains(QPointF(24.3, 24.3)));
// By hand: the extreme breaks the loop; curves keep ends.
QPainterPath open;
open.moveTo(0, 0);
for (const QPointF point : {QPointF(5, -1.3), QPointF(10, 0), QPointF(10, 10), QPointF(0, 10), QPointF(0, 5)})
open.lineTo(point);
open.moveTo(20, 0);
open.lineTo(30, 0);
open.cubicTo(30, 3, 30, 7, 30, 10);
open.lineTo(20, 10);
const QList<QPolygonF> built = ObjectSelection::smoothed(open).toSubpathPolygons();
QCOMPARE(built.size(), 2);
QCOMPARE(built[0].size(), 33);
QCOMPARE(built[0].front(), QPointF(4.375, 0));
QCOMPARE(built[1].size(), 33);
QCOMPARE(built[1].front(), QPointF(24.375, 0));
QCOMPARE(built[1][8], QPointF(30, 4.375));
// A flat loop keeps its points; a closed pair goes.
QPainterPath flat;
flat.moveTo(40, 0);
for (const QPointF point : {QPointF(45, 0), QPointF(50, 0), QPointF(45, 0.5)})
flat.lineTo(point);
flat.closeSubpath();
flat.moveTo(60, 0);
flat.lineTo(70, 0);
flat.closeSubpath();
const QList<QPolygonF> kept = ObjectSelection::smoothed(flat).toSubpathPolygons();
QCOMPARE(kept.size(), 1);
QCOMPARE(kept[0].size(), 33);
// A subpath under three points is dropped.
QPainterPath thin;
thin.moveTo(0, 0);
thin.lineTo(5, 5);
QVERIFY(ObjectSelection::smoothed(thin).isEmpty());
}
void ObjectSelectionTests::theObjectUnderTheClickIsTracedAndTheBackgroundIsNone()
{
const QImage pair = discs({{QPointF(110, 150), 60}, {QPointF(290, 150), 60}});
const std::optional<QPainterPath> left = ObjectSelection::select(pair, QPointF(110, 150), 0, false);
QVERIFY(inside(left, QPointF(110, 150)) && inside(left, QPointF(160, 150)));
QVERIFY(!inside(left, QPointF(290, 150)) && !inside(left, QPointF(10, 10)) && !inside(left, QPointF(178, 150)));
const std::optional<QPainterPath> right = ObjectSelection::select(pair, QPointF(290.7, 150.2), 0, false);
QVERIFY(inside(right, QPointF(290, 150)) && !inside(right, QPointF(110, 150)));
// The raw mask hugs the disc, within two percent.
const int area = covered(left, pair.size());
QVERIFY2(std::abs(area - M_PI * 60 * 60) < 0.02 * M_PI * 60 * 60, qPrintable(QString::number(area)));
// Background, off the image, no number, or no subject: none.
QVERIFY(!ObjectSelection::select(pair, QPointF(10, 10), 0, false));
QVERIFY(!ObjectSelection::select(pair, QPointF(200, 150), 0, false));
for (const QPointF point : {QPointF(-0.5, 10), QPointF(400, 10), QPointF(10, 300), QPointF(std::nan(""), 10), QPointF(10, INFINITY)})
QVERIFY(!ObjectSelection::select(pair, point, 0, false));
QVERIFY(!ObjectSelection::select(discs({}), QPointF(200, 150), 0, false));
// The point floors: the last half pixel is inside.
const QImage edges = discs({{QPointF(20, 150), 60}, {QPointF(380, 150), 60}});
QVERIFY(inside(ObjectSelection::select(edges, QPointF(399.5, 150), 0, false), QPointF(390, 150)));
QVERIFY(inside(ObjectSelection::select(edges, QPointF(0, 150), 0, false), QPointF(10, 150)));
QVERIFY(!ObjectSelection::select(edges, QPointF(-0.5, 150), 0, false));
// The cell row comes from the height.
const QImage low = discs({{QPointF(800, 1000), 150}}, QSize(1600, 1200));
QVERIFY(inside(ObjectSelection::select(low, QPointF(800, 1000), 0, false), QPointF(800, 1000)));
// Smoothing keeps the object and drops the stairs.
const std::optional<QPainterPath> smooth = ObjectSelection::select(pair, QPointF(110, 150), 0, true);
QVERIFY(inside(smooth, QPointF(110, 150)) && !inside(smooth, QPointF(290, 150)));
QVERIFY(points(smooth.value()) < points(left.value()));
// Cutting corners shaves a convex outline a little.
const int rounded = covered(smooth, pair.size());
QVERIFY2(rounded < area && rounded > 0.97 * area, qPrintable(QString::number(rounded)));
}
void ObjectSelectionTests::theEdgeOffsetTightensAndLoosensTheOutline()
{
const QImage disc = discs({{QPointF(200, 150), 80}});
const int plain = covered(ObjectSelection::select(disc, QPointF(200, 150), 0, false), disc.size());
const int tight = covered(ObjectSelection::select(disc, QPointF(200, 150), 4, false), disc.size());
const int loose = covered(ObjectSelection::select(disc, QPointF(200, 150), -4, false), disc.size());
// Square steps widen a round rim: 8 r k.
const double ring = 8 * 80 * 4;
QVERIFY2(std::abs(plain - tight - ring) < 0.05 * ring, qPrintable(QString::number(plain - tight)));
QVERIFY2(std::abs(loose - plain - ring - 4 * 4 * 4) < 0.05 * ring, qPrintable(QString::number(loose - plain)));
}
void ObjectSelectionTests::theEdgeFollowsTheImageNotTheModelsGrid()
{
// Grid cells five pixels wide; the image decides the edge.
const QSize size(1600, 1200);
const QImage disc = discs({{QPointF(800, 600), 320}}, size);
const QImage coverage = DocumentSelection{ObjectSelection::select(disc, QPointF(800, 600), 0, false).value(), false}.coverage(size.width(), size.height());
int wrong = 0, outside = 0;
for (int y = 0; y < size.height(); ++y) {
for (int x = 0; x < size.width(); ++x) {
const bool truth = std::hypot(x + 0.5 - 800, y + 0.5 - 600) < 320;
const bool chosen = coverage.constScanLine(y)[x] >= 128;
wrong += truth != chosen;
outside += chosen && !truth;
}
}
// Measured: 190 wrong, 141 outside; the grid alone, 1682.
QVERIFY2(wrong < 230 && outside > 70 && wrong - outside < 100, qPrintable(QStringLiteral("%1 %2").arg(wrong).arg(outside)));
}
void ObjectSelectionTests::theSessionSelectsCombinesAndNamesTheStep()
{
const auto session = withDisc();
bool done = false;
session->selectObject(QPointF(200, 150), SelectionMode::replace, [&done] { done = true; });
QVERIFY(session->isProjectBusy());
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
QVERIFY(!session->isProjectBusy());
QCOMPARE(session->history.undoName(), QString("Object Selection"));
QCOMPARE(coverage(*session, 200, 150), 255);
QCOMPARE(coverage(*session, 10, 10), 0);
// A traced outline is kept as traced, antialiased as set.
QVERIFY(session->selection().value().antialiased);
// Add and Subtract combine with what is there.
session->applySelection(rectPath(QRectF(10, 10, 30, 30)), SelectionMode::replace, "Select");
selectObject(*session, QPointF(200, 150), SelectionMode::add);
QCOMPARE(session->history.undoName(), QString("Object Selection"));
QCOMPARE(coverage(*session, 20, 20), 255);
QCOMPARE(coverage(*session, 200, 150), 255);
selectObject(*session, QPointF(200, 150), SelectionMode::subtract);
QCOMPARE(coverage(*session, 20, 20), 255);
QCOMPARE(coverage(*session, 200, 150), 0);
// Background adds nothing, and New there deselects.
const int steps = session->history.undoCount();
selectObject(*session, QPointF(10, 290), SelectionMode::add);
QCOMPARE(session->history.undoCount(), steps);
QCOMPARE(coverage(*session, 20, 20), 255);
selectObject(*session, QPointF(10, 290), SelectionMode::replace);
QVERIFY(!session->selection());
// The anti-alias setting reaches the selection.
const auto whole = [](const QPainterPath &path) {
for (int index = 0; index < path.elementCount(); ++index) {
const QPainterPath::Element element = path.elementAt(index);
if (element.x != std::round(element.x) || element.y != std::round(element.y))
return false;
}
return true;
};
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QVERIFY(!whole(session->selection().value().path));
session->setSelectionAntialiased(false);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QVERIFY(!session->selection().value().antialiased);
// Without anti-alias the traced pixel edges stay.
QVERIFY(whole(session->selection().value().path));
// The document gone mid-run: the outline is dropped.
done = false;
session->selectObject(QPointF(200, 150), SelectionMode::replace, [&done] { done = true; });
session->clearProject();
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
QVERIFY(!session->document() && !session->isProjectBusy());
}
void ObjectSelectionTests::theSessionRefusesAndReadsTheLayerItIsTold()
{
const auto session = withDisc();
session->selectAll();
const int steps = session->history.undoCount();
for (const QPointF point : {QPointF(-1, 10), QPointF(400, 10), QPointF(10, 300), QPointF(std::nan(""), 10)}) {
selectObject(*session, point, SelectionMode::replace);
QCOMPARE(coverage(*session, 10, 10), 255);
}
session->setIsProjectBusy(true);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
session->setIsProjectBusy(false);
QVERIFY(session->beginSelectionMove());
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
session->endSelectionMove();
QCOMPARE(coverage(*session, 10, 10), 255);
QCOMPARE(session->history.undoCount(), steps);
// This Layer reads a blank layer: nothing there.
session->addBlankLayer();
ObjectSelectionSettings own = session->objectSelectionSettings();
own.sampleAllLayers = false;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QVERIFY(!session->selection());
own.sampleAllLayers = true;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QCOMPARE(coverage(*session, 200, 150), 255);
// The edge offset reaches the trace, clamped to ten.
const int plain = covered(session->selection().value().path, QSize(400, 300));
own.edgeOffset = 50;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
const int clamped = covered(session->selection().value().path, QSize(400, 300));
QCOMPARE(session->objectSelectionSettings().edgeOffset, 50);
const QImage disc = discs({{QPointF(200, 150), 80}});
QCOMPARE(clamped, covered(ObjectSelection::select(disc, QPointF(200, 150), 10, true), QSize(400, 300)));
QVERIFY(clamped < plain);
own.edgeOffset = -50;
session->setObjectSelectionSettings(own);
selectObject(*session, QPointF(200, 150), SelectionMode::replace);
QCOMPARE(covered(session->selection().value().path, QSize(400, 300)), covered(ObjectSelection::select(disc, QPointF(200, 150), -10, true), QSize(400, 300)));
}
void ObjectSelectionTests::theModeSwitchesByTabAndWKeepsIt()
{
EditorSession session;
session.createDocument(20, 20);
QCOMPARE(session.wandMode(), WandMode::wand);
QCOMPARE(rawValue(WandMode::wand), QString("Wand"));
QCOMPARE(rawValue(WandMode::object), QString("Object"));
QSignalSpy changed(&session, &EditorSession::changed);
session.setWandMode(WandMode::object);
QCOMPARE(changed.count(), 1);
ObjectSelectionSettings settings;
QVERIFY(settings.sampleAllLayers && settings.edgeOffset == 0);
settings.edgeOffset = -3;
session.setObjectSelectionSettings(settings);
QCOMPARE(changed.count(), 2);
QCOMPARE(session.objectSelectionSettings(), settings);
// Tab cycles the mode only under the Magic tool.
session.selectTool(NavigationTool::lasso);
session.cycleToolMode();
QCOMPARE(session.wandMode(), WandMode::object);
session.pressWandKey();
QCOMPARE(session.tool(), NavigationTool::wand);
QCOMPARE(session.wandMode(), WandMode::object);
session.cycleToolMode();
QCOMPARE(session.wandMode(), WandMode::wand);
session.cycleToolMode();
QCOMPARE(session.wandMode(), WandMode::object);
QCOMPARE(label(NavigationTool::wand), QString("Magic (W) · Tab switches Wand and Object"));
// The list's W picks the tool; a repeat changes nothing.
NativeLayerList list(session);
list.show();
QVERIFY(QTest::qWaitForWindowExposed(&list));
list.setFocus();
QTRY_VERIFY(list.hasFocus());
session.selectTool(NavigationTool::brush);
QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::NoModifier, QStringLiteral("w"), true);
QApplication::sendEvent(&list, &repeat);
QCOMPARE(session.tool(), NavigationTool::brush);
QTest::keyClick(&list, Qt::Key_W);
QCOMPARE(session.tool(), NavigationTool::wand);
QCOMPARE(session.wandMode(), WandMode::object);
}
void ObjectSelectionTests::theCanvasClicksPicksAgainInsideAndShowsTheObjectCursor()
{
Canvas shown;
EditorSession &session = shown.session;
const QImage image = discs({{QPointF(200, 150), 80}});
session.insert(ImportedImage(image, image, QStringLiteral("Disc")));
session.selectTool(NavigationTool::wand);
shown.canvas->synchronizeDisplay();
const double ratio = shown.canvas->devicePixelRatio();
shown.hover(QPointF(20, 20));
QVERIFY(shown.shows(CanvasView::wandCursor(SelectionMode::replace, ratio)));
// Tab on the canvas switches to Object and its cursor.
shown.canvas->setFocus();
QTRY_VERIFY(shown.canvas->hasFocus());
QTest::keyClick(shown.canvas, Qt::Key_Tab);
QCOMPARE(session.wandMode(), WandMode::object);
shown.hover(QPointF(20, 20));
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::objectSelection, SelectionMode::replace, ratio)));
shown.hover(QPointF(20, 20), Qt::ShiftModifier);
QVERIFY(shown.shows(CanvasView::selectionCursor(SelectionIcon::objectSelection, SelectionMode::add, ratio)));
// A click traces the object under the document point.
session.zoom(2);
shown.canvas->synchronizeDisplay();
shown.click(session.viewport.viewPoint(QPointF(260, 150), shown.documentSize()));
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && session.selection().has_value(), 20000);
QCOMPARE(session.history.undoName(), QString("Object Selection"));
QCOMPARE(shown.coverage(200, 150), 255);
QCOMPARE(shown.coverage(20, 20), 0);
session.zoom(1);
shown.canvas->synchronizeDisplay();
// A click inside a selection picks afresh, never deselects.
session.selectAll();
shown.canvas->synchronizeDisplay();
shown.click(QPointF(200, 150));
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && shown.coverage(20, 20) == 0, 20000);
QCOMPARE(shown.coverage(200, 150), 255);
QCOMPARE(session.history.undoName(), QString("Object Selection"));
// Shift adds the object to a box elsewhere.
session.applySelection(rectPath(QRectF(10, 10, 20, 20)), SelectionMode::replace, "Select");
shown.canvas->synchronizeDisplay();
shown.click(QPointF(200, 150), Qt::ShiftModifier);
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && shown.coverage(200, 150) == 255, 20000);
QCOMPARE(shown.coverage(20, 20), 255);
// Wand mode's click matches colour again.
session.setWandMode(WandMode::wand);
session.deselect();
shown.canvas->synchronizeDisplay();
shown.click(QPointF(20, 20));
QTRY_VERIFY_WITH_TIMEOUT(!session.isProjectBusy() && session.selection().has_value(), 20000);
QCOMPARE(session.history.undoName(), QString("Magic Wand"));
QCOMPARE(shown.coverage(20, 20), 255);
QCOMPARE(shown.coverage(200, 150), 0);
// W picks Magic, keeps the mode, and ignores a repeat.
session.setWandMode(WandMode::object);
session.selectTool(NavigationTool::brush);
QKeyEvent repeat(QEvent::KeyPress, Qt::Key_W, Qt::NoModifier, QStringLiteral("w"), true);
QApplication::sendEvent(shown.canvas, &repeat);
QCOMPARE(session.tool(), NavigationTool::brush);
QTest::keyClick(shown.canvas, Qt::Key_W);
QCOMPARE(session.tool(), NavigationTool::wand);
QCOMPARE(session.wandMode(), WandMode::object);
}
QTEST_MAIN(ObjectSelectionTests)
#include "ObjectSelectionTests.moc"
+2
View File
@@ -79,6 +79,8 @@ inline QStringList described(const EditorSession &session)
.arg(int(session.selectionAntialiased())).arg(session.selectionExpandAmount()).arg(session.selectionContractAmount());
result << QStringLiteral("wand %1 %2 %3 %4").arg(session.wandSettings().tolerance).arg(int(session.wandSettings().sampleSize))
.arg(int(session.wandSettings().contiguous)).arg(int(session.wandSettings().sampleAllLayers));
result << QStringLiteral("magic %1 object %2 %3").arg(int(session.wandMode())).arg(int(session.objectSelectionSettings().sampleAllLayers))
.arg(session.objectSelectionSettings().edgeOffset);
if (session.lassoDraft().has_value()) {
const LassoDraft &draft = session.lassoDraft().value();
result << QStringLiteral("lasso %1 %2 points %3 cursor %4").arg(int(draft.kind)).arg(int(draft.mode)).arg(draft.points.size())
+19
View File
@@ -1,4 +1,5 @@
#include "AddressSpaceLimit.h"
#include "Document/ObjectSelection.h"
#include "Document/BrushStroke.h"
#include "Document/SubjectRemoval.h"
#include "IO/ImageExporter.h"
@@ -14,6 +15,7 @@ private slots:
void initTestCase() { mallopt(M_MMAP_THRESHOLD, 64 * 1024); }
void aPreviewThatFindsNoMemoryIsARenderError();
void aModelRunThatFindsNoMemoryIsARenderError();
void anObjectSelectionThatFindsNoMemoryIsARenderError();
};
namespace {
@@ -51,5 +53,22 @@ void SubjectRemovalFailureTests::aModelRunThatFindsNoMemoryIsARenderError()
QVERIFY_THROWS_EXCEPTION(ExportError, SubjectRemoval::run(fresh, FilterSettings()));
}
void SubjectRemovalFailureTests::anObjectSelectionThatFindsNoMemoryIsARenderError()
{
const QImage large = disc(6000, 4000);
// The model's mask is kept, so only the selection allocates.
QVERIFY(ObjectSelection::select(large, QPointF(3000, 2000), 0, false).has_value());
malloc_trim(0);
{
// The model's grid finds no room.
const AddressSpaceLimit limit(16ll * 1024);
QVERIFY_THROWS_EXCEPTION(ExportError, ObjectSelection::select(large, QPointF(3000, 2000), 0, false));
}
malloc_trim(0);
// The grid fits; the image-sized masks do not.
const AddressSpaceLimit limit(8ll * 1024 * 1024);
QVERIFY_THROWS_EXCEPTION(ExportError, ObjectSelection::select(large, QPointF(3000, 2000), 0, false));
}
QTEST_GUILESS_MAIN(SubjectRemovalFailureTests)
#include "SubjectRemovalFailureTests.moc"
+19
View File
@@ -1,4 +1,6 @@
#include "Document/BrushStroke.h"
#include "Document/EditorSession.h"
#include "Document/ObjectSelection.h"
#include "Document/SubjectRemoval.h"
#include <QPainter>
#include <QTemporaryDir>
@@ -48,6 +50,23 @@ void SubjectRemovalModelTests::aModelThatFailsIsNamedAndTheNextUseLoadsAgain()
said = failure(image);
QVERIFY2(said.startsWith(QStringLiteral("Remove Background could not use its model: omaphoto/u2net.onnx is in no data folder (")), qPrintable(said));
QVERIFY2(said.contains(empty.path()), qPrintable(said));
// Object mode passes the model's failure on unchanged.
try {
ObjectSelection::select(image, QPointF(100, 75), 0, true);
QFAIL("the object selection ran without a model");
} catch (const SubjectRemovalError &error) {
QVERIFY(error.kind == SubjectRemovalError::Kind::model);
}
// The session reports it and keeps the selection.
EditorSession session;
session.createDocument(200, 150);
session.insert(ImportedImage(image, image, QStringLiteral("Disc")));
session.selectAll();
bool done = false;
session.selectObject(QPointF(100, 75), SelectionMode::replace, [&done] { done = true; });
QVERIFY(QTest::qWaitFor([&done] { return done; }, 20000));
QVERIFY(session.brushError().value().startsWith(QStringLiteral("Remove Background could not use its model: ")));
QCOMPARE(session.selection().value().path.boundingRect(), QRectF(0, 0, 200, 150));
// Back in its folder, the next use loads it.
if (folders.isEmpty())
qunsetenv("XDG_DATA_DIRS");
+7 -6
View File
@@ -67,9 +67,9 @@ void ToolIconTests::everyToolHasItsOwnIcon()
icons << icon;
}
QCOMPARE(icons.size(), 15);
// The Marquee's, the Lasso's and the brush's kinds are icons.
// Marquee, Lasso, brush and Magic kinds are icons.
for (const auto &[tool, kind] : {std::pair(NavigationTool::marquee, ToolIconKind::ellipse), std::pair(NavigationTool::lasso, ToolIconKind::polygonal),
std::pair(NavigationTool::brush, ToolIconKind::eraser)}) {
std::pair(NavigationTool::brush, ToolIconKind::eraser), std::pair(NavigationTool::wand, ToolIconKind::object)}) {
const QImage icon = drawn(tool, Qt::white, 36, {0, 0}, kind);
QVERIFY(inked(icon) > 60 && !icons.contains(icon));
icons << icon;
@@ -77,6 +77,7 @@ void ToolIconTests::everyToolHasItsOwnIcon()
// A kind another tool lacks leaves its icon alone.
QCOMPARE(drawn(NavigationTool::brush, Qt::white, 36, {0, 0}, ToolIconKind::ellipse), drawn(NavigationTool::brush, Qt::white));
QCOMPARE(drawn(NavigationTool::move, Qt::white, 36, {0, 0}, ToolIconKind::eraser), drawn(NavigationTool::move, Qt::white));
QCOMPARE(drawn(NavigationTool::lasso, Qt::white, 36, {0, 0}, ToolIconKind::object), drawn(NavigationTool::lasso, Qt::white));
}
void ToolIconTests::anIconTakesTheColourItIsGivenAndNoOther()
@@ -312,22 +313,22 @@ void ToolIconTests::anIconKeepsToTheCallersClip()
void ToolIconTests::theDrawingsMatchTheirReference()
{
// Every stroke is pinned by the picture in the fixtures.
QImage sheet(18 * 44 + 8, 2 * 52, QImage::Format_ARGB32_Premultiplied);
QImage sheet(19 * 44 + 8, 2 * 52, QImage::Format_ARGB32_Premultiplied);
sheet.fill(QColor(36, 36, 36));
QPainter painter(&sheet);
for (int index = 0; index < 15; ++index) {
ToolIcons::paint(painter, tools[index], QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235));
ToolIcons::paint(painter, tools[index], QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235));
}
// Three more: the ellipse Marquee, polygonal Lasso, the eraser.
// Four more: ellipse Marquee, polygonal Lasso, eraser, Object mode.
for (const auto &[index, tool, kind] : {std::tuple(15, NavigationTool::marquee, ToolIconKind::ellipse), std::tuple(16, NavigationTool::lasso, ToolIconKind::polygonal),
std::tuple(17, NavigationTool::brush, ToolIconKind::eraser)}) {
std::tuple(17, NavigationTool::brush, ToolIconKind::eraser), std::tuple(18, NavigationTool::wand, ToolIconKind::object)}) {
ToolIcons::paint(painter, tool, QPointF(8 + index * 44, 8), 36, QColor(235, 235, 235), kind);
ToolIcons::paint(painter, tool, QPointF(17 + index * 44, 66), 18, QColor(235, 235, 235), kind);
}
painter.end();
// After a wanted change, look at this file; commit it.
QVERIFY(sheet.save(QStringLiteral("ToolIcons.png")));
QVERIFY(sheet.save(QCoreApplication::applicationDirPath() + QStringLiteral("/ToolIcons.png")));
const QImage reference = QImage(QFINDTESTDATA("fixtures/ToolIcons.png")).convertToFormat(QImage::Format_ARGB32_Premultiplied);
QCOMPARE(reference.size(), sheet.size());
// Type's letters take the system's sans; the sheet's is DejaVu.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB