Reattach wires to a terminal that moved a little since the project was saved

A project saves, for every placed symbol, where each terminal docked, its
orientation and its number. When the symbol's definition is replaced by one
whose terminal sits a few units elsewhere, wires were matched by exact
position and so were dropped on the next open: legacy wires by Terminal::
fromXml(), uuid wires by the uuid derived from the terminal's new place.

Element::fromXml() now pairs each saved terminal that nothing matched
exactly with the live terminal of the same orientation whose docking point
is within 5 units in x and y, only when each is the other's only candidate.
A pair registers the saved number, and the uuid derived from the old place,
which Diagram::findTerminals() checks after the derived uuid of the current
place. Wire ends reattached this way are counted and logged once per folio.

tst_movedterminals covers a move of 3 and 5 units (number and derived
uuid), 6 units, two candidates, and an unmoved terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-15 20:26:30 +13:00
parent 6af9c097d8
commit 786f8574d9
5 changed files with 344 additions and 1 deletions
+14
View File
@@ -1790,6 +1790,11 @@ QList<Terminal *> findTerminals(int conductor_index,
if (terminal->derivedUuid() == terminal_uuid)
match = terminal;
}
//A terminal that moved since the project was saved: the
//uuid worked out from its old place was paired with it
//when the symbol was read (Element::fromXml()).
if (!match)
match = element->terminalFromOldPlace(terminal_uuid);
if (match)
found << match;
else
@@ -2241,6 +2246,7 @@ bool Diagram::fromXml(QDomElement &document,
if (consider_informations)
m_wires_not_reconnected.clear();
QList<Conductor *> added_conductors;
int moved_ends = 0;
for (auto f : QET::findInDomElement(root,
QStringLiteral("conductors"),
QStringLiteral("conductor")))
@@ -2312,11 +2318,19 @@ bool Diagram::fromXml(QDomElement &document,
ends.join(QLatin1Char('\n'))));
}
added_conductors << c;
for (Terminal *end : {p1, p2})
if (end->parentElement()->terminalWasMoved(end)) ++moved_ends;
}
else
delete c;
}
}
if (consider_informations && moved_ends) {
qInfo().noquote() << "Diagram::fromXml():"
<< moved_ends
<< "wire end(s) on a terminal that moved since the project was saved,"
<< "matched to it by place";
}
if (consider_informations && !m_wires_not_reconnected.isEmpty()) {
qWarning().noquote() << "Diagram::fromXml():"
<< m_wires_not_reconnected.size()
+109 -1
View File
@@ -19,6 +19,7 @@
#include "../qetapp.h"
#include "../qetproject.h"
#include "../dataBase/projectdatabase.h"
#include "../ElementsCollection/terminaluuids.h"
#include "../PropertiesEditor/propertieseditordialog.h"
#include "../autoNum/assignvariables.h"
#include "../autoNum/elementautonumschemecommand.h"
@@ -972,6 +973,107 @@ bool Element::valideXml(QDomElement &e)
return(true);
}
/**
@brief Element::matchMovedTerminals
Pair the saved terminals that no live terminal matched exactly with the
live terminals that moved a little: the definition of the symbol was
replaced or redrawn since the project was saved. A saved record gives
the old docking point and orientation of its terminal, so it is paired
with a live terminal of the same orientation whose docking point is
within moved_terminal_reach in x and y, when each is the only candidate
of the other. Anything less clear stays unmatched, as before.
A pair registers the record's legacy id, and the uuid TerminalUuids
derived for the terminal at its old place, so that a wire naming either
reattaches (see Diagram::findTerminals()).
@param records : the saved terminals of this symbol
@param matched_records : indexes in records already matched
@param matched_terminals : the live terminals already matched
@param id_adr : receives the legacy id of each pair
*/
void Element::matchMovedTerminals(const QList<QDomElement> &records,
const QSet<int> &matched_records,
const QSet<Terminal *> &matched_terminals,
QHash<int, Terminal *> &id_adr)
{
if (matched_records.size() == records.size()) return;
const auto dock_of = [](const QDomElement &r) {
return QPointF(r.attribute(QStringLiteral("x")).toDouble(),
r.attribute(QStringLiteral("y")).toDouble());
};
const auto orientation_of = [](const QDomElement &r) {
return r.attribute(QStringLiteral("orientation")).toInt();
};
QHash<int, QList<Terminal *>> candidates; // record -> live terminals in reach
QHash<Terminal *, QList<int>> claimants; // live terminal -> records in reach
for (int i = 0; i < records.size(); ++i) {
if (matched_records.contains(i)) continue;
const QPointF dock = dock_of(records.at(i));
for (Terminal *t : std::as_const(m_terminals)) {
if (matched_terminals.contains(t)) continue;
if (static_cast<int>(t->orientation()) != orientation_of(records.at(i))) continue;
if (qAbs(t->dock_elmt_.x() - dock.x()) > moved_terminal_reach
|| qAbs(t->dock_elmt_.y() - dock.y()) > moved_terminal_reach) continue;
candidates[i] << t;
claimants[t] << i;
}
}
for (auto it = candidates.constBegin(); it != candidates.constEnd(); ++it) {
Terminal *t = it.value().value(0);
if (it.value().size() != 1 || claimants.value(t).size() != 1) continue;
const QDomElement &record = records.at(it.key());
const QPointF dock = dock_of(record);
//The old place of the terminal: its docking point less the
//stub that Terminal::init() added
QPointF old_pos = dock;
switch (orientation_of(record)) {
case Qet::North: old_pos -= QPointF(0, Terminal::terminalSize); break;
case Qet::East : old_pos -= QPointF(-Terminal::terminalSize, 0); break;
case Qet::West : old_pos -= QPointF(Terminal::terminalSize, 0); break;
case Qet::South: old_pos -= QPointF(0, -Terminal::terminalSize); break;
}
//The rank among the records at the same place, as the project
//counted it when it gave the terminals their uuids
int rank = 0;
for (int j = 0; j < it.key(); ++j) {
const QPointF other = dock_of(records.at(j));
if (orientation_of(records.at(j)) == orientation_of(record)
&& qAbs(other.x() - dock.x()) < 1e-6
&& qAbs(other.y() - dock.y()) < 1e-6)
++rank;
}
id_adr.insert(record.attribute(QStringLiteral("id")).toInt(), t);
m_old_place_terminals.insert(TerminalUuids::derived(old_pos.x(), old_pos.y(),
orientation_of(record), rank), t);
m_moved_terminals << t;
}
}
/**
@return the terminal that was paired with the saved terminal whose uuid
derived from its old place is @p old_place_uuid, if any
@see matchMovedTerminals()
*/
Terminal *Element::terminalFromOldPlace(const QUuid &old_place_uuid) const
{
return m_old_place_terminals.value(old_place_uuid, nullptr);
}
/**
@return true if @p terminal was paired with a saved terminal at another
place than its own
@see matchMovedTerminals()
*/
bool Element::terminalWasMoved(const Terminal *terminal) const
{
return m_moved_terminals.contains(const_cast<Terminal *>(terminal));
}
/**
@brief Element::fromXml
Import the parameters of this element from a xml document.
@@ -999,22 +1101,28 @@ bool Element::fromXml(QDomElement &e,
}
QHash<int, Terminal *> priv_id_adr;
QSet<Terminal *> matched_terminals;
QSet<int> matched_records;
for (auto *qgi : childItems())
{
if (auto terminal_ = qgraphicsitem_cast<Terminal *>(qgi))
{
for(auto qde : liste_terminals)
for (int i = 0; i < liste_terminals.size(); ++i)
{
QDomElement qde = liste_terminals.at(i);
if (terminal_ -> fromXml(qde))
{
priv_id_adr.insert(qde.attribute(QStringLiteral("id")).toInt(),
terminal_);
matched_terminals << terminal_;
matched_records << i;
}
}
}
}
matchMovedTerminals(liste_terminals, matched_records, matched_terminals, priv_id_adr);
//Check that associated id/address doesn't conflict with table_id_adr
for(auto found_id : priv_id_adr.keys())
+18
View File
@@ -27,6 +27,7 @@
#include "../properties/elementdata.h"
#include <QHash>
#include <QSet>
#include <QPicture>
#include <algorithm>
@@ -282,6 +283,23 @@ class Element : public QetGraphicsItem
DynamicElementTextItem *parseDynamicText(
const QDomElement &dom_element);
Terminal *parseTerminal(const QDomElement &dom_element);
void matchMovedTerminals(const QList<QDomElement> &records,
const QSet<int> &matched_records,
const QSet<Terminal *> &matched_terminals,
QHash<int, Terminal *> &id_adr);
public:
Terminal *terminalFromOldPlace(const QUuid &old_place_uuid) const;
bool terminalWasMoved(const Terminal *terminal) const;
private:
/// How far, in x and y, a terminal may have moved from where
/// a project saved it and still be recognised
static constexpr qreal moved_terminal_reach = 5.0;
/// Terminals paired with a saved one that was elsewhere,
/// and the uuid derived from that old place
QHash<QUuid, Terminal *> m_old_place_terminals;
QSet<Terminal *> m_moved_terminals;
//Reimplemented from QGraphicsItem
public:
+12
View File
@@ -1033,6 +1033,18 @@ target_compile_definitions(tst_derivedwireuuid PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
# A wire follows a terminal that moved a little since the project was saved
# (a symbol replaced by a newer definition), by its legacy id or by the uuid
# derived from its old place: Element::fromXml() pairs them (F112).
add_executable(
tst_movedterminals
tst_movedterminals.cpp)
add_test(NAME tst_movedterminals COMMAND tst_movedterminals)
add_dependencies(tst_movedterminals qelectrotech)
target_link_libraries(tst_movedterminals PRIVATE Qt::Test Qt::Xml)
target_compile_definitions(tst_movedterminals PRIVATE
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
# Symbols sharing one uuid on a folio keep their wires through save and
# reopen (#1408): nets of examples/Habitat-Schemas_developpes.qet.
add_executable(
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
#include <QUuid>
// A project saves, for every placed symbol, where each terminal docked, and
// its wires name the terminals by that number or by the uuid worked out from
// that place. When the symbol's definition has since been replaced by one
// whose terminal sits a little elsewhere, the wire must still reattach --
// when the match is clear.
//
// Runs the real binary (--resave) on a small project written here: two
// copies of one embedded symbol, joined by one wire from the south terminal
// (the one the definition moves) to the north one (which stays).
namespace {
const QUuid ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}"));
// What TerminalUuids::derived() works out for a terminal at x, y
QString derived(int y, int orientation)
{
return QUuid::createUuidV5(ns, QStringLiteral("%1|%2|%3")
.arg(0.0, 0, 'f', 4).arg(double(y), 0, 'f', 4).arg(orientation))
.toString();
}
const QString uuid_a = QStringLiteral("{11111111-1111-4111-8111-111111111111}");
const QString uuid_b = QStringLiteral("{22222222-2222-4222-8222-222222222222}");
// The symbol as the project's embedded collection now holds it. The south
// terminals are at the given y (the project was saved with one at y=10);
// the north one has always been at y=-10.
QString definition(const QList<int> &south_ys)
{
QString terminals = QStringLiteral(
"<terminal orientation=\"n\" type=\"Generic\" name=\"n\" x=\"0\" y=\"-10\"/>");
for (int y : south_ys)
terminals += QStringLiteral(
"<terminal orientation=\"s\" type=\"Generic\" name=\"s\" x=\"0\" y=\"%1\"/>").arg(y);
return QStringLiteral(
"<collection><category name=\"import\"><category name=\"t\">"
"<element name=\"box.elmt\">"
"<definition width=\"30\" hotspot_y=\"21\" version=\"0.100.0\" type=\"element\""
" link_type=\"simple\" hotspot_x=\"15\" height=\"42\">"
"<uuid uuid=\"{33333333-3333-4333-8333-333333333333}\"/>"
"<names><name lang=\"en\">box</name></names>"
"<kindInformations/><informations/>"
"<description><rect x=\"-5\" y=\"-6\" width=\"10\" height=\"12\" antialias=\"true\""
" style=\"line-style:normal;line-weight:normal;filling:none;color:black\"/>%1</description>"
"</definition></element></category></category></collection>").arg(terminals);
}
// One placed symbol as the project saved it: south terminal docked at y=6
// (a terminal at y=10 less the 4-unit stub), north at y=-6.
QString placed(const QString &uuid, int first_id, int x)
{
return QStringLiteral(
"<element orientation=\"0\" type=\"embed://import/t/box.elmt\" z=\"10\""
" uuid=\"%1\" x=\"%2\" y=\"200\"><terminals>"
"<terminal orientation=\"2\" x=\"0\" y=\"6\" id=\"%3\"/>"
"<terminal orientation=\"0\" x=\"0\" y=\"-6\" id=\"%4\"/>"
"</terminals></element>").arg(uuid).arg(x).arg(first_id).arg(first_id + 1);
}
// The project, with the wire naming its ends as given
QString project(const QList<int> &south_ys, const QString &wire_ends)
{
return QStringLiteral(
"<project title=\"moved\" version=\"0.100.1\">"
"<diagram title=\"f\" cols=\"8\" rows=\"4\" colsize=\"60\" rowsize=\"80\">"
"<elements>%1%2</elements>"
"<conductors><conductor %3 type=\"multi\" num=\"\"/></conductors>"
"</diagram>%4</project>")
.arg(placed(uuid_a, 0, 100), placed(uuid_b, 2, 300), wire_ends, definition(south_ys));
}
QString byId() { return QStringLiteral("terminal1=\"0\" terminal2=\"3\""); }
QString byUuid(int old_south_y)
{
return QStringLiteral("element1=\"%1\" terminal1=\"%2\" element2=\"%3\" terminal2=\"%4\"")
.arg(uuid_a, derived(old_south_y, 2), uuid_b, derived(-10, 0));
}
} // namespace
class tst_movedterminals : public QObject
{
Q_OBJECT
QTemporaryDir m_dir;
int m_run = 0;
struct Result { int wires = -1; QString log; };
// Open and save the project in a sandbox of its own (so a running
// QElectroTech cannot answer instead): the wires that survive.
Result resave(const QString &xml)
{
const QString in = m_dir.filePath(QStringLiteral("in%1.qet").arg(m_run));
const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run));
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
QDir().mkpath(home);
QFile f(in);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(xml.toUtf8());
f.close();
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
env.insert(QStringLiteral("HOME"), home);
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config"));
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data"));
QProcess proc;
proc.setProcessEnvironment(env);
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--resave"), in, out});
if (!proc.waitForFinished(120000) || proc.exitCode() != 0) return {};
Result r;
r.log = QString::fromUtf8(proc.readAllStandardError() + proc.readAllStandardOutput());
QDomDocument doc;
QFile saved(out);
if (!saved.open(QIODevice::ReadOnly) || !doc.setContent(&saved)) return {};
r.wires = doc.elementsByTagName(QStringLiteral("conductor")).size();
return r;
}
static bool said(const Result &r)
{
return r.log.contains(QStringLiteral("matched to it by place"));
}
private slots:
void initTestCase()
{
QVERIFY(m_dir.isValid());
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
}
// The terminal has not moved: found as before, nothing to report.
void exactMatchIsUnaffected()
{
const Result legacy = resave(project({10}, byId()));
QCOMPARE(legacy.wires, 1);
QVERIFY(!said(legacy));
const Result uuid = resave(project({10}, byUuid(10)));
QCOMPARE(uuid.wires, 1);
QVERIFY(!said(uuid));
}
void legacyIdWireFollowsATerminalMovedThreeUnits()
{
const Result r = resave(project({13}, byId()));
QCOMPARE(r.wires, 1);
QVERIFY(said(r));
}
void derivedUuidWireFollowsATerminalMovedThreeUnits()
{
const Result r = resave(project({13}, byUuid(10)));
QCOMPARE(r.wires, 1);
QVERIFY(said(r));
}
void movedFiveUnitsStillMatches()
{
QCOMPARE(resave(project({15}, byId())).wires, 1);
}
void movedSixUnitsDoesNot()
{
QCOMPARE(resave(project({16}, byId())).wires, 0);
QCOMPARE(resave(project({16}, byUuid(10))).wires, 0);
}
// Two live terminals in reach of one saved one: no way to tell which.
void twoCandidatesPairNothing()
{
const Result legacy = resave(project({13, 14}, byId()));
QCOMPARE(legacy.wires, 0);
QVERIFY(!said(legacy));
QCOMPARE(resave(project({13, 14}, byUuid(10))).wires, 0);
}
};
QTEST_APPLESS_MAIN(tst_movedterminals)
#include "tst_movedterminals.moc"