#include "Document/BrushStroke.h" #include "Document/Distort.h" #include "IO/ImageExporter.h" #include "SessionFixtures.h" #include // Free distortion: the warp, the session's edit, masks along. namespace { const Corners shape = {QPointF(10, 10), QPointF(60, 10), QPointF(30, 30), QPointF(10, 30)}; QImage red(int width, int height) { QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); image.fill(Qt::red); return image; } QUuid insertAt(EditorSession &session, const QImage &image, QPointF origin) { session.insert(ImportedImage(image, image, "Image")); const QUuid id = session.activeLayerID().value(); rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).transform.origin = origin; }); return id; } int alphaAt(const EditorSession &session, int x, int y) { return ImageExporter::render(session.projectSnapshot().value()).image.pixelColor(x, y).alpha(); } bool near(QPointF a, QPointF b) { return std::abs(a.x() - b.x()) < 1e-6 && std::abs(a.y() - b.y()) < 1e-6; } } class DistortTests : public QObject { Q_OBJECT private slots: void cornersRunClockwiseFromTheTopLeft(); void perspectiveMappingHitsTheCornersAndDegenerateShapesAreRefused(); void distortingWarpsTheLayerIntoTheShapeAsOneUndoStep(); void distortedLayerIsTrimmedToItsVisiblePixels(); void foldedShapesWarpAsTwoTrianglesAndFlipsSwapCorners(); void carriedCornersAndWarpedMasks(); void nudgesGroupsAndRefusals(); void mapPathCarriesCurvesFlipsAndTheFillRule(); void pointsPastTheHorizonDivideAsSwiftDoes(); }; void DistortTests::cornersRunClockwiseFromTheTopLeft() { const LayerTransform upright{.origin = {10, 20}, .size = {40, 30}}; QCOMPARE(DistortWarp::corners(upright), (Corners{QPointF(10, 20), QPointF(50, 20), QPointF(50, 50), QPointF(10, 50)})); // Turned, each corner is where the transform puts it. const LayerTransform turned{.origin = {10, 20}, .size = {40, 30}, .rotation = 37, .flipX = true}; const Corners corners = DistortWarp::corners(turned); const std::array unit = {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)}; for (size_t index = 0; index < 4; ++index) QVERIFY(near(corners[index], turned.point(unit[index]))); QVERIFY(!near(corners[1], corners[0])); } void DistortTests::perspectiveMappingHitsTheCornersAndDegenerateShapesAreRefused() { const QTransform map = DistortWarp::homography(shape); const Corners unit = {QPointF(0, 0), QPointF(1, 0), QPointF(1, 1), QPointF(0, 1)}; for (size_t index = 0; index < 4; ++index) QVERIFY2(near(map.map(unit[index]), shape[index]), qPrintable(QString("corner %1").arg(index))); // The middle lands where the diagonals cross, a perspective. const QPointF middle = map.map(QPointF(0.5, 0.5)); QVERIFY(std::abs(middle.x() - 170.0 / 7) < 1e-9 && std::abs(middle.y() - 170.0 / 7) < 1e-9); // A slanted bottom too: both perspective terms at work. const Corners slanted = {QPointF(10, 10), QPointF(60, 10), QPointF(30, 40), QPointF(10, 30)}; const QTransform slant = DistortWarp::homography(slanted); for (size_t index = 0; index < 4; ++index) QVERIFY(near(slant.map(unit[index]), slanted[index])); const QPointF crossing = slant.map(QPointF(0.5, 0.5)); QVERIFY(std::abs(crossing.x() - 390.0 / 19) < 1e-9 && std::abs(crossing.y() - 490.0 / 19) < 1e-9); QVERIFY(DistortWarp::isUsable(shape) && DistortWarp::isConvex(shape)); // A bow tie folds: usable as two triangles, no perspective. const Corners bowTie = {shape[0], shape[2], shape[1], shape[3]}; const Corners collapsed = {shape[0], shape[0], shape[2], shape[3]}; QVERIFY(DistortWarp::isUsable(bowTie) && !DistortWarp::isConvex(bowTie)); QVERIFY(!DistortWarp::isUsable(collapsed)); // A flat half is refused; a straight side, no perspective. QVERIFY(!DistortWarp::isUsable({QPointF(10, 10), QPointF(60, 10), QPointF(30, 30), QPointF(20, 20)})); QVERIFY(DistortWarp::isUsable({QPointF(10, 10), QPointF(60, 10), QPointF(60, 30), QPointF(60, 50)})); QVERIFY(!DistortWarp::isConvex({QPointF(10, 10), QPointF(60, 10), QPointF(60, 30), QPointF(60, 50)})); QVERIFY(!DistortWarp::isUsable({shape[0], QPointF(std::nan(""), 10), shape[2], shape[3]})); QVERIFY(!DistortWarp::isUsable({shape[0], QPointF(2'000'000, 10), shape[2], shape[3]})); // A dent is usable but no perspective: two triangles. const Corners dented = {QPointF(10, 10), QPointF(60, 10), QPointF(15, 15), QPointF(10, 60)}; QVERIFY(DistortWarp::isUsable(dented) && !DistortWarp::isConvex(dented)); // Mirrored winding still counts as convex. QVERIFY(DistortWarp::isConvex({shape[1], shape[0], shape[3], shape[2]})); } void DistortTests::distortingWarpsTheLayerIntoTheShapeAsOneUndoStep() { EditorSession session; session.createDocument(100, 60); const QUuid id = insertAt(session, red(20, 20), QPointF(10, 10)); session.selectTool(NavigationTool::move); session.beginTransform(false); session.beginDistort(); QVERIFY(session.transformEdit().value().corners.has_value() && session.transformEdit().value().persistent); // A collapsed corner: ignored. session.previewCorners({shape[0], shape[0], shape[2], shape[3]}); QCOMPARE(session.transformEdit().value().corners.value()[1], QPointF(30, 10)); // A folded shape is a distortion of its own. session.previewCorners({shape[0], shape[2], shape[1], shape[3]}); QCOMPARE(session.transformEdit().value().corners.value()[1], shape[2]); session.previewCorners(shape); QCOMPARE(session.transformEdit().value().corners.value(), shape); const int count = session.history.undoCount(); session.commitTransform(); QVERIFY(!session.transformEdit().has_value()); QCOMPARE(session.history.undoCount(), count + 1); QCOMPARE(session.history.undoName(), QString("Distort")); const LayerTransform transform = layerWith(session, id).transform; QCOMPARE(transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}})); QCOMPARE(layerWith(session, id).asset.value().size(), QSize(50, 20)); QCOMPARE(alphaAt(session, 50, 12), 255); QCOMPARE(alphaAt(session, 15, 25), 255); QCOMPARE(alphaAt(session, 50, 28), 0); QCOMPARE(alphaAt(session, 80, 12), 0); session.undo(); QCOMPARE(layerWith(session, id).transform.size, QSizeF(20, 20)); QCOMPARE(layerWith(session, id).asset.value().size(), QSize(20, 20)); } void DistortTests::distortedLayerIsTrimmedToItsVisiblePixels() { EditorSession session; session.createDocument(100, 60); // A 40 by 20 layer, clear except a red square. QImage image(40, 20, QImage::Format_RGBA8888_Premultiplied); image.fill(Qt::transparent); QPainter painter(&image); painter.fillRect(QRect(15, 5, 10, 10), Qt::red); painter.end(); const QUuid id = insertAt(session, image, QPointF(10, 10)); session.selectTool(NavigationTool::move); session.beginTransform(false); session.beginDistort(); session.previewCorners({QPointF(10, 10), QPointF(60, 10), QPointF(50, 30), QPointF(10, 30)}); session.commitTransform(); const LayerTransform transform = layerWith(session, id).transform; // Bounds of 50 by 20; the square's warp is smaller. QVERIFY2(transform.size.width() < 20 && transform.size.height() <= 12, qPrintable(QString("%1 x %2").arg(transform.size.width()).arg(transform.size.height()))); QVERIFY2(transform.origin.x() >= 20 && transform.origin.y() >= 14, qPrintable(QString("%1, %2").arg(transform.origin.x()).arg(transform.origin.y()))); QCOMPARE(alphaAt(session, 30, 20), 255); // The trimmed asset is the layer's own size. QCOMPARE(QSizeF(layerWith(session, id).asset.value().size()), transform.size); } void DistortTests::foldedShapesWarpAsTwoTrianglesAndFlipsSwapCorners() { const LayerTransform placed{.origin = {10, 10}, .size = {20, 20}}; QVERIFY_THROWS_EXCEPTION(ProjectError, DistortWarp::warp(red(20, 20), placed, {shape[0], shape[0], shape[2], shape[3]}, false)); // A shape too large for a warp. QVERIFY_THROWS_EXCEPTION(ProjectError, DistortWarp::warp(red(20, 20), placed, {QPointF(0, 0), QPointF(40'000, 0), QPointF(40'000, 10), QPointF(0, 10)}, false)); const Corners dented = {QPointF(10, 10), QPointF(60, 10), QPointF(15, 15), QPointF(10, 60)}; const DistortWarp::Warped folded = DistortWarp::warp(red(20, 20), placed, dented, false); // Copied without interpolation: a colour boundary stays sharp. QImage twoTone = red(20, 20); QPainter painter(&twoTone); painter.fillRect(QRect(0, 0, 10, 20), Qt::blue); painter.end(); const Corners wide = {QPointF(10, 10), QPointF(110, 10), QPointF(20, 20), QPointF(10, 110)}; const DistortWarp::Warped sharp = DistortWarp::warp(twoTone, placed, wide, false); // The half is sheared: its colour boundary sits at 45.5. QCOMPARE(sharp.image.pixelColor(43, 0), QColor(Qt::blue)); QCOMPARE(sharp.image.pixelColor(47, 0), QColor(Qt::red)); QCOMPARE(folded.transform, (LayerTransform{.origin = {10, 10}, .size = {50, 50}})); QCOMPARE(folded.image.size(), QSize(50, 50)); QCOMPARE(folded.image.pixelColor(20, 2).alpha(), 255); QCOMPARE(folded.image.pixelColor(2, 20).alpha(), 255); QCOMPARE(folded.image.pixelColor(40, 4).alpha(), 0); QCOMPARE(folded.image.pixelColor(45, 45).alpha(), 0); // The halves meet along the diagonal without a seam. const DistortWarp::Warped seam = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 0), QPointF(30, 30), QPointF(0, 100)}, false); for (const int along : {10, 15, 25}) QCOMPARE(seam.image.pixelColor(along, along).alpha(), 255); QCOMPARE(seam.image.pixelColor(50, 5).alpha(), 255); QCOMPARE(seam.image.pixelColor(5, 50).alpha(), 255); QCOMPARE(seam.image.pixelColor(60, 60).alpha(), 0); // Past a half's edge nothing draws, though its map would. const DistortWarp::Warped spill = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 0), QPointF(30, 30), QPointF(10, 5)}, false); QCOMPARE(spill.image.pixelColor(40, 10).alpha(), 255); QCOMPARE(spill.image.pixelColor(5, 20).alpha(), 0); QCOMPARE(spill.image.pixelColor(10, 20).alpha(), 0); // A centre a sloping edge holds is kept; fractions too. const DistortWarp::Warped sloping = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 1), QPointF(25, 26), QPointF(0, 100)}, false); QVERIFY(sloping.image.pixelColor(5, 0).alpha() > 200); QVERIFY(sloping.image.pixelColor(2, 40).alpha() > 200); // Beside the diagonal each pixel comes from its own half. QImage patterned(20, 20, QImage::Format_RGBA8888_Premultiplied); for (int y = 0; y < 20; ++y) { for (int x = 0; x < 20; ++x) patterned.setPixelColor(x, y, QColor(12 * x, 12 * y, 0)); } const DistortWarp::Warped beside = DistortWarp::warp(patterned, {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(100, 1), QPointF(25, 26), QPointF(0, 100)}, false); QCOMPARE(beside.image.pixelColor(20, 20), QColor(180, 180, 0)); // A fractional shared edge, met both ways, has no hole. const DistortWarp::Warped shared = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.1, 0.9), QPointF(100, 0.9), QPointF(20.1, 30.9), QPointF(0.1, 100)}, false); for (const QPoint hole : {QPoint(4, 7), QPoint(6, 10), QPoint(8, 13)}) QCOMPARE(shared.image.pixelColor(hole).alpha(), 255); const DistortWarp::Warped fractional = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.4, 0.4), QPointF(60.6, 0.4), QPointF(20.5, 20.5), QPointF(0.4, 60.6)}, false); QCOMPARE(fractional.image.pixelColor(30, 5).alpha(), 255); QCOMPARE(fractional.image.pixelColor(5, 30).alpha(), 255); QCOMPARE(fractional.image.pixelColor(40, 40).alpha(), 0); for (const int along : {8, 12, 16}) QCOMPARE(fractional.image.pixelColor(along, along).alpha(), 255); QVERIFY(fractional.image.pixelColor(30, 0).alpha() > 100); // A row centred on a horizontal top edge is drawn. const DistortWarp::Warped topEdge = DistortWarp::warp(red(20, 20), {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0.5, 0.5), QPointF(100.5, 0.5), QPointF(30, 30), QPointF(0, 100)}, false); QVERIFY(topEdge.image.pixelColor(50, 0).alpha() > 100); // Where the halves overlap the second replaces the first. QImage faint(20, 20, QImage::Format_RGBA8888_Premultiplied); faint.fill(QColor(128, 0, 0, 128)); const DistortWarp::Warped bowTie = DistortWarp::warp(faint, {.origin = {0, 0}, .size = {20, 20}}, {QPointF(0, 0), QPointF(40, 40), QPointF(40, 0), QPointF(0, 40)}, false); QCOMPARE(bowTie.image.pixelColor(20, 10).alpha(), 128); QCOMPARE(qAlpha(bowTie.image.pixel(20, 10)), 128); // A limit shrinks the warp; a flip sends pixels across. const DistortWarp::Warped small = DistortWarp::warp(twoTone, placed, shape, false, 25); QCOMPARE(small.image.size(), QSize(25, 10)); QCOMPARE(small.transform, (LayerTransform{.origin = {10, 10}, .size = {50, 20}})); QCOMPARE(small.image.pixelColor(2, 2), QColor(Qt::blue)); LayerTransform flipped = placed; flipped.flipX = true; const DistortWarp::Warped mirrored = DistortWarp::warp(twoTone, flipped, shape, false); QCOMPARE(mirrored.image.pixelColor(2, 2), QColor(Qt::red)); QCOMPARE(mirrored.image.pixelColor(40, 2), QColor(Qt::blue)); // The sampling travels with the placement. LayerTransform nearest = placed; nearest.sampling = LayerSampling::nearest; QCOMPARE(DistortWarp::warp(twoTone, nearest, shape, false).transform.sampling, LayerSampling::nearest); } void DistortTests::carriedCornersAndWarpedMasks() { const LayerTransform box{.origin = {10, 10}, .size = {20, 20}}; // The box lands on the shape; one moved aside, aside. const Corners same = DistortWarp::carried(box, box, shape); for (size_t index = 0; index < 4; ++index) QVERIFY(near(same[index], shape[index])); const LayerTransform turned{.origin = {10, 10}, .size = {20, 20}, .rotation = 30}; const Corners turnedSame = DistortWarp::carried(turned, turned, shape); for (size_t index = 0; index < 4; ++index) QVERIFY(near(turnedSame[index], shape[index])); const Corners moved = {QPointF(20, 20), QPointF(40, 20), QPointF(40, 40), QPointF(20, 40)}; const Corners aside = DistortWarp::carried({.origin = {15, 15}, .size = {20, 20}}, box, moved); for (size_t index = 0; index < 4; ++index) QVERIFY(near(aside[index], moved[index] + QPointF(5, 5))); // A mask's background fills the shape's surround. QImage mask(4, 4, QImage::Format_Grayscale8); mask.fill(200); const Corners half = {QPointF(0, 0), QPointF(8, 0), QPointF(4, 4), QPointF(0, 4)}; const LayerTransform grid{.origin = {0, 0}, .size = {4, 4}}; const DistortWarp::Warped lit = DistortWarp::warpMask(mask, grid, half, 1); QCOMPARE(lit.image.size(), QSize(8, 4)); QCOMPARE(lit.image.format(), QImage::Format_Grayscale8); QCOMPARE(int(lit.image.constScanLine(3)[7]), 255); QVERIFY(std::abs(int(lit.image.constScanLine(1)[1]) - 200) <= 2); const DistortWarp::Warped dark = DistortWarp::warpMask(mask, grid, half, 0); QCOMPARE(int(dark.image.constScanLine(3)[7]), 0); // A uniform mask covers any shape as it is. QImage uniform(1, 1, QImage::Format_Grayscale8); uniform.fill(255); const DistortWarp::Warped kept = DistortWarp::warpMask(uniform, grid, half, 1); QCOMPARE(kept.image.cacheKey(), uniform.cacheKey()); QCOMPARE(kept.transform, (LayerTransform{.origin = {0, 0}, .size = {8, 4}})); } void DistortTests::nudgesGroupsAndRefusals() { EditorSession session; session.beginDistort(); session.previewCorners(shape); session.createDocument(100, 60); const QUuid a = insertAt(session, red(20, 20), QPointF(10, 10)); const QUuid b = insertAt(session, red(10, 10), QPointF(50, 30)); session.selectLayer(a); // Without an edit or corners, nothing begins or moves. session.beginDistort(); QVERIFY(!session.transformEdit().has_value()); session.beginTransform(); session.previewCorners(shape); QVERIFY(!session.transformEdit().value().corners.has_value()); session.beginDistort(); session.beginDistort(); const Corners own = DistortWarp::corners(layerWith(session, a).transform); QCOMPARE(session.transformEdit().value().corners.value(), own); // A nudge moves the corners; a new begin keeps them. session.nudgeLayer(3, -2); QCOMPARE(session.transformEdit().value().draft.origin, QPointF(13, 8)); QCOMPARE(session.transformEdit().value().corners.value()[2], own[2] + QPointF(3, -2)); session.beginDistort(); QCOMPARE(session.transformEdit().value().corners.value()[2], own[2] + QPointF(3, -2)); session.cancelTransform(); QCOMPARE(layerWith(session, a).transform.origin, QPointF(10, 10)); // A group takes the box's perspective; an unlinked mask stays. rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, b, coverage()); record(snapshot, b).maskLinked = false; }); session.selectLayers({a, b}, a); session.beginTransform(); session.beginDistort(); QCOMPARE(session.displayedMaskPlacement(layerWith(session, b)), std::optional(layerWith(session, b).transform)); const Corners box = session.transformEdit().value().corners.value(); QCOMPARE(box[0], QPointF(10, 10)); QCOMPARE(box[2], QPointF(60, 40)); session.previewCorners({box[0], box[1] + QPointF(20, 0), box[2], box[3]}); QVERIFY(session.distortPreview(layerWith(session, b)).has_value()); session.commitTransform(); QCOMPARE(session.history.undoName(), QString("Distort Layers")); QVERIFY(layerWith(session, a).transform.size.width() > 20); QVERIFY(layerWith(session, b).transform.size.width() > 10); QCOMPARE(layerWith(session, b).transform.origin.x(), 50.0); } void DistortTests::mapPathCarriesCurvesFlipsAndTheFillRule() { // A 10-pixel layer at 10,10, its box the shape. const LayerTransform transform{.origin = {10, 10}, .size = {10, 10}}; const QTransform placement = BrushRaster::pixelToDocument(transform, 10, 10); const Corners box = DistortWarp::corners(transform); QPainterPath ellipse; ellipse.addEllipse(QRectF(11, 11, 4, 4)); ellipse.setFillRule(Qt::WindingFill); const QPainterPath carried = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), transform, box).value(); QCOMPARE(carried.fillRule(), Qt::WindingFill); QCOMPARE(carried.elementCount(), ellipse.elementCount()); for (int i = 0; i < ellipse.elementCount(); ++i) { QCOMPARE(carried.elementAt(i).type, ellipse.elementAt(i).type); QVERIFY(near(carried.elementAt(i), ellipse.elementAt(i))); } // A flipped draft mirrors it inside the shape. LayerTransform flipped = transform; flipped.flipX = true; QRectF bounds = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), flipped, box).value().boundingRect(); QVERIFY(near(bounds.topLeft(), QPointF(15, 11)) && near(bounds.bottomRight(), QPointF(19, 15))); flipped = transform; flipped.flipY = true; bounds = DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), flipped, box).value().boundingRect(); QVERIFY(near(bounds.topLeft(), QPointF(11, 15)) && near(bounds.bottomRight(), QPointF(15, 19))); // A folded shape or an empty grid carries nothing. const Corners folded = {QPointF(10, 10), QPointF(20, 20), QPointF(20, 10), QPointF(10, 20)}; QVERIFY(!DistortWarp::mapPath(ellipse, placement, QSizeF(10, 10), transform, folded).has_value()); QVERIFY(!DistortWarp::mapPath(ellipse, placement, QSizeF(0, 10), transform, box).has_value()); } // Past the horizon Swift divides by w; Qt 6.10 clamps. void DistortTests::pointsPastTheHorizonDivideAsSwiftDoes() { const Corners trapezoid = {QPointF(0, 0), QPointF(40, 0), QPointF(30, 20), QPointF(10, 20)}; const QTransform map = DistortWarp::homography(trapezoid); const auto divided = [&map](QPointF p) { const double w = map.m13() * p.x() + map.m23() * p.y() + map.m33(); return QPointF((map.m11() * p.x() + map.m21() * p.y() + map.m31()) / w, (map.m12() * p.x() + map.m22() * p.y() + map.m32()) / w); }; const LayerTransform unit{.origin = {0, 0}, .size = {1, 1}}; const Corners far = DistortWarp::corners({.origin = {0, -3}, .size = {1, 1}}); for (const QPointF corner : far) QVERIFY(map.m13() * corner.x() + map.m23() * corner.y() + map.m33() < 0); const Corners carried = DistortWarp::carried({.origin = {0, -3}, .size = {1, 1}}, unit, trapezoid); for (size_t index = 0; index < 4; ++index) QVERIFY(near(carried[index], divided(far[index]))); QPainterPath line; line.moveTo(far[0]); line.lineTo(far[2]); const QPainterPath mapped = DistortWarp::mapPath(line, QTransform(), QSizeF(1, 1), unit, trapezoid).value(); QVERIFY(near(mapped.elementAt(0), divided(far[0])) && near(mapped.elementAt(1), divided(far[2]))); } QTEST_GUILESS_MAIN(DistortTests) #include "DistortTests.moc"