Add horizontal and vertical mirror for symbols on a folio (#1335)

Edit > "Miroir horizontal" (M) and "Miroir vertical" (F), also in the
folio's right-click menu, mirror the selected symbols in place. The keys
are the ones the element editor uses for the same two actions.

An element keeps two mirrors about its own axes, applied before its
rotation, and saves them as mirror="horizontal|vertical|both" on its
<element> (written only when set, so other projects save byte for byte
as before). On a symbol turned by 90 or 270 degrees, a mirror of the
folio is the other mirror of the symbol itself, so the rotation never
changes: a label kept upright does not swing round, and "Pivoter" still
turns a mirrored symbol clockwise.

- Terminals face the mirrored way (Terminal::orientation()), so wires
  follow.
- The symbol stays where it was: its centre is kept, on the grid, since
  the hotspot is often a corner.
- Texts read normally. The element's texts, text groups and cross
  reference are mirrored a second time about the centre of their own box
  (Element::keepReadable()), and ElementPictureFactory draws the texts
  of the symbol itself the same way, in a cached picture per mirror.
  Groups and cross references held at the bottom of the folio stay
  centred under their element.
- DXF export mirrors the symbol's lines, arcs and texts.
- Scripting: qet.mirrorElement(folio, uuid, vertical) and
  qet.elementMirror(folio, uuid); live mode may run both menu commands;
  the MCP server gets a mirror_element op and qet_diff reports mirrors.

Not done: the parts of a PLC table drawn at run time
(Element::drawPlcTable()) are not kept readable on a mirrored PLC; the
project database has no column for it, as it has none for the rotation.

Test: tst_scriptmirror mirrors a symbol through --run, checks every
terminal's side and facing, the round trip, undo, a save and reload,
and a symbol turned by 90 degrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-07 11:01:27 +13:00
parent 0708338bce
commit a5b5d49b98
21 changed files with 923 additions and 33 deletions
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// Mirroring a symbol on its folio (#1335), through the real binary's --run
// on perceuse.qet: qet.mirrorElement() pushes the same undo command as
// Edit > Miroir horizontal / vertical. A horizontal mirror swaps the
// terminals left and right and turns the ones facing east to face west; a
// vertical one does the same top and bottom; mirroring twice the same way
// puts the symbol back exactly; and a save keeps the mirror.
class tst_scriptmirror : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
// Run @p script on @p project in a sandbox of its own and return the
// JSON object it logged.
QJsonObject run(const QString &script, const QString &project)
{
const QString path = m_dir.filePath(QStringLiteral("probe.js"));
const QString home = m_dir.filePath(QStringLiteral("home"));
QDir().mkpath(home);
QFile f(path);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(script.toUtf8());
f.close();
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
env.insert(QStringLiteral("TMPDIR"), m_dir.path());
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH),
{QStringLiteral("--run"), path, project});
if (!proc.waitForFinished(120000)) return {};
const QString out = QString::fromUtf8(proc.readAllStandardOutput()
+ proc.readAllStandardError());
const QString mark = QStringLiteral("PROBE ");
for (const QString &line : out.split(QLatin1Char('\n'))) {
const int i = line.indexOf(mark);
if (i >= 0)
return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object();
}
return {};
}
static QString facing(const QJsonArray &terminals, int i)
{
return terminals.at(i).toObject().value(QStringLiteral("facing")).toString();
}
static double coord(const QJsonArray &terminals, int i, const char *axis)
{
return terminals.at(i).toObject().value(QLatin1String(axis)).toDouble();
}
// The facing of a terminal once mirrored: across a vertical line
// (horizontal mirror) east and west swap, across a horizontal one north
// and south do.
static QString mirrored(const QString &facing, bool vertical)
{
const QString swap = vertical ? QStringLiteral("ns") : QStringLiteral("ew");
const int i = swap.indexOf(facing);
return i < 0 ? facing : QString(swap.at(1 - i));
}
// @p after is @p before mirrored horizontally (or vertically): the
// terminals face the mirrored way, keep the other coordinate, and are
// mirrored across one same line, so x + x' (or y + y') is the same for
// all of them.
static void checkMirrored(const QJsonArray &before, const QJsonArray &after, bool vertical)
{
QCOMPARE(after.size(), before.size());
const char *across = vertical ? "y" : "x";
const char *along = vertical ? "x" : "y";
const double axis = coord(before, 0, across) + coord(after, 0, across);
const double shift = coord(after, 0, along) - coord(before, 0, along);
for (int i = 0; i < before.size(); ++i) {
QCOMPARE(facing(after, i), mirrored(facing(before, i), vertical));
QCOMPARE(coord(before, i, across) + coord(after, i, across), axis);
QCOMPARE(coord(after, i, along) - coord(before, i, along), shift);
}
}
static QString script(const QString &body)
{
return QStringLiteral(
"function terms(f, el) {\n"
" var r = [], n = qet.elementTerminals(f, el).length;\n"
" for (var i = 0; i < n; i++) r.push(qet.terminalPosition(f, el, i));\n"
" return r;\n"
"}\n") + body;
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
void mirrorBackAndSave()
{
const QString saved = m_dir.filePath(QStringLiteral("mirrored.qet"));
const QJsonObject r = run(script(QStringLiteral(
// a symbol with terminals at different x and different y, one
// of them facing east or west
"var pick = null;\n"
"for (var f = 0; f < qet.folioCount() && !pick; f++) {\n"
" var els = qet.elementUuids(f);\n"
" for (var e = 0; e < els.length && !pick; e++) {\n"
" var t = terms(f, els[e]);\n"
" if (t.length < 2) continue;\n"
" var side = t.some(function (p) { return p.facing === 'e' || p.facing === 'w'; });\n"
" var xs = t.some(function (p) { return p.x !== t[0].x; });\n"
" var ys = t.some(function (p) { return p.y !== t[0].y; });\n"
" if (side && xs && ys) pick = {f: f, e: els[e]};\n"
" }\n"
"}\n"
"var f = pick.f, el = pick.e, out = {element: el};\n"
"out.before = terms(f, el);\n"
"qet.mirrorElement(f, el, false);\n"
"out.h = terms(f, el); out.hMirror = qet.elementMirror(f, el);\n"
"qet.mirrorElement(f, el, false);\n"
"out.back = terms(f, el); out.backMirror = qet.elementMirror(f, el);\n"
"qet.mirrorElement(f, el, true);\n"
"out.v = terms(f, el); out.vMirror = qet.elementMirror(f, el);\n"
"qet.undo();\n"
"out.undone = terms(f, el);\n"
"qet.mirrorElement(f, el, true);\n"
"out.folio = f;\n"
"out.saved = qet.save(%1);\n"
"qet.log('PROBE ' + JSON.stringify(out));\n")
.arg(QString::fromUtf8(QJsonDocument(QJsonArray{saved}).toJson(QJsonDocument::Compact)).mid(1).chopped(1))),
QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet"));
QVERIFY2(!r.isEmpty(), "the script logged nothing");
const QJsonArray before = r.value(QStringLiteral("before")).toArray();
QVERIFY(before.size() >= 2);
QVERIFY(!r.value(QStringLiteral("hMirror")).toString().isEmpty());
checkMirrored(before, r.value(QStringLiteral("h")).toArray(), false);
QCOMPARE(r.value(QStringLiteral("backMirror")).toString(), QString());
QCOMPARE(r.value(QStringLiteral("back")).toArray(), before);
checkMirrored(before, r.value(QStringLiteral("v")).toArray(), true);
QVERIFY(r.value(QStringLiteral("vMirror")).toString()
!= r.value(QStringLiteral("hMirror")).toString());
QCOMPARE(r.value(QStringLiteral("undone")).toArray(), before);
//The save keeps the mirror, and only on that element
QVERIFY(r.value(QStringLiteral("saved")).toBool());
QFile file(saved);
QVERIFY(file.open(QIODevice::ReadOnly));
QDomDocument doc;
QVERIFY(bool(doc.setContent(&file)));
const QString element = r.value(QStringLiteral("element")).toString();
const QDomNodeList elements = doc.elementsByTagName(QStringLiteral("element"));
int mirrored_count = 0;
for (int i = 0; i < elements.size(); ++i) {
const QDomElement e = elements.at(i).toElement();
if (e.hasAttribute(QStringLiteral("mirror"))) {
++mirrored_count;
QCOMPARE(e.attribute(QStringLiteral("uuid")), element);
QCOMPARE(e.attribute(QStringLiteral("mirror")),
r.value(QStringLiteral("vMirror")).toString());
}
}
QCOMPARE(mirrored_count, 1);
const QJsonObject reloaded = run(script(QStringLiteral(
"var f = %1, el = '%2';\n"
"qet.log('PROBE ' + JSON.stringify({t: terms(f, el), m: qet.elementMirror(f, el)}));\n")
.arg(r.value(QStringLiteral("folio")).toInt()).arg(element)), saved);
QCOMPARE(reloaded.value(QStringLiteral("m")).toString(),
r.value(QStringLiteral("vMirror")).toString());
QCOMPARE(reloaded.value(QStringLiteral("t")).toArray(),
r.value(QStringLiteral("v")).toArray());
}
/**
On a turned symbol a mirror of the folio is the other mirror of the
symbol itself, and its rotation does not change: a label kept
upright does not swing round.
*/
void turnedElement_data()
{
QTest::addColumn<bool>("vertical");
QTest::newRow("horizontal") << false;
QTest::newRow("vertical") << true;
}
void turnedElement()
{
QFETCH(bool, vertical);
const QJsonObject r = run(script(QStringLiteral(
"var pick = null;\n"
"for (var f = 0; f < qet.folioCount() && !pick; f++) {\n"
" var els = qet.elementUuids(f);\n"
" for (var e = 0; e < els.length && !pick; e++) {\n"
" var t = terms(f, els[e]);\n"
" if (t.length >= 2 && t.some(function (p) { return p.x !== t[0].x; })\n"
" && t.some(function (p) { return p.y !== t[0].y; })) pick = {f: f, e: els[e]};\n"
" }\n"
"}\n"
"var f = pick.f, el = pick.e, out = {};\n"
"qet.rotateElement(f, el, 90);\n"
"out.before = terms(f, el);\n"
"qet.mirrorElement(f, el, %1);\n"
"out.after = terms(f, el); out.mirror = qet.elementMirror(f, el);\n"
"qet.log('PROBE ' + JSON.stringify(out));\n")
.arg(vertical ? QStringLiteral("true") : QStringLiteral("false"))),
QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet"));
QVERIFY2(!r.isEmpty(), "the script logged nothing");
checkMirrored(r.value(QStringLiteral("before")).toArray(),
r.value(QStringLiteral("after")).toArray(), vertical);
//turned by 90 degrees, the symbol's own axes are the other way
QCOMPARE(r.value(QStringLiteral("mirror")).toString(),
vertical ? QStringLiteral("horizontal") : QStringLiteral("vertical"));
}
};
QTEST_APPLESS_MAIN(tst_scriptmirror)
#include "tst_scriptmirror.moc"