Files
OmaPhoto/tests/DistortTests.cpp
T
雷电芽衣andClaude d0088fac89 Review fixes for 11.4 and 11.4b
ubuntu.sh takes Ubuntu 24.04 and its derivatives alone, since the DEB
names 24.04's t64 libraries. nixos.sh installs the newest release's
tag, not main. The release workflow refuses a tag that names another
version than the build files, and runs on the Node 24 actions.
DistortTests pins Swift's division by w past the horizon.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-26 11:28:13 -04:00

378 lines
21 KiB
C++

#include "Document/BrushStroke.h"
#include "Document/Distort.h"
#include "IO/ImageExporter.h"
#include "SessionFixtures.h"
#include <QtTest>
// 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 perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused();
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<QPointF, 4> 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::perspectiveMappingHitsTheCornersAndTwistedShapesAreRefused()
{
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));
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"