From c740cdf1ac4492a41b248f2f98d78c644fe2c666 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 21 Sep 2026 17:57:29 +1200 Subject: [PATCH] Let a script wire, label and rotate, not only place The scripting API (bugtracker #162) could place an element and move it, and could count conductors but not make one. So a script could put a coil and a motor on a folio and had no way to connect them, which is most of what drawing is. This adds the missing verbs: addConductor() wire terminal i of one element to terminal j of another rotateElement() setElementInfo() any information key setElementLabel() the label key, by name, since it is the one people want addFolio() setFolioTitle() elementUuids() what is on this folio elementName() elementTerminals() which terminal index is which, before wiring it Each goes through the command the GUI already uses, so a script's edits undo like manual ones and reach the project database the same way: ConductorCreator (the drag-a-rectangle-over-terminals path, which is what makes a new conductor inherit an existing potential's properties and join auto-numbering), ChangeElementInformationCommand, QETProject::addNewDiagram(), ChangeTitleBlockCommand. rotateElement() pushes the same QPropertyUndoCommand on "rotation" that RotateSelectionCommand pushes for an Element, rather than RotateSelectionCommand itself, which works on the diagram's selection and would mean rewriting the user's selection to rotate one element. Terminals are addressed by index, not uuid. Terminal::uuid() is a property of the catalog .elmt definition: empty for most of the installed base, and where present, identical across every instance of that element -- two coils of the same type placed side by side have byte-identical terminal uuids, so a uuid cannot say which coil's A1 is meant. elementTerminals() exists so a script can see the indexing instead of guessing it. The one real hazard is that ConductorCreator asks the user which potential to inherit from when the two terminals sit on two different existing ones, and it asks with a plain modal QDialog that QET::QetMessageBox's non-interactive mode does not cover -- so under headless --run there is nobody to answer and the call never returns. Measured: with the check removed, that one call hangs until killed; with it, it declines in 0.4 s. addConductor() therefore refuses that case, the same way and for the same reason addElement() already refuses the import-conflict dialog. To make that check without duplicating the condition, existingPotential() becomes static over an explicit terminal list and ConductorCreator gains a public needsPotentialChoice() predicate. Behaviour of the GUI path is unchanged; setUpPropertieToUse() passes m_terminals_list to the same code it called before. Verified headlessly against a copy of examples/ArduinoLCD.qet: new folio titled, two coils placed, wired, labelled, an info key set and the element rotated; saved, reloaded, and the conductor, label, title and rotation (persisted as orientation="1") all read back. Re-saving the result is byte-identical. qet-lint clean on the generated project; qet-coherence-check clean on it and on the 24-project example corpus, and shown to report 9 findings on a deliberately broken copy of the same file, so the clean result discriminates. Qt 6.10.2, ctest identical to master (the 61 failures are the vendored KDE ECM suite, present on both). Co-Authored-By: Claude Opus 5 (1M context) --- sources/scripting/qetscriptapi.cpp | 249 +++++++++++++++++++++++++++++ sources/scripting/qetscriptapi.h | 63 +++++++- sources/utils/conductorcreator.cpp | 34 +++- sources/utils/conductorcreator.h | 5 +- 4 files changed, 339 insertions(+), 12 deletions(-) diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index b13969404..f9bfec17f 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -29,8 +29,14 @@ #include "../qetmessagebox.h" #include "../qetproject.h" #include "../qetresult.h" +#include "../qetgraphicsitem/terminal.h" +#include "../qetinformation.h" +#include "../titleblockproperties.h" #include "../undocommand/addgraphicsobjectcommand.h" +#include "../undocommand/changeelementinformationcommand.h" +#include "../undocommand/changetitleblockcommand.h" #include "../undocommand/deleteqgraphicsitemcommand.h" +#include "../utils/conductorcreator.h" #include #include @@ -268,6 +274,48 @@ Element *QetScriptApi::findElement(int folioIndex, const QString &elementUuid) c return nullptr; } +Terminal *QetScriptApi::findTerminal(int folioIndex, const QString &elementUuid, + int terminalIndex, const QString &caller) +{ + Element *element = findElement(folioIndex, elementUuid); + if (!element) { + log(QStringLiteral("qet.%1: no element %2 on folio %3").arg(caller, elementUuid).arg(folioIndex)); + return nullptr; + } + const QList terminals = element->terminals(); + if (terminalIndex < 0 || terminalIndex >= terminals.count()) { + log(QStringLiteral("qet.%1: %2 has %3 terminal(s), no index %4") + .arg(caller, element->name()).arg(terminals.count()).arg(terminalIndex)); + return nullptr; + } + return terminals.at(terminalIndex); +} + +bool QetScriptApi::setInfoKey(int folioIndex, const QString &elementUuid, + const QString &key, const QString &value, const QString &caller) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + if (key.isEmpty()) { + log(QStringLiteral("qet.%1: empty information key").arg(caller)); + return false; + } + Element *element = findElement(folioIndex, elementUuid); + if (!element) return false; + + const DiagramContext old_info = element->elementInformations(); + if (old_info.value(key).toString() == value) return true; // nothing to push + DiagramContext new_info = old_info; + new_info.addValue(key, value); + + auto *cmd = new ChangeElementInformationCommand(element, old_info, new_info); + m_project->undoStack()->push(cmd); + return true; +} + /** @brief QetScriptApi::addElement Place a new element on a folio, through the same AddGraphicsObjectCommand @@ -417,6 +465,207 @@ bool QetScriptApi::deleteElement(int folioIndex, const QString &elementUuid) return true; } +bool QetScriptApi::rotateElement(int folioIndex, const QString &elementUuid, double angle) +{ + if (m_project && m_project->isReadOnly()) { + log(QStringLiteral("qet.rotateElement: project is read-only")); + return false; + } + Element *element = findElement(folioIndex, elementUuid); + if (!element) return false; + + // The same property command RotateSelectionCommand pushes for an + // Element -- deliberately not RotateSelectionCommand itself, which + // works on diagram->selectedItems() and would mean quietly rewriting + // the user's selection to rotate one element by uuid. For a single + // element the two are mechanically identical: that class special-cases + // Element::Type to exactly this one command, and only adds a second, + // positional one when rotating a multi-item selection as a group. + auto *cmd = new QPropertyUndoCommand(element, "rotation", + QVariant(element->rotation()), + QVariant(element->rotation() + angle)); + cmd->setText(QObject::tr("Pivoter %1").arg(element->name())); + m_project->undoStack()->push(cmd); + return true; +} + +QStringList QetScriptApi::elementUuids(int folioIndex) const +{ + QStringList uuids; + if (!m_project) return uuids; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return uuids; + DiagramContent content(diagrams.at(folioIndex), false); + for (Element *elmt : std::as_const(content.m_elements)) { + uuids << elmt->uuid().toString(); + } + return uuids; +} + +QString QetScriptApi::elementName(int folioIndex, const QString &elementUuid) const +{ + Element *element = findElement(folioIndex, elementUuid); + return element ? element->name() : QString(); +} + +/** + @brief QetScriptApi::elementTerminals + The element's terminals, in the order addConductor() indexes them: one + entry per terminal, ": ( conductor(s))". Descriptive + rather than structured because its only job is to let a script -- or a + human reading a script's output -- see which index is which before + wiring anything to it. + + Indexes, not uuids, because a terminal uuid does not address a terminal + on a folio. Terminal::uuid() comes from the catalog .elmt definition + (see Terminal::stableUuid()), so it is empty for most of the installed + base, and where it is not, every instance of that same element carries + the same one -- two coils of one type placed side by side have + byte-identical terminal uuids, which is plainly visible in the saved + file of any project written through this API. The order of + Element::terminals() also comes from the definition, but it is at least + unambiguous within the element the caller has already named by uuid. +*/ +QStringList QetScriptApi::elementTerminals(int folioIndex, const QString &elementUuid) const +{ + QStringList list; + Element *element = findElement(folioIndex, elementUuid); + if (!element) return list; + const QList terminals = element->terminals(); + for (int i = 0 ; i < terminals.count() ; ++i) + { + Terminal *t = terminals.at(i); + list << QStringLiteral("%1: %2 (%3 conductor(s))") + .arg(i) + .arg(t->name().isEmpty() ? QStringLiteral("-") : t->name()) + .arg(t->conductorsCount()); + } + return list; +} + +QString QetScriptApi::elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const +{ + Element *element = findElement(folioIndex, elementUuid); + if (!element) return QString(); + return element->elementInformations().value(key).toString(); +} + +bool QetScriptApi::setElementInfo(int folioIndex, const QString &elementUuid, + const QString &key, const QString &value) +{ + return setInfoKey(folioIndex, elementUuid, key, value, QStringLiteral("setElementInfo")); +} + +QString QetScriptApi::elementLabel(int folioIndex, const QString &elementUuid) const +{ + return elementInfo(folioIndex, elementUuid, QETInformation::ELMT_LABEL); +} + +bool QetScriptApi::setElementLabel(int folioIndex, const QString &elementUuid, const QString &label) +{ + return setInfoKey(folioIndex, elementUuid, QETInformation::ELMT_LABEL, label, + QStringLiteral("setElementLabel")); +} + +/** + @brief QetScriptApi::addConductor + Wire terminal terminalIndexA of one element to terminalIndexB of + another, on the same folio, through ConductorCreator -- the same class + the "draw a selection rectangle over terminals" GUI path uses. Going + through it rather than constructing a Conductor directly is what makes + the new conductor inherit an existing potential's properties and take + part in conductor auto-numbering; a hand-built one would be silently + outside both. + + Refuses, rather than creating anything, when the two terminals sit on + two different existing potentials: ConductorCreator then has to ask + which one's properties the new conductor should inherit, and it asks + with a plain modal QDialog that QET::QetMessageBox's non-interactive + mode does not cover -- so under headless --run there would be nobody to + answer it and the script would hang forever. Same reasoning, and the + same choice, as addElement() makes about the import-conflict dialog. + @return true if a conductor was created +*/ +bool QetScriptApi::addConductor(int folioIndex, + const QString &elementUuidA, int terminalIndexA, + const QString &elementUuidB, int terminalIndexB) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addConductor: project is read-only")); + return false; + } + const QString caller = QStringLiteral("addConductor"); + Terminal *t1 = findTerminal(folioIndex, elementUuidA, terminalIndexA, caller); + Terminal *t2 = findTerminal(folioIndex, elementUuidB, terminalIndexB, caller); + if (!t1 || !t2) return false; + + if (t1 == t2) { + log(QStringLiteral("qet.addConductor: both ends are the same terminal")); + return false; + } + if (t1->isLinkedTo(t2)) { + log(QStringLiteral("qet.addConductor: those two terminals are already wired together")); + return false; + } + if (!t1->canBeLinkedTo(t2)) { + log(QStringLiteral("qet.addConductor: those two terminals cannot be linked")); + return false; + } + + const QList terminals {t1, t2}; + if (ConductorCreator::needsPotentialChoice(terminals)) { + log(QStringLiteral("qet.addConductor: those terminals are on two different existing " + "potentials, so creating a conductor would ask which one to inherit " + "-- refusing rather than open a dialog no script can answer")); + return false; + } + + Diagram *diagram = m_project->diagrams().at(folioIndex); + ConductorCreator creator(diagram, terminals); + Q_UNUSED(creator) + + // ConductorCreator has no return value and several ways to decline + // quietly, so report what actually happened rather than that it ran. + return t1->isLinkedTo(t2); +} + +int QetScriptApi::addFolio() +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addFolio: project is read-only")); + return -1; + } + Diagram *diagram = m_project->addNewDiagram(); + if (!diagram) return -1; + return m_project->diagrams().indexOf(diagram); +} + +bool QetScriptApi::setFolioTitle(int folioIndex, const QString &title) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setFolioTitle: project is read-only")); + return false; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return false; + Diagram *diagram = diagrams.at(folioIndex); + + // The folio title is one field of the title block properties, so it + // changes the way the title block dialog changes it: read the whole + // struct, set one member, push the command with both versions. + const TitleBlockProperties old_properties = diagram->border_and_titleblock.exportTitleBlock(); + if (old_properties.title == title) return true; + TitleBlockProperties new_properties = old_properties; + new_properties.title = title; + + auto *cmd = new ChangeTitleBlockCommand(diagram, old_properties, new_properties); + m_project->undoStack()->push(cmd); + return true; +} + bool QetScriptApi::undo() { if (!m_project || !m_project->undoStack()->canUndo()) return false; diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index c0c5416f5..2544f483f 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -25,6 +25,7 @@ class QETProject; class DiagramView; class Element; +class Terminal; /** @brief The QetScriptApi class @@ -65,12 +66,34 @@ class Element; QPropertyUndoCommand merges consecutive commands on the same object+property when their text() also matches (QPropertyUndoCommand::mergeWith(), pre-existing), and - setElementPosition()/moveElement() always use the same text for a - given element -- so several position changes to the same element in a - row collapse into one undo step, the same way dragging an element - does, not one step per call. Verified against exactly that: two + setElementPosition()/moveElement()/rotateElement() always use the + same text for a given element -- so several position changes, or + several rotations, of the same element in a row collapse into one + undo step, the same way dragging or repeatedly rotating an element + does, not one step per call. setElementInfo()/setElementLabel() + behave the same way for the same reason, through + ChangeElementInformationCommand::mergeWith(). Verified against exactly that: two consecutive calls on one element, then undo/undo/redo/redo, land where a merge predicts, not where two independent steps would. + - @b Wiring, @b labelling and @b folios: create a conductor between two + terminals (ConductorCreator, the same class the GUI's + drag-a-rectangle-over-terminals path uses, so the result inherits an + existing potential's properties and joins conductor auto-numbering), + change an element's label or any other information key + (ChangeElementInformationCommand, which also tells the project + database what changed), add a folio (QETProject::addNewDiagram(), + already undoable) and set its title (ChangeTitleBlockCommand). With + addElement() these are what make a script able to draw rather than + only rearrange: before them a script could place two symbols and had + no way to connect them. + + Terminals are addressed by their @b index in Element::terminals(), + not by uuid, and elementTerminals() prints that indexing so a script + can see what it is about to wire. Terminal uuids look like the + obvious key and are not one: Terminal::uuid() is a property of the + catalog .elmt definition, empty for most of the installed base and, + where present, identical across every instance of that element -- so + it does not distinguish one placed coil's A1 from another's. - @b Navigating and @b messaging: select an element, zoom the active view, and show the user a message. Deliberately narrow: selection and messaging work with no view at all (headless `--run`); zoom is a no-op @@ -87,7 +110,11 @@ class Element; import-collision case that would otherwise reach QETProject::importElement()'s own ImportElementDialog::exec() and refuses instead, rather than let a plain QDialog (not routed through - QetMessageBox) block a script the same way. + QetMessageBox) block a script the same way. addConductor() declines the + same way, for the same reason, when the two terminals belong to two + different existing potentials and ConductorCreator would therefore ask + which one's properties to inherit -- measured: with that check removed, + exactly that call never returns. */ class QetScriptApi : public QObject { @@ -128,7 +155,29 @@ class QetScriptApi : public QObject Q_INVOKABLE QString addElement(int folioIndex, const QString &locationPath, double x, double y); Q_INVOKABLE bool setElementPosition(int folioIndex, const QString &elementUuid, double x, double y); Q_INVOKABLE bool moveElement(int folioIndex, const QString &elementUuid, double dx, double dy); + Q_INVOKABLE bool rotateElement(int folioIndex, const QString &elementUuid, double angle); Q_INVOKABLE bool deleteElement(int folioIndex, const QString &elementUuid); + + // -- address what is already there -- + Q_INVOKABLE QStringList elementUuids(int folioIndex) const; + Q_INVOKABLE QString elementName(int folioIndex, const QString &elementUuid) const; + Q_INVOKABLE QStringList elementTerminals(int folioIndex, const QString &elementUuid) const; + + // -- element information, through ChangeElementInformationCommand -- + Q_INVOKABLE QString elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const; + Q_INVOKABLE bool setElementInfo(int folioIndex, const QString &elementUuid, const QString &key, const QString &value); + Q_INVOKABLE QString elementLabel(int folioIndex, const QString &elementUuid) const; + Q_INVOKABLE bool setElementLabel(int folioIndex, const QString &elementUuid, const QString &label); + + // -- wire two terminals together -- + Q_INVOKABLE bool addConductor(int folioIndex, + const QString &elementUuidA, int terminalIndexA, + const QString &elementUuidB, int terminalIndexB); + + // -- folios -- + Q_INVOKABLE int addFolio(); + Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title); + Q_INVOKABLE bool undo(); Q_INVOKABLE bool redo(); Q_INVOKABLE bool canUndo() const; @@ -148,6 +197,10 @@ class QetScriptApi : public QObject private: bool runFlag(const QString &flag, const QStringList &args); Element *findElement(int folioIndex, const QString &elementUuid) const; + Terminal *findTerminal(int folioIndex, const QString &elementUuid, int terminalIndex, + const QString &caller); + bool setInfoKey(int folioIndex, const QString &elementUuid, + const QString &key, const QString &value, const QString &caller); QETProject *m_project; DiagramView *m_view; diff --git a/sources/utils/conductorcreator.cpp b/sources/utils/conductorcreator.cpp index 167a2a37d..4a0ce5a37 100644 --- a/sources/utils/conductorcreator.cpp +++ b/sources/utils/conductorcreator.cpp @@ -95,6 +95,29 @@ void ConductorCreator::create(Diagram *d, const QPolygonF &polygon) } } +/** + @brief ConductorCreator::needsPotentialChoice + Whether creating a potential between these terminals would ask the user + to choose which of several existing potentials to inherit from -- that + is, whether the constructor would reach PotentialSelectorDialog. + + This exists for callers with nobody there to answer: the dialog is a + plain QDialog::exec(), not routed through QET::QetMessageBox, so its + non-interactive mode does not cover it and a headless caller would hang + on it indefinitely. Such a caller can check this first and decline. + Exposed here, rather than reimplemented by the caller, so the condition + cannot drift away from the one setUpPropertieToUse() actually applies. + @param terminals_list the terminals a potential would be created between + @return true if the constructor would open the dialog +*/ +bool ConductorCreator::needsPotentialChoice(const QList &terminals_list) +{ + if (terminals_list.size() <= 1) { + return false; + } + return existingPotential(terminals_list).size() >= 2; +} + /** @brief ConductorCreator::propertieToUse @return true if the caller should proceed with conductor creation, @@ -104,7 +127,7 @@ void ConductorCreator::create(Diagram *d, const QPolygonF &polygon) */ bool ConductorCreator::setUpPropertieToUse() { - QList potentials = existingPotential(); + QList potentials = existingPotential(m_terminals_list); //There is an existing potential //we get one of them @@ -145,14 +168,15 @@ bool ConductorCreator::setUpPropertieToUse() @brief ConductorCreator::existingPotential Return the list of existing potential of the terminal list + @param terminals_list the terminals to inspect @return c_list QList */ -QList ConductorCreator::existingPotential() +QList ConductorCreator::existingPotential(const QList &terminals_list) { QList c_list; QList t_exclude; - for (Terminal *t : m_terminals_list) + for (Terminal *t : terminals_list) { if (t_exclude.contains(t)) { continue; @@ -166,9 +190,9 @@ QList ConductorCreator::existingPotential() //in the same potential of c, and if true, exclude this terminal from the search. for (Conductor *c : t->conductors().first()->relatedPotentialConductors(false)) { - if (m_terminals_list.contains(c->terminal1)) { + if (terminals_list.contains(c->terminal1)) { t_exclude.append(c->terminal1); - } else if (m_terminals_list.contains(c->terminal2)) { + } else if (terminals_list.contains(c->terminal2)) { t_exclude.append(c->terminal2); } } diff --git a/sources/utils/conductorcreator.h b/sources/utils/conductorcreator.h index 515081b1f..ec287bddd 100644 --- a/sources/utils/conductorcreator.h +++ b/sources/utils/conductorcreator.h @@ -38,10 +38,11 @@ class ConductorCreator public: ConductorCreator(Diagram *d, QList terminals_list); static void create(Diagram *d, const QPolygonF &polygon); - + static bool needsPotentialChoice(const QList &terminals_list); + private: + static QList existingPotential(const QList &terminals_list); bool setUpPropertieToUse(); - QList existingPotential(); Terminal *hubTerminal();