From 7cc5790f7c8007089e61cacf56d67119149b79c8 Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Sat, 20 Jun 2026 07:11:09 +1200 Subject: [PATCH] Add EdzPart + EdzElementBuilder: .edz part -> .elmt (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the edz2qet.py prototype's mapping to C++. EdzPart parses an EPLAN part.xml into a portable model (identity/metadata, localized names, connection list); EdzElementBuilder turns that into a QET element (generic symbol: body rectangle + one west-facing terminal per pin, per-pin labels, localized s, and elementInformations for the BOM). Pins are natural-sorted by designation so they stack 1,2,3,4 regardless of the order EPLAN lists them (MFH200 lists 1,3,4,2). Output verified structurally identical (uuids aside) to the Python oracle for three ifm samples — KG6000 (4-pin), MFH200 (4-pin, reordered) and R1D200 (5-pin, incl. Dutch name) — and the generated element loads in the QET editor with correct UTF-8. Co-Authored-By: Claude Opus 4.8 --- cmake/qet_compilation_vars.cmake | 4 + sources/import/edz/edzelementbuilder.cpp | 235 +++++++++++++++++++++++ sources/import/edz/edzelementbuilder.h | 40 ++++ sources/import/edz/edzpart.cpp | 217 +++++++++++++++++++++ sources/import/edz/edzpart.h | 68 +++++++ 5 files changed, 564 insertions(+) create mode 100644 sources/import/edz/edzelementbuilder.cpp create mode 100644 sources/import/edz/edzelementbuilder.h create mode 100644 sources/import/edz/edzpart.cpp create mode 100644 sources/import/edz/edzpart.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 37144c7d3..e6e189350 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -109,6 +109,10 @@ set(QET_RES_FILES set(QET_SRC_FILES ${QET_DIR}/sources/import/edz/edzarchive.cpp ${QET_DIR}/sources/import/edz/edzarchive.h + ${QET_DIR}/sources/import/edz/edzpart.cpp + ${QET_DIR}/sources/import/edz/edzpart.h + ${QET_DIR}/sources/import/edz/edzelementbuilder.cpp + ${QET_DIR}/sources/import/edz/edzelementbuilder.h ${QET_DIR}/sources/borderproperties.cpp ${QET_DIR}/sources/borderproperties.h ${QET_DIR}/sources/bordertitleblock.cpp diff --git a/sources/import/edz/edzelementbuilder.cpp b/sources/import/edz/edzelementbuilder.cpp new file mode 100644 index 000000000..6bccbce32 --- /dev/null +++ b/sources/import/edz/edzelementbuilder.cpp @@ -0,0 +1,235 @@ +/* + Copyright 2006 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "edzelementbuilder.h" + +#include "edzpart.h" + +#include +#include +#include + +namespace { + +const QString STYLE = + QStringLiteral("line-style:normal;line-weight:normal;filling:none;color:black"); +const QString FONT = + QStringLiteral("Liberation Sans,5,-1,5,50,0,0,0,0,0,Regular"); +const QString TITLE_FONT = + QStringLiteral("Liberation Sans,5,-1,5,75,0,0,0,0,0,Bold"); + +QString uuidStr() +{ + return QUuid::createUuid().toString(); // "{....}" +} + +QDomElement makeText(QDomDocument &doc, int x, int y, const QString &text, + const QString &font) +{ + QDomElement e = doc.createElement(QStringLiteral("text")); + e.setAttribute(QStringLiteral("x"), x); + e.setAttribute(QStringLiteral("y"), y); + e.setAttribute(QStringLiteral("text"), text); + e.setAttribute(QStringLiteral("rotation"), QStringLiteral("0")); + e.setAttribute(QStringLiteral("font"), font); + e.setAttribute(QStringLiteral("color"), QStringLiteral("#000000")); + return e; +} + +int ceilTo10(int v) +{ + if (v <= 0) { + return 0; + } + return ((v + 9) / 10) * 10; +} + +} // namespace + +QDomDocument EdzElementBuilder::build(const EdzPart &part) +{ + QList pins = part.pins(); + if (pins.isEmpty()) { + pins.append(EdzPin{QStringLiteral("1"), QString()}); + } + const int n = pins.size(); + const int pitch = 10; + const int body_left = 0, body_right = 90, body_top = -20; + const int body_bottom = (n - 1) * pitch + 10; + + // Coordinate accumulators for the bounding box (origin always included). + QList xs{0}, ys{0}; + xs << body_left << body_right; + ys << body_top << body_bottom; + xs << (body_left + 6); // title x + ys << (body_top + 7); // title y + for (int i = 0; i < n; ++i) { + xs << 6 << 0; // pin label x, terminal x + ys << (i * pitch + 2) << (i * pitch); // pin label y, terminal y + } + + const int pad = 10; + const int min_x = *std::min_element(xs.begin(), xs.end()) - pad; + const int max_x = *std::max_element(xs.begin(), xs.end()) + pad; + const int min_y = *std::min_element(ys.begin(), ys.end()) - pad; + const int max_y = *std::max_element(ys.begin(), ys.end()) + pad; + const int width = std::max(10, ceilTo10(max_x - min_x)); + const int height = std::max(10, ceilTo10(max_y - min_y)); + const int hotspot_x = -min_x; + const int hotspot_y = -min_y; + + QDomDocument doc; + doc.appendChild(doc.createProcessingInstruction( + QStringLiteral("xml"), + QStringLiteral("version=\"1.0\" encoding=\"utf-8\""))); + + QDomElement defn = doc.createElement(QStringLiteral("definition")); + defn.setAttribute(QStringLiteral("version"), QStringLiteral("0.100.0")); + defn.setAttribute(QStringLiteral("type"), QStringLiteral("element")); + defn.setAttribute(QStringLiteral("link_type"), QStringLiteral("simple")); + defn.setAttribute(QStringLiteral("width"), width); + defn.setAttribute(QStringLiteral("height"), height); + defn.setAttribute(QStringLiteral("hotspot_x"), hotspot_x); + defn.setAttribute(QStringLiteral("hotspot_y"), hotspot_y); + doc.appendChild(defn); + + QDomElement uuid_el = doc.createElement(QStringLiteral("uuid")); + uuid_el.setAttribute(QStringLiteral("uuid"), uuidStr()); + defn.appendChild(uuid_el); + + // Localized names: fr/en first, then the rest alphabetically. + QDomElement names = doc.createElement(QStringLiteral("names")); + QMap name_map = part.names(); + const QString fallback = !part.orderNumber().isEmpty() ? part.orderNumber() + : (!part.partNumber().isEmpty() ? part.partNumber() + : QStringLiteral("part")); + if (name_map.isEmpty()) { + name_map.insert(QStringLiteral("en"), + !part.description().isEmpty() ? part.description() + : fallback); + } + QStringList ordered; + for (const QString &c : {QStringLiteral("fr"), QStringLiteral("en")}) { + if (name_map.contains(c)) ordered << c; + } + QStringList rest; + for (const QString &c : name_map.keys()) { + if (c != QLatin1String("fr") && c != QLatin1String("en")) rest << c; + } + std::sort(rest.begin(), rest.end()); + ordered << rest; + for (const QString &lang : ordered) { + QDomElement nm = doc.createElement(QStringLiteral("name")); + nm.setAttribute(QStringLiteral("lang"), lang); + nm.appendChild(doc.createTextNode(name_map.value(lang))); + names.appendChild(nm); + } + defn.appendChild(names); + + QDomElement infos = doc.createElement(QStringLiteral("informations")); + infos.appendChild(doc.createTextNode( + QStringLiteral("Imported from EPLAN .edz by edz2qet (%1)") + .arg(part.partNumber()))); + defn.appendChild(infos); + + // Element information -> QET BOM / nomenclature. + QDomElement einfos = doc.createElement(QStringLiteral("elementInformations")); + const QList> info_map{ + {QStringLiteral("manufacturer"), part.manufacturer()}, + {QStringLiteral("manufacturer_reference"), part.orderNumber()}, + {QStringLiteral("designation"), part.description()}, + {QStringLiteral("comment"), part.comment()}, + }; + for (const auto &kv : info_map) { + if (kv.second.isEmpty()) { + continue; + } + QDomElement e = doc.createElement(QStringLiteral("elementInformation")); + e.setAttribute(QStringLiteral("name"), kv.first); + e.setAttribute(QStringLiteral("show"), QStringLiteral("1")); + e.appendChild(doc.createTextNode(kv.second)); + einfos.appendChild(e); + } + defn.appendChild(einfos); + + QDomElement desc = doc.createElement(QStringLiteral("description")); + + // Body rectangle. + QDomElement rect = doc.createElement(QStringLiteral("rect")); + rect.setAttribute(QStringLiteral("x"), body_left); + rect.setAttribute(QStringLiteral("y"), body_top); + rect.setAttribute(QStringLiteral("width"), body_right - body_left); + rect.setAttribute(QStringLiteral("height"), body_bottom - body_top); + rect.setAttribute(QStringLiteral("style"), STYLE); + rect.setAttribute(QStringLiteral("antialias"), QStringLiteral("false")); + desc.appendChild(rect); + + // Title (order number) in its own band at the top. + const QString title = !part.orderNumber().isEmpty() ? part.orderNumber() + : (!part.partNumber().isEmpty() ? part.partNumber() + : QStringLiteral("PART")); + desc.appendChild(makeText(doc, body_left + 6, body_top + 7, title, TITLE_FONT)); + + // Per-pin labels. + for (int i = 0; i < n; ++i) { + QString label = pins.at(i).designation; + if (!pins.at(i).description.isEmpty()) { + label += QStringLiteral(" ") + pins.at(i).description; + } + desc.appendChild(makeText(doc, 6, i * pitch + 2, label, FONT)); + } + + // Device-tag label (per-instance, above the body). + QDomElement dyn = doc.createElement(QStringLiteral("dynamic_text")); + dyn.setAttribute(QStringLiteral("x"), min_x + pad); + dyn.setAttribute(QStringLiteral("y"), min_y - 2); + dyn.setAttribute(QStringLiteral("z"), QStringLiteral("5")); + dyn.setAttribute(QStringLiteral("text_width"), QStringLiteral("-1")); + dyn.setAttribute(QStringLiteral("Halignment"), QStringLiteral("AlignLeft")); + dyn.setAttribute(QStringLiteral("Valignment"), QStringLiteral("AlignTop")); + dyn.setAttribute(QStringLiteral("frame"), QStringLiteral("false")); + dyn.setAttribute(QStringLiteral("rotation"), QStringLiteral("0")); + dyn.setAttribute(QStringLiteral("keep_visual_rotation"), QStringLiteral("false")); + dyn.setAttribute(QStringLiteral("text_from"), QStringLiteral("ElementInfo")); + dyn.setAttribute(QStringLiteral("uuid"), uuidStr()); + dyn.setAttribute(QStringLiteral("font"), FONT); + dyn.appendChild(doc.createElement(QStringLiteral("text"))); + QDomElement info_name = doc.createElement(QStringLiteral("info_name")); + info_name.appendChild(doc.createTextNode(QStringLiteral("label"))); + dyn.appendChild(info_name); + desc.appendChild(dyn); + + // Terminals. + for (int i = 0; i < n; ++i) { + QDomElement t = doc.createElement(QStringLiteral("terminal")); + t.setAttribute(QStringLiteral("uuid"), uuidStr()); + t.setAttribute(QStringLiteral("name"), pins.at(i).designation); + t.setAttribute(QStringLiteral("x"), 0); + t.setAttribute(QStringLiteral("y"), i * pitch); + t.setAttribute(QStringLiteral("orientation"), QStringLiteral("w")); + t.setAttribute(QStringLiteral("type"), QStringLiteral("Generic")); + desc.appendChild(t); + } + + defn.appendChild(desc); + return doc; +} + +QString EdzElementBuilder::toElmtString(const QDomDocument &doc) +{ + return doc.toString(0); +} diff --git a/sources/import/edz/edzelementbuilder.h b/sources/import/edz/edzelementbuilder.h new file mode 100644 index 000000000..8af8350a8 --- /dev/null +++ b/sources/import/edz/edzelementbuilder.h @@ -0,0 +1,40 @@ +/* + Copyright 2006 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef EDZELEMENTBUILDER_H +#define EDZELEMENTBUILDER_H + +#include + +class EdzPart; + +/** + @brief Builds a QElectroTech element (.elmt) from an EdzPart. + + Generates a generic symbol: a body rectangle with one west-facing terminal + per pin (stacked on the left edge, on the 10px wiring grid), per-pin labels, + localized names and the element-information fields QET uses for the BOM. + Faithful port of the edz2qet.py prototype's builder. +*/ +class EdzElementBuilder +{ + public: + static QDomDocument build(const EdzPart &part); + static QString toElmtString(const QDomDocument &doc); +}; + +#endif // EDZELEMENTBUILDER_H diff --git a/sources/import/edz/edzpart.cpp b/sources/import/edz/edzpart.cpp new file mode 100644 index 000000000..5a2364698 --- /dev/null +++ b/sources/import/edz/edzpart.cpp @@ -0,0 +1,217 @@ +/* + Copyright 2006 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "edzpart.h" + +#include +#include +#include +#include +#include + +namespace { + +/** + Parse EPLAN's `de_DE@..;en_EN@..;..` multilingual blob into ordered + (full_code, text) pairs. Values may contain `;;` as an in-text separator, so + we split on language *markers* (xx_YY@) rather than on bare `;`. +*/ +QList> langDict(const QString &value) +{ + QList> out; + if (value.isEmpty()) { + return out; + } + static const QRegularExpression re(QStringLiteral("[a-z]{2}_[A-Z]{2}@")); + QList marks; + auto it = re.globalMatch(value); + while (it.hasNext()) { + marks.append(it.next()); + } + for (int i = 0; i < marks.size(); ++i) { + const QRegularExpressionMatch &m = marks.at(i); + const QString code = value.mid(m.capturedStart(), + m.capturedLength() - 1); // drop '@' + const int text_start = m.capturedEnd(); + const int text_end = (i + 1 < marks.size()) + ? marks.at(i + 1).capturedStart() + : value.size(); + QString text = value.mid(text_start, text_end - text_start); + while (!text.isEmpty() && text.back().isSpace()) { + text.chop(1); + } + while (!text.isEmpty() && text.back() == QLatin1Char(';')) { + text.chop(1); + } + text.replace(QStringLiteral(";;"), QStringLiteral("; ")); + text = text.trimmed(); + if (!text.isEmpty()) { + out.append({code, text}); + } + } + return out; +} + +/** Pick one language: preferred English variants, then any English, then first. */ +QString pickLang(const QString &value) +{ + if (value.isEmpty()) { + return QString(); + } + const QList> d = langDict(value); + if (d.isEmpty()) { + return value.trimmed(); + } + for (const QString &code : {QStringLiteral("en_EN"), QStringLiteral("en_US"), + QStringLiteral("en_GB")}) { + for (const auto &p : d) { + if (p.first == code && !p.second.isEmpty()) { + return p.second; + } + } + } + for (const auto &p : d) { + if (p.first.startsWith(QStringLiteral("en_")) && !p.second.isEmpty()) { + return p.second; + } + } + return d.first().second; +} + +/** {2-letter QET lang -> text}; first variant of each language wins. */ +QMap multilangNames(const QString &value) +{ + QMap names; + for (const auto &p : langDict(value)) { + const QString s = p.first.left(2).toLower(); + if (!names.contains(s)) { + names.insert(s, p.second); + } + } + return names; +} + +/** Tokenise a designation into (is-text, number, text) runs for natural sort. */ +struct NatTok { int type; qlonglong num; QString str; }; + +QList natKey(const QString &s) +{ + QList out; + static const QRegularExpression re(QStringLiteral("\\d+|\\D+")); + auto it = re.globalMatch(s); + while (it.hasNext()) { + const QString t = it.next().captured(); + bool all_digit = !t.isEmpty(); + for (const QChar c : t) { + if (!c.isDigit()) { all_digit = false; break; } + } + if (all_digit) { + out.append({0, t.toLongLong(), QString()}); + } else { + out.append({1, 0, t.toLower()}); + } + } + return out; +} + +bool natLess(const QString &a, const QString &b) +{ + const QList ka = natKey(a), kb = natKey(b); + const int n = qMin(ka.size(), kb.size()); + for (int i = 0; i < n; ++i) { + if (ka[i].type != kb[i].type) { + return ka[i].type < kb[i].type; + } + if (ka[i].type == 0) { + if (ka[i].num != kb[i].num) return ka[i].num < kb[i].num; + } else { + if (ka[i].str != kb[i].str) return ka[i].str < kb[i].str; + } + } + return ka.size() < kb.size(); +} + +} // namespace + +/** Parse the part.xml at @a part_xml_path into this object. */ +bool EdzPart::parse(const QString &part_xml_path) +{ + m_error.clear(); + + QFile file(part_xml_path); + if (!file.open(QIODevice::ReadOnly)) { + m_error = QStringLiteral("Cannot open %1").arg(part_xml_path); + return false; + } + QDomDocument doc; + QString parse_err; + int line = 0, col = 0; + if (!doc.setContent(&file, &parse_err, &line, &col)) { + m_error = QStringLiteral("Malformed part.xml (line %1): %2") + .arg(line).arg(parse_err); + return false; + } + file.close(); + + const QDomElement part = + doc.documentElement().firstChildElement(QStringLiteral("part")); + if (part.isNull()) { + m_error = QStringLiteral("No element in part.xml"); + return false; + } + + m_part_number = part.attribute(QStringLiteral("P_ARTICLE_PARTNR")); + m_manufacturer = part.attribute(QStringLiteral("P_ARTICLE_MANUFACTURER")); + m_order_number = part.attribute(QStringLiteral("P_ARTICLE_ORDERNR")); + m_type_number = part.attribute(QStringLiteral("P_ARTICLE_TYPENR")); + + const QString descr1 = part.attribute(QStringLiteral("P_ARTICLE_DESCR1")); + const QString descr2 = part.attribute(QStringLiteral("P_ARTICLE_DESCR2")); + m_description = pickLang(descr1); + m_comment = pickLang(descr2); + m_names = multilangNames(descr1); + + const QString pic = part.attribute(QStringLiteral("P_ARTICLE_PICTUREFILE")); + if (!pic.isEmpty()) { + m_picture = QFileInfo(QString(pic).replace('\\', '/')).fileName(); + } + + // Pins = function templates that carry a physical connection designation. + const QDomNodeList fts = + part.elementsByTagName(QStringLiteral("functiontemplate")); + for (int i = 0; i < fts.size(); ++i) { + const QDomElement ft = fts.at(i).toElement(); + const QString desig = + ft.attribute(QStringLiteral("connectionDesignation")).trimmed(); + if (desig.isEmpty()) { + continue; + } + EdzPin pin; + pin.designation = desig; + pin.description = + ft.attribute(QStringLiteral("connectiondescription")).trimmed(); + m_pins.append(pin); + } + + // EPLAN lists pins in its own order (e.g. MFH200: 1,3,4,2); order them by + // pin number so they stack 1,2,3,4 on the symbol. + std::stable_sort(m_pins.begin(), m_pins.end(), + [](const EdzPin &a, const EdzPin &b) { + return natLess(a.designation, b.designation); + }); + return true; +} diff --git a/sources/import/edz/edzpart.h b/sources/import/edz/edzpart.h new file mode 100644 index 000000000..45d31620a --- /dev/null +++ b/sources/import/edz/edzpart.h @@ -0,0 +1,68 @@ +/* + Copyright 2006 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef EDZPART_H +#define EDZPART_H + +#include +#include +#include + +/** One physical connection of an EPLAN part (a pin/terminal). */ +struct EdzPin { + QString designation; ///< terminal id, e.g. "1", "PE" + QString description; ///< function label, e.g. "L+" +}; + +/** + @brief The portable data of an EPLAN part, read from its part.xml. + + Mirrors the fields the standalone edz2qet.py prototype maps: identity and + metadata, localized names (2-letter language -> text) and the connection + list. Geometry from the EPLAN macro is intentionally ignored — the importer + generates a generic symbol from the pin list instead. +*/ +class EdzPart +{ + public: + bool parse(const QString &part_xml_path); + QString errorString() const { return m_error; } + + QString partNumber() const { return m_part_number; } + QString manufacturer() const { return m_manufacturer; } + QString orderNumber() const { return m_order_number; } + QString typeNumber() const { return m_type_number; } + QString description() const { return m_description; } + QString comment() const { return m_comment; } + QString picture() const { return m_picture; } + QMap names() const { return m_names; } + QList pins() const { return m_pins; } + + private: + QString m_error; + QString m_part_number; + QString m_manufacturer; + QString m_order_number; + QString m_type_number; + QString m_description; ///< DESCR1, preferred English (for element-info) + QString m_comment; ///< DESCR2, preferred English + QString m_picture; ///< picture file name, if any + QMap m_names; ///< 2-letter lang -> localized name + QList m_pins; +}; + +#endif // EDZPART_H