From b8eb5c90a1f919f800419fdf0d0d6e228f70393a Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 17:24:11 +1300 Subject: [PATCH 1/9] Cross references at the folio bottom: stack only when texts overlap, add a checkbox (#1287 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stacking added in #1287 tested the crosses' bounding rects, so crosses whose frames or empty margins touched were raised although no text was hidden (the list display case found in review). The texts each cross really draws are now recorded while its geometry is built, and a cross is raised only when one of its texts would be drawn over a text of another one. A new "Empiler les références dont les textes se chevauchent" checkbox in the cross reference properties, per type, turns the stacking off; the crosses of that type are then only centred, as before #1287. It is saved in the project (xref attribute "stackoverlapping") and in the settings, and is on when absent. Co-Authored-By: Claude Opus 5.5 --- sources/properties/xrefproperties.cpp | 6 ++ sources/properties/xrefproperties.h | 4 + sources/qetgraphicsitem/crossrefitem.cpp | 106 +++++++++++++++-------- sources/qetgraphicsitem/crossrefitem.h | 5 ++ sources/ui/xrefpropertieswidget.cpp | 5 ++ sources/ui/xrefpropertieswidget.ui | 11 +++ 6 files changed, 101 insertions(+), 36 deletions(-) diff --git a/sources/properties/xrefproperties.cpp b/sources/properties/xrefproperties.cpp index 22fe44cea..e6ed7cd53 100644 --- a/sources/properties/xrefproperties.cpp +++ b/sources/properties/xrefproperties.cpp @@ -61,6 +61,7 @@ XRefProperties::XRefProperties() m_show_power_ctc = true; m_show_terminal_name = true; m_show_all_configured_slaves = false; + m_stack_overlapping = true; m_display = Cross; m_snap_to = Bottom; m_prefix_keys << "power" << "delay" << "switch"; @@ -83,6 +84,7 @@ void XRefProperties::toSettings(QSettings &settings, settings.setValue(prefix % "showpowerctc", m_show_power_ctc); settings.setValue(prefix % "showterminalname", m_show_terminal_name); settings.setValue(prefix % "showallconfiguredslaves", m_show_all_configured_slaves); + settings.setValue(prefix % "stackoverlapping", m_stack_overlapping); QString display = m_display == Cross? "cross" : "contacts"; settings.setValue(prefix % "displayhas", display); QString snap = m_snap_to == Bottom? "bottom" : "label"; @@ -117,6 +119,7 @@ void XRefProperties::fromSettings(const QSettings &settings, m_show_power_ctc = settings.value(prefix % "showpowerctc", true).toBool(); m_show_terminal_name = settings.value(prefix % "showterminalname", true).toBool(); m_show_all_configured_slaves = settings.value(prefix % "showallconfiguredslaves", false).toBool(); + m_stack_overlapping = settings.value(prefix % "stackoverlapping", true).toBool(); QString display = settings.value(prefix % "displayhas", "cross").toString(); display == "cross"? m_display = Cross : m_display = Contacts; QString snap = settings.value(prefix % "snapto", "label").toString(); @@ -148,6 +151,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const xml_element.setAttribute("showpowerctc", m_show_power_ctc? "true" : "false"); xml_element.setAttribute("showterminalname", m_show_terminal_name? "true" : "false"); xml_element.setAttribute("showallconfiguredslaves", m_show_all_configured_slaves? "true" : "false"); + xml_element.setAttribute("stackoverlapping", m_stack_overlapping? "true" : "false"); QString display = m_display == Cross? "cross" : "contacts"; xml_element.setAttribute("displayhas", display); QString snap = m_snap_to == Bottom? "bottom" : "label"; @@ -181,6 +185,7 @@ bool XRefProperties::fromXml(const QDomElement &xml_element) { m_show_power_ctc = xml_element.attribute("showpowerctc") == "true"; m_show_terminal_name = xml_element.attribute("showterminalname", "true") == "true"; m_show_all_configured_slaves = xml_element.attribute("showallconfiguredslaves", "false") == "true"; + m_stack_overlapping = xml_element.attribute("stackoverlapping", "true") == "true"; QString display = xml_element.attribute("displayhas", "cross"); display == "cross"? m_display = Cross : m_display = Contacts; QString snap = xml_element.attribute("snapto", "label"); @@ -228,6 +233,7 @@ bool XRefProperties::operator ==(const XRefProperties &xrp) const{ return (m_show_power_ctc == xrp.m_show_power_ctc && m_show_terminal_name == xrp.m_show_terminal_name && m_show_all_configured_slaves == xrp.m_show_all_configured_slaves + && m_stack_overlapping == xrp.m_stack_overlapping && m_display == xrp.m_display && m_snap_to == xrp.m_snap_to && m_prefix == xrp.m_prefix diff --git a/sources/properties/xrefproperties.h b/sources/properties/xrefproperties.h index e2faa015e..08dfeca02 100644 --- a/sources/properties/xrefproperties.h +++ b/sources/properties/xrefproperties.h @@ -63,6 +63,9 @@ class XRefProperties : public PropertiesInterface void setShowAllConfiguredSlaves (const bool a) {m_show_all_configured_slaves = a;} bool showAllConfiguredSlaves () const {return m_show_all_configured_slaves;} + void setStackOverlapping (const bool a) {m_stack_overlapping = a;} + bool stackOverlapping () const {return m_stack_overlapping;} + void setDisplayHas (const DisplayHas dh) {m_display = dh;} DisplayHas displayHas () const {return m_display;} @@ -92,6 +95,7 @@ class XRefProperties : public PropertiesInterface bool m_show_power_ctc; bool m_show_terminal_name; bool m_show_all_configured_slaves; + bool m_stack_overlapping; DisplayHas m_display; SnapTo m_snap_to; Qt::AlignmentFlag m_xref_pos; diff --git a/sources/qetgraphicsitem/crossrefitem.cpp b/sources/qetgraphicsitem/crossrefitem.cpp index fb710a968..33b17c463 100644 --- a/sources/qetgraphicsitem/crossrefitem.cpp +++ b/sources/qetgraphicsitem/crossrefitem.cpp @@ -268,6 +268,7 @@ void CrossRefItem::updateLabel() m_shape_path = QPainterPath(); prepareGeometryChange(); m_bounding_rect = QRectF(); + m_text_rects.clear(); // Build geometry and m_hovered_contacts_map using a QImage-backed // painter so font metrics match the screen painter in paint(). @@ -343,9 +344,12 @@ void CrossRefItem::autoPos() @brief CrossRefItem::stackAtBottom Places every cross reference of this folio that snaps to the bottom. Each one is centred under its element at the bottom of the folio, and - when two would overlap, the one of the higher element goes above the - other: several coils in one column get their crosses stacked in the - same order as the coils, instead of all on the same spot. + when its texts would be drawn over the texts of another one, the one + of the higher element goes above the other: several coils in one + column get their crosses stacked in the same order as the coils, + instead of all on the same spot. Crosses that only touch, or overlap + without any text over text, stay where they are. + The types whose properties do not ask for stacking are only centred. */ void CrossRefItem::stackAtBottom() { @@ -374,41 +378,56 @@ void CrossRefItem::stackAtBottom() return a->m_element->uuid() < b->m_element->uuid(); }); + struct Placed { QRectF rect; QList texts; }; const qreal gap = 5; - QList placed; + QList placed; for (CrossRefItem *xref : std::as_const(xrefs)) { const qreal offset = xref->m_properties.offset(); QGIUtility::centerToBottomDiagram(xref, xref->m_element, offset <= 40 ? 5 : offset); - if (xref->boundingRect().isEmpty()) continue; + if (xref->boundingRect().isEmpty() + || !xref->m_properties.stackOverlapping()) + continue; - // Move up past every cross already placed that it would overlap. - // A list of contacts has an empty margin in its bounding rect: - // only its content is tested, so side by side lists do not - // count as overlapping. - const bool list = xref->m_properties.displayHas() - == XRefProperties::Contacts; - QRectF rect = xref->sceneBoundingRect(); - if (list) - rect.adjust(list_margin_left, 0, -list_margin_right, 0); - const qreal bottom = rect.bottom(); + Placed self; + self.rect = xref->sceneBoundingRect(); + const QTransform to_scene = xref->sceneTransform(); + for (const QRectF &text : std::as_const(xref->m_text_rects)) + self.texts << to_scene.mapRect(text); + const qreal bottom = self.rect.bottom(); + + // Move up past every cross already placed whose texts it would + // draw over. Each move goes strictly up, so this ends. for (bool moved = true; moved; ) { moved = false; - for (const QRectF &other : std::as_const(placed)) - if (rect.left() < other.right() && other.left() < rect.right() - && rect.top() < other.bottom() + gap - && other.top() - gap < rect.bottom()) { - rect.moveBottom(other.top() - gap); - moved = true; - } + for (const Placed &other : std::as_const(placed)) { + const qreal dy = other.rect.top() - gap - self.rect.bottom(); + if (dy >= 0 || !textsOverlap(self.texts, other.texts)) + continue; + self.rect.translate(0, dy); + for (QRectF &text : self.texts) text.translate(0, dy); + moved = true; + } } - if (rect.bottom() != bottom) + if (self.rect.bottom() != bottom) xref->setPos(xref->parentItem()->mapFromScene( - xref->scenePos() + QPointF(0, rect.bottom() - bottom))); - placed << rect; + xref->scenePos() + QPointF(0, self.rect.bottom() - bottom))); + placed << self; } } +/** + @brief CrossRefItem::textsOverlap + @return true if one of the rects of a intersects one of the rects of b +*/ +bool CrossRefItem::textsOverlap(const QList &a, const QList &b) +{ + for (const QRectF &ra : a) + for (const QRectF &rb : b) + if (ra.intersects(rb)) return true; + return false; +} + /** @brief CrossRefItem::sceneEvent @param event @@ -1156,9 +1175,9 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in painter.setFont(QETApp::diagramTextsFont(4)); QRectF bt(0, offset, 24, 10); if (terminal_names.size() >= 1) - painter.drawText(bt, Qt::AlignLeft|Qt::AlignTop, terminal_names[0]); + drawText(painter, bt, Qt::AlignLeft|Qt::AlignTop, terminal_names[0]); if (terminal_names.size() >= 2) - painter.drawText(bt, Qt::AlignRight|Qt::AlignTop, terminal_names[1]); + drawText(painter, bt, Qt::AlignRight|Qt::AlignTop, terminal_names[1]); painter.setFont(QETApp::diagramTextsFont(5)); } @@ -1241,7 +1260,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in QRectF text_rect = painter.boundingRect(QRectF(30, offset, 5, 10), Qt::AlignLeft | Qt::AlignVCenter, str); if (!str.isEmpty()) { - painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); + drawText(painter, text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); bounding_rect = bounding_rect.united(text_rect); } @@ -1286,13 +1305,13 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in painter.setFont(QETApp::diagramTextsFont(4)); // Storage order set above: [0]=NC, [1]=NO, [2]=Common if (terminal_names.size() >= 2) - painter.drawText(QRectF(0, offset, 8, 8), + drawText(painter, QRectF(0, offset, 8, 8), Qt::AlignLeft|Qt::AlignTop, terminal_names[1]); // NO top-left if (terminal_names.size() >= 3) - painter.drawText(QRectF(16, offset+4, 8, 6), + drawText(painter, QRectF(16, offset+4, 8, 6), Qt::AlignRight|Qt::AlignTop, terminal_names[2]); // Common right if (terminal_names.size() >= 1) - painter.drawText(QRectF(0, offset+9, 8, 6), + drawText(painter, QRectF(0, offset+9, 8, 6), Qt::AlignLeft|Qt::AlignTop, terminal_names[0]); // NC bottom-left painter.setFont(QETApp::diagramTextsFont(5)); } @@ -1325,7 +1344,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in str); if (!str.isEmpty()) { - painter.drawText(text_rect, + drawText(painter, text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); bounding_rect = bounding_rect.united(text_rect); @@ -1361,7 +1380,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in str); if (!str.isEmpty()) { - painter.drawText(text_rect, + drawText(painter, text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); bounding_rect = bounding_rect.united(text_rect); @@ -1454,7 +1473,7 @@ void CrossRefItem::fillCrossRef(QPainter &painter) QSize(middle_cross, 1)), Qt::AlignLeft, str); - painter.drawText(bounding, Qt::AlignLeft, str); + drawText(painter, bounding, Qt::AlignLeft, str); if (m_update_map) { QString pos_str = elementPositionText(elmt, true); @@ -1534,7 +1553,7 @@ void CrossRefItem::fillCrossRef(QPainter &painter) QSize(middle_cross, 1)), Qt::AlignRight, str); - painter.drawText(bounding, Qt::AlignRight, str); + drawText(painter, bounding, Qt::AlignRight, str); if (m_update_map) { QString pos_str = elementPositionText(elmt, true); @@ -1582,7 +1601,7 @@ void CrossRefItem::AddExtraInfo(QPainter &painter, const QString& type) r, Qt::TextWordWrap | Qt::AlignHCenter, text); - painter.drawText(text_bounding, + drawText(painter, text_bounding, Qt::TextWordWrap | Qt::AlignHCenter, text); @@ -1598,6 +1617,21 @@ void CrossRefItem::AddExtraInfo(QPainter &painter, const QString& type) } } +/** + @brief CrossRefItem::drawText + Draws text in rect with painter, like QPainter::drawText. While the + geometry is built (m_update_map), also stores the rect really covered + by the text, so stackAtBottom() can tell texts drawn over each other. +*/ +void CrossRefItem::drawText(QPainter &painter, const QRectF &rect, + int flags, const QString &text) +{ + QRectF drawn; + painter.drawText(rect, flags, text, &drawn); + if (m_update_map && !text.isEmpty()) + m_text_rects << painter.transform().mapRect(drawn); +} + /** @brief CrossRefItem::NOElements @return The linked elements of m_element which are open or switch contact. diff --git a/sources/qetgraphicsitem/crossrefitem.h b/sources/qetgraphicsitem/crossrefitem.h index bb7068981..a16167d83 100644 --- a/sources/qetgraphicsitem/crossrefitem.h +++ b/sources/qetgraphicsitem/crossrefitem.h @@ -59,6 +59,8 @@ class CrossRefItem : public QGraphicsObject void init(); void setUpConnection(); void stackAtBottom(); + static bool textsOverlap(const QList &a, + const QList &b); public: enum { Type = UserType + 1009 }; @@ -138,6 +140,8 @@ class CrossRefItem : public QGraphicsObject const QStringList &master_labels = QStringList()); void fillCrossRef(QPainter &painter); void AddExtraInfo(QPainter &painter, const QString&); + void drawText(QPainter &painter, const QRectF &rect, int flags, + const QString &text); QList NOElements() const; QList NCElements() const; @@ -150,6 +154,7 @@ class CrossRefItem : public QGraphicsObject int m_drawed_contacts; bool m_update_map = false; QMultiMap m_hovered_contacts_map; + QList m_text_rects; //rects of the drawn texts (local coords) Element *m_hovered_contact = nullptr; DynamicElementTextItem *m_text = nullptr; ElementTextItemGroup *m_group = nullptr; diff --git a/sources/ui/xrefpropertieswidget.cpp b/sources/ui/xrefpropertieswidget.cpp index d08c8d5c7..911804491 100644 --- a/sources/ui/xrefpropertieswidget.cpp +++ b/sources/ui/xrefpropertieswidget.cpp @@ -146,6 +146,7 @@ void XRefPropertiesWidget::saveProperties(int index) { xrp.setShowPowerContac(ui->m_show_power_cb->isChecked()); xrp.setShowTerminalName(ui->m_show_terminal_name_cb->isChecked()); xrp.setShowAllConfiguredSlaves(ui->m_show_all_slaves_cb->isChecked()); + xrp.setStackOverlapping(ui->m_stack_overlapping_cb->isChecked()); xrp.setPrefix("power", ui->m_power_prefix_le->text()); xrp.setPrefix("delay", ui->m_delay_prefix_le->text()); xrp.setPrefix("switch", ui->m_switch_prefix_le->text()); @@ -201,6 +202,8 @@ void XRefPropertiesWidget::updateDisplay() ui->m_snap_to_cb->setCurrentIndex(ui->m_snap_to_cb->findData("label")); ui->m_offset_sb->setEnabled(false); } + ui->m_stack_overlapping_cb->setChecked(xrp.stackOverlapping()); + ui->m_stack_overlapping_cb->setEnabled(ui->m_offset_sb->isEnabled()); if(xrp.getXrefPos() == Qt::AlignTop) ui->m_xrefpos_cb->setCurrentIndex(ui->m_xrefpos_cb->findData("top")); else if(xrp.getXrefPos() == Qt::AlignLeft) ui->m_xrefpos_cb->setCurrentIndex(ui->m_xrefpos_cb->findData("left")); @@ -233,6 +236,7 @@ void XRefPropertiesWidget::updateDisplay() ui->m_display_has_cross_rb->setVisible(!is_plc); ui->m_show_terminal_name_cb->setVisible(!is_plc); ui->m_show_all_slaves_cb->setVisible(!is_plc); + ui->m_stack_overlapping_cb->setVisible(!is_plc); ui->m_cross_properties_gb->setVisible(!is_plc); } @@ -261,4 +265,5 @@ void XRefPropertiesWidget::enableOffsetSB(int i){ ui->m_offset_sb->setEnabled(false); else ui->m_offset_sb->setEnabled(true); + ui->m_stack_overlapping_cb->setEnabled(ui->m_offset_sb->isEnabled()); } diff --git a/sources/ui/xrefpropertieswidget.ui b/sources/ui/xrefpropertieswidget.ui index e79070539..0d33c7a27 100644 --- a/sources/ui/xrefpropertieswidget.ui +++ b/sources/ui/xrefpropertieswidget.ui @@ -89,6 +89,16 @@ + + + + + Quand les textes de deux références croisées se chevauchent, celle de l'élément le plus haut est placée au-dessus de l'autre. + + + Empiler les références dont les textes se chevauchent + + @@ -330,6 +340,7 @@ m_type_cb m_snap_to_cb m_offset_sb + m_stack_overlapping_cb m_xrefpos_cb m_display_has_contacts_rb m_display_has_cross_rb From 42b1e43e4ab19f201e88f4e377c07e6b3c7957b7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 18:41:58 +1300 Subject: [PATCH 2/9] Live mode: create a new project (new_project, qet_live_new_project) An assistant in live mode could only work on the project already open. new_project makes one as File > New does -- this installation's new-folio defaults, optional title and number of folios -- and makes it the current project, so the next request works on it. An optional absolute path saves it at once, never over an existing file: QElectroTech refuses, and the MCP server refuses before asking (a new never-overwrite class in its path policy, so an "overwrite" flag cannot get round it). Pages and links across pages already worked through scripts (qet.addFolio, setFolioTitle, linkElements across folios); the tool's description now says so. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 50 ++++++++++++++++++++++++++ misc/qet-mcp/test_qet_mcp.py | 40 +++++++++++++++++++-- sources/scripting/liveserver.cpp | 61 ++++++++++++++++++++++++++++++++ sources/scripting/liveserver.h | 1 + 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index df5c2a157..6ca809908 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -4085,6 +4085,22 @@ def tool_live_show_folio(folio: int) -> dict: return _live_call({"cmd": "show_folio", "folio": folio}) +def tool_live_new_project(title: str = "", folios: int = 1, path: str = "") -> dict: + _require_script_consent() + if not isinstance(folios, int) or isinstance(folios, bool) or not 1 <= folios <= 100: + raise ValueError("'folios' must be a whole number from 1 to 100") + if not isinstance(title, str) or not isinstance(path, str): + raise ValueError("'title' and 'path' must be text") + if path and not Path(path).expanduser().is_absolute(): + raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet") + request = {"cmd": "new_project", "folios": folios} + if title: + request["title"] = title + if path: + request["path"] = str(Path(path).expanduser()) + return _live_call(request) + + def tool_live_undo_last() -> dict: _require_script_consent() return _live_call({"cmd": "undo_last"}) @@ -5164,6 +5180,31 @@ TOOLS = [ }, "handler": lambda a: tool_live_show_folio(a["folio"]), }, + { + "name": "qet_live_new_project", + "description": "LIVE MODE. Create a new project in the QElectroTech the " + "user has open, as File > New does (this installation's " + "new-folio defaults), and make it the current project: every " + "following qet_live_* call works on it. Optional title, number " + "of empty folios (pages, 1-100) and an absolute path to save it " + "to at once -- never over an existing file. Then, in " + "qet_live_run_script: qet.addFolio() / qet.insertFolio(i) add " + "pages, qet.setFolioTitle(i, text) names them, and " + "qet.linkElements(folioA, a, folioB, b) links across pages -- " + "a going folio report arrow to a coming one, or a coil to its " + "contacts on another page.", + "inputSchema": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "the project's title"}, + "folios": {"type": "integer", "default": 1, "minimum": 1, "maximum": 100}, + "path": {"type": "string", + "description": "absolute .qet path to save to now; must not exist"}, + }, + }, + "handler": lambda a: tool_live_new_project(a.get("title", ""), a.get("folios", 1), + a.get("path", "")), + }, { "name": "qet_live_undo_last", "description": "LIVE MODE. Undo the newest step in the open project, only if " @@ -5298,6 +5339,9 @@ _DATA_PATHS = { "qet_check": {"read": ("project",)}, "qet_layout_check": {"read": ("project",)}, "qet_project_new": {"write": ("output",)}, + # QElectroTech writes this file, but where is the client's choice: + # held to the same workspace as every other file a tool writes. + "qet_live_new_project": {"write": ("path",)}, "qet_element_build": {"write": ("output",)}, # The scripts folder is chosen by scripts_dir(), never by the client, # so only the project a script is tried on is a data path here. @@ -5318,6 +5362,9 @@ _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} # Tools whose "overwrite" guards a file the server names itself (the # stored script, in scripts_dir()), not a client-chosen output path. _OVERWRITE_OWN_FILE = {"qet_script_install"} +# Tools that create a file and never replace one, whatever the client asks: +# no "overwrite" in their schema, and the flag is ignored if sent anyway. +_NEVER_OVERWRITE = {"qet_live_new_project"} # qet_edit operations that name a file of their own. _DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} @@ -5517,6 +5564,9 @@ def enforce_path_policy(tool_name: str, arguments: dict) -> None: # Writing over something that is already there is the one step this # server cannot undo, so it is the one step it will not take on its # own. qet_project_new already had this flag; the others now match it. + if out.exists() and tool_name in _NEVER_OVERWRITE: + raise ValueError(f"{arg!r} already exists: {out}. This tool only " + "creates new files; choose another name.") if out.exists() and not arguments.get("overwrite"): raise ValueError( f"{arg!r} already exists: {out}. Pass \"overwrite\": true to " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index b4524d7ed..37c68e53b 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -184,7 +184,7 @@ class ToolRegistry(unittest.TestCase): "qet_live_run_stored", "qet_live_command", "qet_live_show_folio", "qet_live_undo_last", "qet_live_screenshot", "qet_about", "qet_recording_list", "qet_recording_read", "qet_recording_check", - "qet_recording_remove", "qet_layout_check"}) + "qet_recording_remove", "qet_layout_check", "qet_live_new_project"}) class EditValidation(unittest.TestCase): @@ -2829,7 +2829,20 @@ class PathPolicy(unittest.TestCase): guarded = {name for name, spec in m._DATA_PATHS.items() if spec.get("write")} advertised = {t["name"] for t in m.TOOLS if "overwrite" in t["inputSchema"].get("properties", {})} - self.assertEqual(guarded, advertised - m._OVERWRITE_OWN_FILE) + self.assertEqual(guarded - m._NEVER_OVERWRITE, advertised - m._OVERWRITE_OWN_FILE) + self.assertFalse(advertised & m._NEVER_OVERWRITE) + + def test_never_overwrite_tools_ignore_the_flag(self): + with tempfile.TemporaryDirectory() as d: + target = Path(d) / "exists.qet" + target.write_text("x") + with mock.patch.dict(os.environ, {"QET_MCP_WORKSPACE": d}): + for args in ({"path": str(target)}, {"path": str(target), "overwrite": True}): + with self.assertRaisesRegex(ValueError, "only creates new files"): + m.enforce_path_policy("qet_live_new_project", args) + m.enforce_path_policy("qet_live_new_project", {"path": str(Path(d) / "new.qet")}) + with self.assertRaises(ValueError): + m.enforce_path_policy("qet_live_new_project", {"path": "/etc/new.qet"}) def test_every_data_path_argument_is_guarded(self): """The other direction: a tool whose schema takes a data path must be @@ -3533,6 +3546,29 @@ class LiveClient(unittest.TestCase): with self.assertRaises(ValueError): m.tool_live_show_folio("2") + def test_new_project_sends_only_what_was_given(self): + self.session() + m.tool_live_new_project() + m.tool_live_new_project("Pump station", 3, "/tmp/x/pump.qet") + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], + [{"cmd": "new_project", "folios": 1}, + {"cmd": "new_project", "folios": 3, "title": "Pump station", + "path": "/tmp/x/pump.qet"}]) + for bad in (0, 101, "2", True): + with self.assertRaises(ValueError): + m.tool_live_new_project(folios=bad) + with self.assertRaisesRegex(ValueError, "absolute"): + m.tool_live_new_project(path="pump.qet") + self.assertEqual(len(self.seen), 2) + + def test_new_project_needs_script_consent(self): + self.session() + with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): + with self.assertRaises(ValueError): + m.tool_live_new_project() + self.assertEqual(self.seen, []) + def test_stale_session_file(self): self.info({"socket": self.sock_path + "-gone", "token": "T0K"}) with self.assertRaisesRegex(ValueError, "could not reach"): diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 6eb679c18..8a72cbdf8 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -48,6 +48,9 @@ #include #include #include +#include +#include +#include #include #include #include "../projectview.h" @@ -251,6 +254,8 @@ void LiveServer::handle(const QJsonObject &request) answer = runStored(request.value(QStringLiteral("script")).toString()); } else if (cmd == QLatin1String("command")) { answer = command(request.value(QStringLiteral("action")).toString()); + } else if (cmd == QLatin1String("new_project")) { + answer = newProject(request); } else if (cmd == QLatin1String("show_folio")) { answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1)); } else if (cmd == QLatin1String("undo_last")) { @@ -441,6 +446,62 @@ QJsonObject LiveServer::showFolio(int folio) return {{QStringLiteral("ok"), true}, {QStringLiteral("folio"), folio}}; } +/** + @brief LiveServer::newProject + A new project, as File > New makes it: the new-folio defaults of this + QElectroTech, opened and made the current project, so the next script + works on it. Optional: "title", "folios" (how many empty folios, 1 to + 100, default 1), and "path" to save it to at once -- never over an + existing file, so an assistant cannot replace the user's work. +*/ +QJsonObject LiveServer::newProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + + const int folios = request.value(QStringLiteral("folios")).toInt(1); + if (folios < 1 || folios > 100) + return failure(QStringLiteral("folios must be between 1 and 100, not %1").arg(folios)); + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (!path.isEmpty()) { + const QFileInfo info(path); + if (info.isRelative()) + return failure(QStringLiteral("path must be absolute: %1").arg(path)); + if (info.exists()) + return failure(QStringLiteral("%1 already exists; a new project is never " + "saved over a file").arg(path)); + if (!info.dir().exists()) + return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath())); + } + + auto project = new QETProject(e); + for (int i = 0; i < folios; ++i) project->addNewDiagram(); + const QString title = request.value(QStringLiteral("title")).toString().trimmed(); + if (!title.isEmpty()) project->setTitle(title); + if (!e->addProject(project)) return failure(QStringLiteral("QElectroTech refused the new project")); + + //Current at once, whether or not QElectroTech is the active + //application: the next request runs on it (see editor()) + for (ProjectView *pv : e->openedProjects()) { + if (pv->project() == project) { + for (QMdiSubWindow *w : e->m_workspace.subWindowList()) + if (w->widget() == pv) e->m_workspace.setActiveSubWindow(w); + } + } + + if (!path.isEmpty()) { + project->setFilePath(path); + const QETResult result = project->write(); + if (!result.isOk()) + return failure(QStringLiteral("the project was created but not saved to %1: %2") + .arg(path, result.errorMessage())); + } + + QJsonObject answer = status(); + answer.insert(QStringLiteral("created"), true); + return answer; +} + /** @brief LiveServer::undoLast Undo the newest step, only if the assistant made it: what the user did diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 2a8726a88..27ec6ac34 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -84,6 +84,7 @@ class LiveServer : public QObject QJsonObject runStored(const QString &id); QJsonObject command(const QString &id); QJsonObject showFolio(int folio); + QJsonObject newProject(const QJsonObject &request); QJsonObject undoLast(); QJsonObject screenshot(); bool confirm(const QString &name, const QString &source); From ad20eb94ba4a30dcef766231ff1bd2acf4345696 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 19:00:13 +1300 Subject: [PATCH 3/9] Live mode: open a saved project, switch between open projects open_project opens a .qet and makes it current, as File > Open does but with no file dialog and no error windows: a missing, unreadable or foreign file is refused in the answer. A project already open is only made current. switch_project makes another open project current, by its index or its file. status now lists every open project (index, title, file, folios, unsaved changes, current), so the assistant sees what it can switch to. MCP: qet_live_open_project, qet_live_switch_project; both paths go through the workspace policy. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 56 ++++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 22 +++++++- sources/scripting/liveserver.cpp | 94 ++++++++++++++++++++++++++++++++ sources/scripting/liveserver.h | 4 ++ 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 6ca809908..3a5b80075 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -4101,6 +4101,29 @@ def tool_live_new_project(title: str = "", folios: int = 1, path: str = "") -> d return _live_call(request) +def tool_live_open_project(path: str) -> dict: + if not isinstance(path, str) or not path.strip(): + raise ValueError("'path' must be the project's file, e.g. /home/me/projects/pump.qet") + p = Path(path).expanduser() + if not p.is_absolute(): + raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet") + return _live_call({"cmd": "open_project", "path": str(p)}) + + +def tool_live_switch_project(index: int | None = None, path: str = "") -> dict: + if (index is None) == (not path): + raise ValueError("give exactly one of 'index' (from qet_live_status's " + "\"projects\") or 'path'") + if index is not None: + if not isinstance(index, int) or isinstance(index, bool) or index < 0: + raise ValueError("'index' must be a whole number counted from 0") + return _live_call({"cmd": "switch_project", "index": index}) + p = Path(path).expanduser() + if not p.is_absolute(): + raise ValueError("'path' must be absolute") + return _live_call({"cmd": "switch_project", "path": str(p)}) + + def tool_live_undo_last() -> dict: _require_script_consent() return _live_call({"cmd": "undo_last"}) @@ -5107,7 +5130,9 @@ TOOLS = [ "name": "qet_live_status", "description": "LIVE MODE. Ask the QElectroTech the user has open what is on " "screen: the project, the folio shown (index and title), the " - "selected elements, the last undo step and the stored scripts. " + "selected elements, the last undo step, the stored scripts, and " + "\"projects\": every project open in the window (index, title, " + "file, folios, unsaved changes, which is current). " "Works only if the user switched live mode on in QElectroTech " "and accepted its warning at this start; the error says which " "step is missing. Changes nothing.", @@ -5205,6 +5230,33 @@ TOOLS = [ "handler": lambda a: tool_live_new_project(a.get("title", ""), a.get("folios", 1), a.get("path", "")), }, + { + "name": "qet_live_open_project", + "description": "LIVE MODE. Open a saved project (.qet, absolute path) in the " + "QElectroTech the user has open and make it current, as File > " + "Open does but with no dialog; errors come back as text. A " + "project already open is only made current. Every following " + "qet_live_* call works on it.", + "inputSchema": { + "type": "object", + "properties": {"path": {"type": "string", "description": "absolute .qet path"}}, + "required": ["path"], + }, + "handler": lambda a: tool_live_open_project(a["path"]), + }, + { + "name": "qet_live_switch_project", + "description": "LIVE MODE. Make another project that is already open the " + "current one, by its index in qet_live_status's \"projects\" or " + "by its file path. Every following qet_live_* call works on it. " + "Changes no project.", + "inputSchema": { + "type": "object", + "properties": {"index": {"type": "integer", "minimum": 0}, + "path": {"type": "string"}}, + }, + "handler": lambda a: tool_live_switch_project(a.get("index"), a.get("path", "")), + }, { "name": "qet_live_undo_last", "description": "LIVE MODE. Undo the newest step in the open project, only if " @@ -5342,6 +5394,8 @@ _DATA_PATHS = { # QElectroTech writes this file, but where is the client's choice: # held to the same workspace as every other file a tool writes. "qet_live_new_project": {"write": ("path",)}, + "qet_live_open_project": {"read": ("path",)}, + "qet_live_switch_project": {"read": ("path",)}, "qet_element_build": {"write": ("output",)}, # The scripts folder is chosen by scripts_dir(), never by the client, # so only the project a script is tried on is a data path here. diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 37c68e53b..e0ba86bdc 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -184,7 +184,8 @@ class ToolRegistry(unittest.TestCase): "qet_live_run_stored", "qet_live_command", "qet_live_show_folio", "qet_live_undo_last", "qet_live_screenshot", "qet_about", "qet_recording_list", "qet_recording_read", "qet_recording_check", - "qet_recording_remove", "qet_layout_check", "qet_live_new_project"}) + "qet_recording_remove", "qet_layout_check", "qet_live_new_project", + "qet_live_open_project", "qet_live_switch_project"}) class EditValidation(unittest.TestCase): @@ -3562,6 +3563,25 @@ class LiveClient(unittest.TestCase): m.tool_live_new_project(path="pump.qet") self.assertEqual(len(self.seen), 2) + def test_open_and_switch_send_only_what_was_given(self): + self.session() + m.tool_live_open_project("/tmp/x/pump.qet") + m.tool_live_switch_project(1) + m.tool_live_switch_project(path="/tmp/x/pump.qet") + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], + [{"cmd": "open_project", "path": "/tmp/x/pump.qet"}, + {"cmd": "switch_project", "index": 1}, + {"cmd": "switch_project", "path": "/tmp/x/pump.qet"}]) + for bad in ({}, {"index": 0, "path": "/a.qet"}, {"index": -1}, + {"index": True}, {"path": "rel.qet"}): + with self.assertRaises(ValueError): + m.tool_live_switch_project(**bad) + for bad in ("", "rel.qet"): + with self.assertRaises(ValueError): + m.tool_live_open_project(bad) + self.assertEqual(len(self.seen), 3) + def test_new_project_needs_script_consent(self): self.session() with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 8a72cbdf8..f1f87a659 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -256,6 +256,10 @@ void LiveServer::handle(const QJsonObject &request) answer = command(request.value(QStringLiteral("action")).toString()); } else if (cmd == QLatin1String("new_project")) { answer = newProject(request); + } else if (cmd == QLatin1String("open_project")) { + answer = openProject(request.value(QStringLiteral("path")).toString()); + } else if (cmd == QLatin1String("switch_project")) { + answer = switchProject(request); } else if (cmd == QLatin1String("show_folio")) { answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1)); } else if (cmd == QLatin1String("undo_last")) { @@ -318,6 +322,7 @@ QJsonObject LiveServer::status() {QStringLiteral("name"), s.header.name}}); answer.insert(QStringLiteral("stored_scripts"), scripts); //So an assistant can notice a recording the user just made + answer.insert(QStringLiteral("projects"), openProjects()); answer.insert(QStringLiteral("macro_recorder"), QJsonObject{ {QStringLiteral("recording"), MacroRecorder::instance().isRecording()}, {QStringLiteral("steps"), MacroRecorder::instance().stepCount()}, @@ -502,6 +507,95 @@ QJsonObject LiveServer::newProject(const QJsonObject &request) return answer; } +/** + @brief LiveServer::openProjects + The projects open in the editor window, in tab order: the index + switch_project takes, title, file, folios, unsaved changes, and which + one is current. +*/ +QJsonArray LiveServer::openProjects() const +{ + QJsonArray list; + QETDiagramEditor *e = editor(); + if (!e) return list; + const QETProject *current = e->currentProject(); + const QList views = e->openedProjects(); + for (int i = 0; i < views.count(); ++i) { + QETProject *p = views.at(i)->project(); + list.append(QJsonObject{ + {QStringLiteral("index"), i}, + {QStringLiteral("title"), p->title()}, + {QStringLiteral("path"), p->filePath()}, + {QStringLiteral("folios"), int(p->diagrams().count())}, + {QStringLiteral("modified"), p->projectWasModified()}, + {QStringLiteral("read_only"), p->isReadOnly()}, + {QStringLiteral("current"), p == current}}); + } + return list; +} + +/** + @brief LiveServer::openProject + Open a saved project and make it current, as File > Open does but with + no file dialog and no error windows: what is wrong comes back in the + answer. A project already open is only made current. +*/ +QJsonObject LiveServer::openProject(const QString &raw_path) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QString path = raw_path.trimmed(); + if (path.isEmpty()) return failure(QStringLiteral("no path given")); + const QFileInfo info(path); + if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path)); + if (!info.isFile()) return failure(QStringLiteral("%1 does not exist").arg(path)); + if (!info.isReadable()) return failure(QStringLiteral("%1 cannot be read (permissions)").arg(path)); + + QJsonObject extra{{QStringLiteral("opened"), true}}; + if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) { + e->activateProject(pv); + extra = {{QStringLiteral("opened"), false}, {QStringLiteral("already_open"), true}}; + } else if (QETApp::diagramEditorForFile(info.absoluteFilePath())) { + return failure(QStringLiteral("%1 is open in another QElectroTech window; " + "switch to it there").arg(path)); + } else { + if (!e->openAndAddProject(info.absoluteFilePath(), false)) + return failure(QStringLiteral("QElectroTech could not open %1 (not a " + "QElectroTech project, or a newer format)").arg(path)); + if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) e->activateProject(pv); + } + QJsonObject answer = status(); + for (auto it = extra.begin(); it != extra.end(); ++it) answer.insert(it.key(), it.value()); + return answer; +} + +/** + @brief LiveServer::switchProject + Make another open project current, by its index in "projects" (status) + or by its file path. +*/ +QJsonObject LiveServer::switchProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QList views = e->openedProjects(); + ProjectView *target = nullptr; + if (request.contains(QStringLiteral("index"))) { + const int index = request.value(QStringLiteral("index")).toInt(-1); + if (index < 0 || index >= views.count()) + return failure(QStringLiteral("no open project %1: %2 are open, counted from 0") + .arg(index).arg(views.count())); + target = views.at(index); + } else { + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (path.isEmpty()) return failure(QStringLiteral("give \"index\" (from \"projects\") or \"path\"")); + target = e->viewForFile(QFileInfo(path).absoluteFilePath()); + if (!target) return failure(QStringLiteral("%1 is not open; open_project opens it").arg(path)); + } + e->activateProject(target); + return status(); +} + /** @brief LiveServer::undoLast Undo the newest step, only if the assistant made it: what the user did diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 27ec6ac34..93ba325fa 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -18,6 +18,7 @@ #ifndef LIVESERVER_H #define LIVESERVER_H +#include #include #include #include @@ -85,6 +86,9 @@ class LiveServer : public QObject QJsonObject command(const QString &id); QJsonObject showFolio(int folio); QJsonObject newProject(const QJsonObject &request); + QJsonObject openProject(const QString &path); + QJsonObject switchProject(const QJsonObject &request); + QJsonArray openProjects() const; QJsonObject undoLast(); QJsonObject screenshot(); bool confirm(const QString &name, const QString &source); From 2fb48e791b2d4b930956b473f31f10162ba6eff7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 19:15:19 +1300 Subject: [PATCH 4/9] Live mode: open_project never stops on the backup question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openAndAddProject() asked "Créer une copie de sauvegarde ?" even when called non-interactively, so open_project waited for an answer from the user in front of the window. Only live mode opens projects that way. Co-Authored-By: Claude Opus 5.5 --- sources/qetdiagrameditor.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 41c8e82e5..5c51490f2 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1856,8 +1856,10 @@ bool QETDiagramEditor::openAndAddProject( box.exec(); } + //Not when nobody is there to answer: an assistant opening a + //project in live mode would wait on a window it cannot see BackupDialog backup_dialog(this); - if (backup_dialog.exec() == QDialog::Accepted) + if (interactive && backup_dialog.exec() == QDialog::Accepted) { QString backup_path = filepath_info.absolutePath() + QDir::separator() + QDateTime::currentDateTime().toString("yyyy-MM-dd-hh-mm") + "_" + From 0df8ca5b3cfc8c1a29e60dfedf9a3b794ff1d148 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 19:36:34 +1300 Subject: [PATCH 5/9] Live mode: print, save/close, changes, snapshot; house-style layout rules; saved symbol index QElectroTech (LiveServer): - print: folios to the default (or a named) printer with no print dialog, always after asking the user (paper cannot be undone); or to a new PDF file. Rendered as the PDF export draws a folio (no grid, no terminals). - save_project (own file, or save as a new file, never over one) and close_project (only without unsaved changes: never discards work). - changes: the undo history, each step marked assistant or user, with "since" to ask what changed after a point. - snapshot: a copy of the open project as on screen, for a check to read, without saving it or changing its file. - qet.elementTextGeometry(): where a symbol's text is drawn on the folio. qet MCP server: - qet_live_print, qet_live_save_project, qet_live_close_project, qet_live_changes, qet_live_layout_check (snapshot + qet_layout_check, no "run this script?" window); write paths never overwrite. - Layout rules from the house style, for file and live checks alike: label_on_wire, four_way_junction, misaligned_branch. - The element index is saved under ~/.cache/qet-mcp: a new server process loads 8839 symbols in 0.4 s instead of parsing them for 6.9 s; the change check is a third faster. - qet_about lists the installation's standard symbols (standard-symbols.json in QElectroTech's data folder) when there are any. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 407 ++++++++++++++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 146 ++++++++++- sources/scripting/liveserver.cpp | 237 +++++++++++++++++ sources/scripting/liveserver.h | 5 + sources/scripting/qetscriptapi.cpp | 19 ++ sources/scripting/qetscriptapi.h | 2 + 6 files changed, 806 insertions(+), 10 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 3a5b80075..29c3ca812 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -2402,17 +2402,71 @@ _SECONDARY_FOLDER = re.compile(r"(?:^|/)\d+_(?:graphics|manufacturers_articles|m def _collection_signature(root: Path): - """Cheap change detector: file count and newest mtime, no parsing.""" + """Cheap change detector: file count and newest mtime, no parsing. + os.scandir() rather than Path.rglob(): the same answer in about a third + of the time, which every search pays.""" count, newest = 0, 0.0 - for f in root.rglob("*.elmt"): - count += 1 + stack = [str(root)] + while stack: try: - newest = max(newest, f.stat().st_mtime) + it = os.scandir(stack.pop()) except OSError: - pass + continue + with it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + stack.append(entry.path) + elif entry.name.endswith(".elmt"): + count += 1 + newest = max(newest, entry.stat().st_mtime) + except OSError: + pass return count, newest +def _index_cache_file(root: Path) -> Path: + """Where the parsed index of one collection is kept between runs.""" + base = os.environ.get("QET_MCP_CACHE_DIR") or os.path.join( + os.environ.get("XDG_CACHE_HOME") or os.path.join(str(Path.home()), ".cache"), "qet-mcp") + import hashlib + digest = hashlib.sha1(str(root).encode("utf-8")).hexdigest()[:16] + return Path(base) / f"element-index-{digest}.json" + + +_INDEX_CACHE_FORMAT = 1 + + +def _load_index_cache(root: Path, sig) -> list | None: + try: + data = json.loads(_index_cache_file(root).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if (data.get("format") != _INDEX_CACHE_FORMAT or data.get("root") != str(root) + or tuple(data.get("sig") or ()) != tuple(sig)): + return None + items = data.get("items") or [] + for it in items: + it["haystack"] = frozenset(it.get("haystack") or ()) + return items + + +def _save_index_cache(root: Path, sig, items: list) -> None: + """Best effort: a cache that cannot be written only costs the next + start its 6 s; it never fails a search.""" + path = _index_cache_file(root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps({ + "format": _INDEX_CACHE_FORMAT, "root": str(root), "sig": list(sig), + "items": [dict(it, haystack=sorted(it["haystack"])) for it in items]}), + encoding="utf-8") + os.replace(tmp, path) + except OSError: + pass + + def _index_collection(root: Path) -> list: """Parse every .elmt under root once and keep what a search needs. @@ -2426,6 +2480,10 @@ def _index_collection(root: Path) -> list: cached = _ELEMENT_INDEX.get(key) if cached and cached["sig"] == sig: return cached["items"] + items = _load_index_cache(root.resolve(), sig) + if items is not None: + _ELEMENT_INDEX[key] = {"sig": sig, "items": items} + return items items = [] for f in sorted(root.rglob("*.elmt")): @@ -2457,6 +2515,7 @@ def _index_collection(root: Path) -> list: "haystack": frozenset(_search_words(" ".join([*names.values(), rel, kind]))), }) _ELEMENT_INDEX[key] = {"sig": sig, "items": items} + _save_index_cache(root.resolve(), sig, items) return items @@ -2768,6 +2827,27 @@ LAYOUT_RULES = { "are on. An axis a straight wire lines it up on is left alone. " "\"fix\" moves it onto the grid.", }, + "label_on_wire": { + "severity": "warning", + "note": "A symbol's label is drawn over a wire. House style: a label sits at " + "least 20 px past the outermost connected terminal, on a side no " + "wire arrives from. Move the label (setElementTextProperty x/y), not " + "the symbol. Needs a QElectroTech with elementTextGeometry(); older " + "builds skip this rule.", + }, + "four_way_junction": { + "severity": "warning", + "note": "Wires leave one point in all four directions: a 4-way dot, which " + "reads as a crossing when printed. Stagger it into two 3-way " + "T-junctions a grid step or more apart.", + }, + "misaligned_branch": { + "severity": "info", + "note": "Two symbols stacked in the same side branch (each fed by a wire with " + "a horizontal run) are a little out of line, so the branch is not one " + "straight column. House style: parallel branches share one vertical " + "axis. \"dx\" is how far apart their wire docks are.", + }, "crossing": { "severity": "info", "note": "Two wires cross. Counted so two drafts can be compared; some " @@ -2784,13 +2864,25 @@ _LAYOUT_SEG = re.compile(r"^\s*\d+:\s*\(([^,]+),([^)]+)\)-\(([^,]+),([^)]+)\)") _LAYOUT_JS = r""" var only = @FOLIO@; var byUuid = typeof qet.conductorPath === 'function'; +function labelBox(f, u) { + if (typeof qet.elementTextGeometry !== 'function') return null; + var t = qet.elementTexts(f, u); + for (var i = 0; i < t.length; i++) + if (qet.elementTextProperty(f, u, i, 'source') === 'info' + && qet.elementTextProperty(f, u, i, 'info') === 'label') { + var g = qet.elementTextGeometry(f, u, i); + return (g && g.right > g.left && qet.elementTextProperty(f, u, i, 'shows')) ? g : null; + } + return null; +} for (var f = 0; f < qet.folioCount(); f++) { if (only >= 0 && f !== only) continue; var els = qet.elementUuids(f), E = []; for (var i = 0; i < els.length; i++) { E.push({uuid: els[i], name: qet.elementName(f, els[i]), label: qet.elementLabel(f, els[i]), g: qet.elementGeometry(f, els[i]), - terminals: qet.elementTerminals(f, els[i]).length}); + terminals: qet.elementTerminals(f, els[i]).length, + labelbox: labelBox(f, els[i])}); } var cu = qet.conductorUuids(f), lines = qet.conductors(f), C = []; for (var j = 0; j < cu.length; j++) { @@ -2951,7 +3043,9 @@ def _layout_folio(data: dict, max_shift: float) -> dict: "annotation": int(el.get("terminals", 1) or 0) == 0, "x": float(g.get("x", 0)), "y": float(g.get("y", 0)), "xy": (float(g.get("x", 0)), float(g.get("y", 0))), - "box": box, "docks": []} + "box": box, "docks": [], + "labelbox": _box(el.get("labelbox") or {}) + if el.get("labelbox") else None} wire_count = {} wires, unread = [], [] for w in data.get("conductors") or []: @@ -3214,6 +3308,8 @@ def _layout_folio(data: dict, max_shift: float) -> dict: add("crossing", conductors=[w["uuid"], v["uuid"]], count=n, note=LAYOUT_RULES["crossing"]["note"]) + _house_style_rules(symbols, wires, add, dirty_symbols) + return {"folio": folio, "symbols": len(symbols), "wires": len(wires), "unread": unread, "findings": findings, "dirty_wires": dirty_wires, "dirty_symbols": dirty_symbols, "length": length, "crossings": crossings, @@ -3221,6 +3317,86 @@ def _layout_folio(data: dict, max_shift: float) -> dict: "fixes": [move_op(u) for u in move]} +def _house_style_rules(symbols: dict, wires: list, add, dirty_symbols: set) -> None: + """The house-style rules: labels over wires, 4-way junctions, side + branches out of line. Pure geometry, like the rest of the scorer.""" + def seg_hits_box(a, b, box, inset=1.0): + l, t, r, btm = box[0] + inset, box[1] + inset, box[2] - inset, box[3] - inset + if l >= r or t >= btm: + return False + (x1, y1), (x2, y2) = a, b + if abs(y1 - y2) < 1e-6: # horizontal + return t <= y1 <= btm and min(x1, x2) <= r and max(x1, x2) >= l + if abs(x1 - x2) < 1e-6: # vertical + return l <= x1 <= r and min(y1, y2) <= btm and max(y1, y2) >= t + return False + + for s in symbols.values(): + lb = s.get("labelbox") + if not lb: + continue + hit = [w["uuid"] for w in wires + if any(seg_hits_box(a, b, lb) for a, b in zip(w["pts"], w["pts"][1:]))] + if hit: + add("label_on_wire", element=s["uuid"], label=s["label"], name=s["name"], + conductors=hit, note=LAYOUT_RULES["label_on_wire"]["note"]) + dirty_symbols.add(s["uuid"]) + + def key(p): + return (round(p[0]), round(p[1])) + + def direction(a, b): + dx, dy = b[0] - a[0], b[1] - a[1] + if abs(dx) < 1e-6 and abs(dy) < 1e-6: + return None + if abs(dx) >= abs(dy): + return "e" if dx > 0 else "w" + return "s" if dy > 0 else "n" + + ways, owners = {}, {} + for w in wires: + pts = w["pts"] + for i, p in enumerate(pts): + for q in ((pts[i - 1],) if i else ()) + ((pts[i + 1],) if i + 1 < len(pts) else ()): + d = direction(p, q) + if d: + ways.setdefault(key(p), set()).add(d) + owners.setdefault(key(p), set()).add(w["uuid"]) + for pt, d in sorted(ways.items()): + if len(d) == 4 and len(owners[pt]) >= 2: + add("four_way_junction", at=list(pt), conductors=sorted(owners[pt]), + note=LAYOUT_RULES["four_way_junction"]["note"]) + + # A side branch symbol is fed only by wires that jog sideways; one + # joined to a neighbour by a straight vertical wire is in a column. + side, column = set(), set() + for w in wires: + xs = {round(p[0], 3) for p in w["pts"]} + ends = [e for e in w["ends"] if e] + if len(xs) == 1: + column.update(ends) + elif any(abs(a[1] - b[1]) < 1e-6 and abs(a[0] - b[0]) > 1e-6 + for a, b in zip(w["pts"], w["pts"][1:])): + side.update(ends) + side -= column + cand = [s for s in symbols.values() if s["uuid"] in side and s["docks"] + and not s["annotation"]] + # Two symbols joined by a wire of their own are avoidable_bend's case. + joined = {frozenset(w["ends"]) for w in wires if all(w["ends"])} + for i, s in enumerate(cand): + for t in cand[i + 1:]: + sb, tb = s["box"], t["box"] + if sb[1] < tb[3] and tb[1] < sb[3]: + continue # side by side, not stacked + if frozenset((s["uuid"], t["uuid"])) in joined: + continue + dx = min(abs(p[0] - q[0]) for p in s["docks"] for q in t["docks"]) + if 0.5 < dx <= 80: + add("misaligned_branch", elements=[s["uuid"], t["uuid"]], + labels=[s["label"], t["label"]], names=[s["name"], t["name"]], + dx=round(dx, 1), note=LAYOUT_RULES["misaligned_branch"]["note"]) + + def _layout_answer(folios: list, style: str, limit: int) -> dict: """Combine per-folio results into the tool's answer.""" symbols = sum(f["symbols"] for f in folios) @@ -3253,6 +3429,9 @@ def _layout_answer(folios: list, style: str, limit: int) -> dict: "overlaps": count["overlapping_symbols"], "off_grid": count["off_grid"], "crossings": sum(f["crossings"] for f in folios), + "labels_on_wires": count["label_on_wire"], + "four_way_junctions": count["four_way_junction"], + "misaligned_branches": count["misaligned_branch"], "flow": {"vertical": round(vertical / total, 3) if total else 0.0, "horizontal": round(horizontal / total, 3) if total else 0.0}, }, @@ -3669,6 +3848,36 @@ SERVER_INSTRUCTIONS = ( "a wire is straight only when its two terminals are exactly in line.") +def standard_symbols_file(info: dict | None = None) -> Path: + """The installation's standard symbols: one symbol per device role, + chosen by the user. QET_MCP_STANDARD_SYMBOLS if set, else + standard-symbols.json in QElectroTech's data folder.""" + explicit = os.environ.get("QET_MCP_STANDARD_SYMBOLS", "").strip() + if explicit: + return Path(explicit).expanduser() + data = ((info or {}).get("folders") or {}).get("data") + return (Path(data) if data else assistant_info_file().parent) / "standard-symbols.json" + + +def standard_symbols(info: dict | None = None): + """The roles from standard-symbols.json, or None when there is none. + A file that cannot be read is reported, not hidden.""" + path = standard_symbols_file(info) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + return {"error": f"{path} could not be read: {exc}"} + roles = data.get("roles") if isinstance(data, dict) else None + if not isinstance(roles, list): + return {"error": f"{path} has no \"roles\" list"} + return {"file": str(path), "updated": data.get("updated"), + "roles": [{k: r.get(k) for k in ("id", "label", "letter", "path", + "terminals", "note") if r.get(k) is not None} + for r in roles if isinstance(r, dict) and r.get("path")]} + + def tool_about() -> dict: """What this server and the QElectroTech it works with look like now.""" info = assistant_info() @@ -3701,6 +3910,9 @@ def tool_about() -> dict: "script_api": info.get("script_api"), # Never the token: it is for the live tools, not the conversation. "live": {"open": bool(live), "pid": (live or {}).get("pid")}, + # One symbol per device role, the user's own choice: use these + # paths with add_element / qet.addElement before searching. + "standard_symbols": standard_symbols(info), }) return out @@ -4124,6 +4336,100 @@ def tool_live_switch_project(index: int | None = None, path: str = "") -> dict: return _live_call({"cmd": "switch_project", "path": str(p)}) +def _abs_path(value, arg: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"'{arg}' must be a file path") + p = Path(value).expanduser() + if not p.is_absolute(): + raise ValueError(f"'{arg}' must be absolute, e.g. /home/me/projects/pump.qet") + return str(p) + + +def _index_arg(index): + if index is not None and (not isinstance(index, int) or isinstance(index, bool) or index < 0): + raise ValueError("'index' must be a whole number counted from 0") + return index + + +def tool_live_save_project(path: str = "") -> dict: + _require_script_consent() + request = {"cmd": "save_project"} + if path: + request["path"] = _abs_path(path, "path") + return _live_call(request) + + +def tool_live_close_project(index: int | None = None) -> dict: + _require_script_consent() + request = {"cmd": "close_project"} + if _index_arg(index) is not None: + request["index"] = index + return _live_call(request) + + +def tool_live_print(folios="all", printer: str = "", output_file: str = "") -> dict: + _require_script_consent() + if isinstance(folios, list): + if not folios or not all(isinstance(i, int) and not isinstance(i, bool) and i >= 0 + for i in folios): + raise ValueError("'folios' as a list holds folio indexes counted from 0") + elif folios not in ("all", "current"): + raise ValueError("'folios' must be \"all\", \"current\" or a list of indexes") + if printer and output_file: + raise ValueError("give 'printer' or 'output_file', not both") + request = {"cmd": "print", "folios": folios} + if printer: + request["printer"] = printer + if output_file: + request["output_file"] = _abs_path(output_file, "output_file") + # The user answers QElectroTech's question before anything prints. + return _live_call(request, timeout=300) + + +def tool_live_changes(since: int | None = None) -> dict: + request = {"cmd": "changes"} + if since is not None: + if not isinstance(since, int) or isinstance(since, bool) or since < -1: + raise ValueError("'since' must be a step index from an earlier answer (or -1)") + request["since"] = since + return _live_call(request) + + +def tool_live_layout_check(folio: str | int = "current", style: str = "auto", + max_shift: float = 40, limit: int = 50, + elements_dir: str | None = None, timeout: int = 180, + binary: str | None = None) -> dict: + """qet_layout_check on what the user sees: QElectroTech writes a copy of + the open project (unsaved changes included) to a private folder, the + check reads the copy, the copy is deleted. The user's project and file + are not touched, and no "run this script?" window is shown.""" + binary = binary or resolve_binary() + if not binary: + raise ValueError("no QElectroTech binary to run the check with: set QET_BINARY") + work = Path(tempfile.mkdtemp(prefix="qet-live-check-")) + try: + snap = _live_call({"cmd": "snapshot", "path": str(work / "live.qet")}) + if not snap.get("ok"): + return snap + if folio == "current": + folio_no = snap.get("folio", -1) + 1 or None + elif folio == "all": + folio_no = None + elif isinstance(folio, int) and not isinstance(folio, bool) and folio >= 1: + folio_no = folio + else: + raise ValueError("'folio' is \"current\", \"all\" or a folio number counted from 1") + answer = tool_layout_check(str(binary), snap["path"], folio=folio_no, style=style, + max_shift=max_shift, limit=limit, + elements_dir=elements_dir, timeout=timeout) + answer["checked"] = "the open project, as on screen (unsaved changes included)" + # The moves in "fixes" are for qet_edit on a file; live, apply them + # with qet_live_run_script (qet.moveElement), one undo step. + return answer + finally: + shutil.rmtree(work, ignore_errors=True) + + def tool_live_undo_last() -> dict: _require_script_consent() return _live_call({"cmd": "undo_last"}) @@ -5257,6 +5563,84 @@ TOOLS = [ }, "handler": lambda a: tool_live_switch_project(a.get("index"), a.get("path", "")), }, + { + "name": "qet_live_save_project", + "description": "LIVE MODE. Save the current project to its own file, or with " + "'path' save it as a new file (absolute; never over an existing " + "file), which then becomes its file.", + "inputSchema": {"type": "object", + "properties": {"path": {"type": "string"}}}, + "handler": lambda a: tool_live_save_project(a.get("path", "")), + }, + { + "name": "qet_live_close_project", + "description": "LIVE MODE. Close an open project (the current one, or 'index' " + "from qet_live_status's \"projects\") -- only when it has no " + "unsaved changes: closing never discards work, so save it first.", + "inputSchema": {"type": "object", + "properties": {"index": {"type": "integer", "minimum": 0}}}, + "handler": lambda a: tool_live_close_project(a.get("index")), + }, + { + "name": "qet_live_print", + "description": "LIVE MODE. Print folios of the current project without the " + "print dialog: 'folios' is \"all\" (default), \"current\" or a " + "list of indexes from 0; 'printer' names a printer, else the " + "computer's default printer. QElectroTech always asks the user " + "before printing (paper cannot be taken back); \"refused by the " + "user\" means they said no. 'output_file' (absolute .pdf, must " + "not exist) prints to a PDF file instead, with no question.", + "inputSchema": { + "type": "object", + "properties": { + "folios": {"oneOf": [{"type": "string", "enum": ["all", "current"]}, + {"type": "array", "items": {"type": "integer", "minimum": 0}}]}, + "printer": {"type": "string"}, + "output_file": {"type": "string"}, + }, + }, + "handler": lambda a: tool_live_print(a.get("folios", "all"), a.get("printer", ""), + a.get("output_file", "")), + }, + { + "name": "qet_live_changes", + "description": "LIVE MODE. The current project's undo history: each step's name, " + "whether the assistant or the user made it, and whether it is " + "undone; 'since' (a step index from an earlier answer, e.g. its " + "\"now\") keeps only the later steps -- what changed since then. " + "Changes nothing.", + "inputSchema": {"type": "object", + "properties": {"since": {"type": "integer", "minimum": -1}}}, + "handler": lambda a: tool_live_changes(a.get("since")), + }, + { + "name": "qet_live_layout_check", + "description": "LIVE MODE. qet_layout_check on the drawing the user has open, " + "as on screen (unsaved changes included), without saving it: " + "straight wires, symbols in line and on the grid, overlaps, " + "and the house-style rules -- labels over wires, 4-way junction " + "dots, side branches out of line. Run it before saying a " + "drawing is finished. 'folio': \"current\" (default), \"all\" or " + "a number from 1. Apply \"fixes\" with qet_live_run_script " + "(qet.moveElement), not qet_edit. Changes nothing.", + "inputSchema": { + "type": "object", + "properties": { + "folio": {"oneOf": [{"type": "string", "enum": ["current", "all"]}, + {"type": "integer", "minimum": 1}]}, + "style": {"type": "string", "enum": list(LAYOUT_STYLES)}, + "max_shift": {"type": "number", "default": 40}, + "limit": {"type": "integer", "default": 50}, + "elements_dir": {"type": "string"}, + "binary": {"type": "string", "description": "the qelectrotech executable " + "that runs the check; leave it out to use this server's"}, + }, + }, + "handler": lambda a: tool_live_layout_check(a.get("folio", "current"), + a.get("style", "auto"), + a.get("max_shift", 40), a.get("limit", 50), + a.get("elements_dir"), binary=a.get("binary")), + }, { "name": "qet_live_undo_last", "description": "LIVE MODE. Undo the newest step in the open project, only if " @@ -5396,6 +5780,11 @@ _DATA_PATHS = { "qet_live_new_project": {"write": ("path",)}, "qet_live_open_project": {"read": ("path",)}, "qet_live_switch_project": {"read": ("path",)}, + "qet_live_save_project": {"write": ("path",)}, + "qet_live_print": {"write": ("output_file",)}, + # Runs QElectroTech on a copy QElectroTech writes to a private folder: + # no client path, but the binary and elements_dir rules apply. + "qet_live_layout_check": {}, "qet_element_build": {"write": ("output",)}, # The scripts folder is chosen by scripts_dir(), never by the client, # so only the project a script is tried on is a data path here. @@ -5408,7 +5797,7 @@ _DATA_PATHS = { # Tools that launch QElectroTech, and so take "binary" and "elements_dir". _LAUNCHES_QET = {"qet_export", "qet_edit", "qet_query", "qet_continuity", "qet_check", "qet_layout_check", "qet_project_new", "qet_script_api", - "qet_script_test", "qet_recording_check"} + "qet_script_test", "qet_recording_check", "qet_live_layout_check"} # Tools that launch QElectroTech only when given this argument. _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} @@ -5418,7 +5807,7 @@ _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} _OVERWRITE_OWN_FILE = {"qet_script_install"} # Tools that create a file and never replace one, whatever the client asks: # no "overwrite" in their schema, and the flag is ignored if sent anyway. -_NEVER_OVERWRITE = {"qet_live_new_project"} +_NEVER_OVERWRITE = {"qet_live_new_project", "qet_live_save_project", "qet_live_print"} # qet_edit operations that name a file of their own. _DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index e0ba86bdc..9cd59ab32 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -185,7 +185,9 @@ class ToolRegistry(unittest.TestCase): "qet_live_undo_last", "qet_live_screenshot", "qet_about", "qet_recording_list", "qet_recording_read", "qet_recording_check", "qet_recording_remove", "qet_layout_check", "qet_live_new_project", - "qet_live_open_project", "qet_live_switch_project"}) + "qet_live_open_project", "qet_live_switch_project", "qet_live_save_project", + "qet_live_close_project", "qet_live_print", "qet_live_changes", + "qet_live_layout_check"}) class EditValidation(unittest.TestCase): @@ -3582,6 +3584,48 @@ class LiveClient(unittest.TestCase): m.tool_live_open_project(bad) self.assertEqual(len(self.seen), 3) + def test_save_close_print_changes_send_only_what_was_given(self): + self.session() + m.tool_live_save_project() + m.tool_live_save_project("/tmp/x/as.qet") + m.tool_live_close_project() + m.tool_live_close_project(2) + m.tool_live_print() + m.tool_live_print([0, 2], printer="HP") + m.tool_live_print("current", output_file="/tmp/x/out.pdf") + m.tool_live_changes() + m.tool_live_changes(4) + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], [ + {"cmd": "save_project"}, {"cmd": "save_project", "path": "/tmp/x/as.qet"}, + {"cmd": "close_project"}, {"cmd": "close_project", "index": 2}, + {"cmd": "print", "folios": "all"}, + {"cmd": "print", "folios": [0, 2], "printer": "HP"}, + {"cmd": "print", "folios": "current", "output_file": "/tmp/x/out.pdf"}, + {"cmd": "changes"}, {"cmd": "changes", "since": 4}]) + bad = [lambda: m.tool_live_save_project("rel.qet"), + lambda: m.tool_live_close_project(-1), + lambda: m.tool_live_close_project(True), + lambda: m.tool_live_print("some"), + lambda: m.tool_live_print([]), + lambda: m.tool_live_print([0, -1]), + lambda: m.tool_live_print(printer="HP", output_file="/tmp/a.pdf"), + lambda: m.tool_live_print(output_file="out.pdf"), + lambda: m.tool_live_changes(-2)] + for call in bad: + with self.assertRaises(ValueError): + call() + self.assertEqual(len(self.seen), 9) + + def test_writing_live_tools_need_script_consent(self): + self.session() + with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): + for call in (m.tool_live_save_project, m.tool_live_close_project, + m.tool_live_print): + with self.assertRaises(ValueError): + call() + self.assertEqual(self.seen, []) + def test_new_project_needs_script_consent(self): self.session() with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): @@ -3593,6 +3637,106 @@ class LiveClient(unittest.TestCase): self.info({"socket": self.sock_path + "-gone", "token": "T0K"}) with self.assertRaisesRegex(ValueError, "could not reach"): m.tool_live_status() +class HouseStyleLayoutRules(unittest.TestCase): + """The house-style rules of the layout check, on made-up geometry.""" + + def el(self, uuid, x, y, w=20, h=40, label="", labelbox=None): + return {"uuid": uuid, "name": uuid, "label": label, "terminals": 2, + "g": {"x": x, "y": y, "left": x - w / 2, "top": y - h / 2, + "right": x + w / 2, "bottom": y + h / 2}, "labelbox": labelbox} + + def wire(self, uuid, a, b, path): + return {"uuid": uuid, "ends": [a + " terminal 0", b + " terminal 1"], + "path": [{"x": x, "y": y} for x, y in path]} + + def rules(self, data): + return sorted(f["rule"] for f in m._layout_folio(dict(folio=0, **data), 40)["findings"] + if f["rule"] in ("label_on_wire", "four_way_junction", "misaligned_branch")) + + def test_label_over_a_wire_and_clear_of_it(self): + els = [self.el("a", 100, 100, label="-K1", + labelbox={"left": 95, "top": 150, "right": 120, "bottom": 160}), + self.el("b", 100, 300)] + over = [self.wire("w", "a", "b", [(100, 120), (100, 280)])] + self.assertEqual(self.rules({"elements": els, "conductors": over}), ["label_on_wire"]) + els[0]["labelbox"] = {"left": 120, "top": 150, "right": 145, "bottom": 160} + self.assertEqual(self.rules({"elements": els, "conductors": over}), []) + + def test_four_way_dot_but_not_a_t(self): + els = [self.el(k, x, y) for k, x, y in + (("n", 200, 100), ("s", 200, 300), ("e", 300, 200), ("w", 100, 200))] + four = [self.wire("1", "n", "s", [(200, 120), (200, 200), (200, 280)]), + self.wire("2", "w", "e", [(110, 200), (200, 200), (290, 200)])] + # Two straight wires through one point have no vertex there... + self.assertEqual(self.rules({"elements": els, "conductors": four}), []) + star = [self.wire("1", "n", "s", [(200, 120), (200, 200)]), + self.wire("2", "s", "n", [(200, 280), (200, 200)]), + self.wire("3", "w", "e", [(110, 200), (200, 200)]), + self.wire("4", "e", "w", [(290, 200), (200, 200)])] + self.assertEqual(self.rules({"elements": els, "conductors": star}), ["four_way_junction"]) + self.assertEqual(self.rules({"elements": els, "conductors": star[:3]}), []) + + def test_side_branch_out_of_line_like_the_motor_starter(self): + # main column at x 500, branch symbols at 580 (hold-in) and 660 (lamp) + els = [self.el("s2", 500, 470), self.el("hold", 580, 470), self.el("coil", 500, 580), + self.el("lamp", 660, 580)] + wires = [self.wire("1", "s2", "hold", [(500, 450), (580, 450)]), + self.wire("2", "coil", "lamp", [(500, 560), (660, 560)]), + self.wire("3", "s2", "coil", [(500, 490), (500, 560)])] + self.assertEqual(self.rules({"elements": els, "conductors": wires}), ["misaligned_branch"]) + els[3] = self.el("lamp", 580, 580) + wires[1] = self.wire("2", "coil", "lamp", [(500, 560), (580, 560)]) + self.assertEqual(len(wires), 3) + self.assertEqual(self.rules({"elements": els, "conductors": wires}), []) + + +class StandardSymbols(unittest.TestCase): + def test_absent_listed_and_broken(self): + with tempfile.TemporaryDirectory() as d: + f = Path(d) / "standard-symbols.json" + with mock.patch.dict(os.environ, {"QET_MCP_STANDARD_SYMBOLS": str(f)}): + self.assertIsNone(m.standard_symbols()) + f.write_text(json.dumps({"updated": "2026-10-05", "roles": [ + {"id": "coil", "label": "Coil", "letter": "K", "terminals": ["A1", "A2"], + "path": "common://10_electric/x/bobine3.elmt", "score": 99}, + {"id": "none_fit", "label": "Nothing", "path": None}]})) + got = m.standard_symbols() + self.assertEqual(got["roles"], [{"id": "coil", "label": "Coil", "letter": "K", + "path": "common://10_electric/x/bobine3.elmt", + "terminals": ["A1", "A2"]}]) + f.write_text("{not json") + self.assertIn("could not be read", m.standard_symbols()["error"]) + + def test_default_location_is_qet_data_folder(self): + with mock.patch.dict(os.environ, {"QET_MCP_STANDARD_SYMBOLS": ""}): + self.assertEqual(m.standard_symbols_file({"folders": {"data": "/x/data"}}), + Path("/x/data/standard-symbols.json")) + + +class ElementIndexCache(unittest.TestCase): + def test_second_process_reads_the_cache_and_a_change_rebuilds(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) / "elements" + root.mkdir() + elmt = ('' + '{}' + '') + (root / "a.elmt").write_text(elmt.format("Alpha coil")) + with mock.patch.dict(os.environ, {"QET_MCP_CACHE_DIR": str(Path(d) / "cache")}): + m._ELEMENT_INDEX.clear() + first = m._index_collection(root) + self.assertTrue(m._index_cache_file(root.resolve()).is_file()) + m._ELEMENT_INDEX.clear() # a new server process + with mock.patch.object(m.ET, "parse", side_effect=AssertionError("parsed")): + again = m._index_collection(root) + self.assertEqual([i["path"] for i in again], [i["path"] for i in first]) + self.assertEqual(again[0]["haystack"], first[0]["haystack"]) + (root / "b.elmt").write_text(elmt.format("Beta lamp")) + m._ELEMENT_INDEX.clear() + self.assertEqual(len(m._index_collection(root)), 2) + m._ELEMENT_INDEX.clear() + + class AssistantInfoFile(unittest.TestCase): """qet-assistant.json: QElectroTech says where things are; the server believes it over its own per-platform guess.""" diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index f1f87a659..1939b61a0 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -23,6 +23,7 @@ #include "scriptlibrary.h" #include "../diagram.h" #include "../diagramview.h" +#include "../qet.h" #include "../qetapp.h" #include "../qetdiagrameditor.h" #include "../qetproject.h" @@ -51,6 +52,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include "../projectview.h" @@ -260,6 +265,16 @@ void LiveServer::handle(const QJsonObject &request) answer = openProject(request.value(QStringLiteral("path")).toString()); } else if (cmd == QLatin1String("switch_project")) { answer = switchProject(request); + } else if (cmd == QLatin1String("save_project")) { + answer = saveProject(request); + } else if (cmd == QLatin1String("close_project")) { + answer = closeProject(request); + } else if (cmd == QLatin1String("print")) { + answer = print(request); + } else if (cmd == QLatin1String("snapshot")) { + answer = snapshot(request.value(QStringLiteral("path")).toString()); + } else if (cmd == QLatin1String("changes")) { + answer = changes(request); } else if (cmd == QLatin1String("show_folio")) { answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1)); } else if (cmd == QLatin1String("undo_last")) { @@ -596,6 +611,228 @@ QJsonObject LiveServer::switchProject(const QJsonObject &request) return status(); } +/** + @brief LiveServer::saveProject + Save the current project to its own file, or, with "path", to a new + file that becomes its file (Save As) -- never over an existing one. +*/ +QJsonObject LiveServer::saveProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + if (project->isReadOnly()) return failure(QStringLiteral("the project is read-only")); + + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (path.isEmpty()) { + if (project->filePath().isEmpty()) + return failure(QStringLiteral("the project has no file yet: give \"path\" to save it as one")); + } else { + const QFileInfo info(path); + if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path)); + if (info.exists()) + return failure(QStringLiteral("%1 already exists; save as never replaces a file").arg(path)); + if (!info.dir().exists()) + return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath())); + project->setFilePath(info.absoluteFilePath()); + } + const QETResult result = project->write(); + if (!result.isOk()) return failure(QStringLiteral("not saved: %1").arg(result.errorMessage())); + QJsonObject answer = status(); + answer.insert(QStringLiteral("saved"), project->filePath()); + return answer; +} + +/** + @brief LiveServer::closeProject + Close an open project (the current one, or "index"), only when it has + no unsaved changes: closing never discards the user's work, and never + asks them a question on the assistant's behalf. +*/ +QJsonObject LiveServer::closeProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QList views = e->openedProjects(); + ProjectView *pv = nullptr; + if (request.contains(QStringLiteral("index"))) { + const int index = request.value(QStringLiteral("index")).toInt(-1); + if (index < 0 || index >= views.count()) + return failure(QStringLiteral("no open project %1: %2 are open, counted from 0") + .arg(index).arg(views.count())); + pv = views.at(index); + } else { + pv = e->currentProjectView(); + if (!pv) return failure(QStringLiteral("no project is open in QElectroTech")); + } + QETProject *project = pv->project(); + if (project->projectWasModified()) + return failure(QStringLiteral("\"%1\" has unsaved changes; save it first " + "(save_project) -- closing never discards them") + .arg(project->title())); + const QString title = project->title(); + if (!e->closeProject(pv)) return failure(QStringLiteral("QElectroTech did not close \"%1\"").arg(title)); + QJsonObject answer = status(); + answer.insert(QStringLiteral("closed"), title); + return answer; +} + +/** + @brief LiveServer::print + Print folios of the current project with no print dialog: "folios" is + "all" (default), "current" or a list of indexes; "printer" names one, + else the system's default printer. Paper cannot be taken back, so the + user is always asked first, whatever "always" says for scripts. + "output_file" prints to a new PDF file instead (no paper, so no + question; never over an existing file). +*/ +QJsonObject LiveServer::print(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + ProjectView *pv = e ? e->currentProjectView() : nullptr; + if (!pv) return failure(QStringLiteral("no project is open in QElectroTech")); + QETProject *project = pv->project(); + const QList all = project->diagrams(); + + QList folios; + const QJsonValue which = request.value(QStringLiteral("folios")); + if (which.isUndefined() || which.toString() == QLatin1String("all")) { + folios = all; + } else if (which.toString() == QLatin1String("current")) { + if (DiagramView *dv = e->currentDiagramView()) folios << dv->diagram(); + } else if (which.isArray()) { + for (const QJsonValue &v : which.toArray()) { + const int i = v.toInt(-1); + if (i < 0 || i >= all.count()) + return failure(QStringLiteral("no folio %1: the project has %2, counted from 0") + .arg(v.toVariant().toString()).arg(all.count())); + folios << all.at(i); + } + } else { + return failure(QStringLiteral("folios must be \"all\", \"current\" or a list of indexes")); + } + if (folios.isEmpty()) return failure(QStringLiteral("no folio to print")); + + const QString file = request.value(QStringLiteral("output_file")).toString().trimmed(); + QPrinterInfo info; + if (file.isEmpty()) { + const QString name = request.value(QStringLiteral("printer")).toString().trimmed(); + info = name.isEmpty() ? QPrinterInfo::defaultPrinter() : QPrinterInfo::printerInfo(name); + if (info.isNull()) + return failure(name.isEmpty() + ? QStringLiteral("this computer has no default printer") + : QStringLiteral("no printer called \"%1\" (printers: %2)") + .arg(name, QPrinterInfo::availablePrinterNames().join(QStringLiteral(", ")))); + QMessageBox box(QMessageBox::Question, tr("Impression"), + tr("L'assistant veut imprimer %n folio(s) de « %1 » sur « %2 ».", "", int(folios.count())) + .arg(project->title(), info.printerName()), + QMessageBox::NoButton, e); + QPushButton *go = box.addButton(tr("&Imprimer"), QMessageBox::AcceptRole); + box.addButton(tr("&Annuler"), QMessageBox::RejectRole); + box.setDefaultButton(go); + box.exec(); + if (box.clickedButton() != go) return failure(QStringLiteral("refused by the user")); + } else { + const QFileInfo fi(file); + if (fi.isRelative()) return failure(QStringLiteral("output_file must be absolute: %1").arg(file)); + if (fi.exists()) return failure(QStringLiteral("%1 already exists; printing never replaces a file").arg(file)); + } + + QPrinter printer = file.isEmpty() ? QPrinter(info, QPrinter::HighResolution) + : QPrinter(QPrinter::HighResolution); + if (!file.isEmpty()) { + printer.setOutputFormat(QPrinter::PdfFormat); + printer.setOutputFileName(file); + } + printer.setDocName(project->title().isEmpty() ? QStringLiteral("QElectroTech") : project->title()); + const auto folioRect = [](Diagram *d) { + QRectF r = d->border_and_titleblock.borderAndTitleBlockRect(); + r.adjust(0, 0, 1, 1); + return r.toAlignedRect(); + }; + //One orientation for the job: some printer drivers ignore a change + //between pages. The first folio decides. + const QRect first = folioRect(folios.first()); + printer.setPageOrientation(first.width() > first.height() ? QPageLayout::Landscape + : QPageLayout::Portrait); + QPainter painter; + if (!painter.begin(&printer)) return failure(QStringLiteral("the printer could not be opened")); + for (int i = 0; i < folios.count(); ++i) { + Diagram *d = folios.at(i); + if (i) printer.newPage(); + //As the PDF export draws a folio: no grid, guides or terminals + const bool grid = d->displayGrid(), guides = d->displayGuides(); + const bool terms = d->drawTerminals(), names = d->drawTerminalNames(); + d->setDisplayGrid(false); d->setDisplayGuides(false); + d->setDrawTerminals(false); d->setDrawTerminalNames(false); + d->render(&painter, printer.pageLayout().paintRectPixels(printer.resolution()), + folioRect(d), Qt::KeepAspectRatio); + d->setDisplayGrid(grid); d->setDisplayGuides(guides); + d->setDrawTerminals(terms); d->setDrawTerminalNames(names); + } + painter.end(); + return {{QStringLiteral("ok"), true}, {QStringLiteral("printed"), int(folios.count())}, + {QStringLiteral("to"), file.isEmpty() ? info.printerName() : file}}; +} + +/** + @brief LiveServer::snapshot + Write a copy of the current project, as it is on screen (unsaved + changes included), to a new file -- for a check to read -- without + saving the project or changing its file. Never over an existing file. +*/ +QJsonObject LiveServer::snapshot(const QString &raw_path) +{ + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + const QFileInfo info(raw_path.trimmed()); + if (raw_path.trimmed().isEmpty() || info.isRelative()) + return failure(QStringLiteral("path must be absolute")); + if (info.exists()) return failure(QStringLiteral("%1 already exists").arg(raw_path)); + QDomDocument xml(project->toXml()); + QString error; + if (!QET::writeXmlFile(xml, info.absoluteFilePath(), &error)) + return failure(QStringLiteral("snapshot not written: %1").arg(error)); + return {{QStringLiteral("ok"), true}, {QStringLiteral("path"), info.absoluteFilePath()}, + {QStringLiteral("folio"), e->currentDiagramView() + ? int(project->diagrams().indexOf(e->currentDiagramView()->diagram())) : -1}}; +} + +/** + @brief LiveServer::changes + The current project's undo history: each step's name, whether the + assistant made it, and whether it is undone. "since" (an index from an + earlier answer) keeps only the steps after it, so an assistant can say + what it changed since a point. Changes nothing. +*/ +QJsonObject LiveServer::changes(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + QUndoStack *stack = project->undoStack(); + const QString prefix = tr("Assistant : %1").arg(QString()); + const int since = request.value(QStringLiteral("since")).toInt(-1); + QJsonArray steps; + int assistant = 0, user = 0; + for (int i = qMax(0, since + 1); i < stack->count(); ++i) { + const QString text = stack->text(i); + const bool mine = text.startsWith(prefix); + const bool undone = i >= stack->index(); + if (!undone) (mine ? assistant : user)++; + steps.append(QJsonObject{{QStringLiteral("index"), i}, {QStringLiteral("step"), text}, + {QStringLiteral("by"), mine ? QStringLiteral("assistant") + : QStringLiteral("user")}, + {QStringLiteral("undone"), undone}}); + } + return {{QStringLiteral("ok"), true}, {QStringLiteral("project"), project->title()}, + {QStringLiteral("steps"), steps}, + {QStringLiteral("now"), stack->index() - 1}, + {QStringLiteral("saved_at"), stack->cleanIndex() - 1}, + {QStringLiteral("done_by_assistant"), assistant}, {QStringLiteral("done_by_user"), user}}; +} + /** @brief LiveServer::undoLast Undo the newest step, only if the assistant made it: what the user did diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 93ba325fa..882d86bbc 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -89,6 +89,11 @@ class LiveServer : public QObject QJsonObject openProject(const QString &path); QJsonObject switchProject(const QJsonObject &request); QJsonArray openProjects() const; + QJsonObject saveProject(const QJsonObject &request); + QJsonObject closeProject(const QJsonObject &request); + QJsonObject print(const QJsonObject &request); + QJsonObject changes(const QJsonObject &request); + QJsonObject snapshot(const QString &path); QJsonObject undoLast(); QJsonObject screenshot(); bool confirm(const QString &name, const QString &source); diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 0de2b7497..78c0833d1 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -3489,6 +3489,25 @@ bool QetScriptApi::deleteImage(int folioIndex, int imageIndex) return true; } +/** + @brief QetScriptApi::elementTextGeometry + Where one of a symbol's text fields is drawn on the folio, in folio + coordinates: left, top, right, bottom of the text as shown. A label + that sits on a wire is visible here, not from x/y alone (x/y are + relative to the symbol and say nothing about the text's size). + @return an empty map when the text does not exist +*/ +QVariantMap QetScriptApi::elementTextGeometry(int folioIndex, const QString &elementUuid, + int textIndex) const +{ + DynamicElementTextItem *t = findElementText(folioIndex, elementUuid, textIndex, + QStringLiteral("elementTextGeometry")); + if (!t) return {}; + const QRectF r = t->sceneBoundingRect(); + return {{QStringLiteral("left"), r.left()}, {QStringLiteral("top"), r.top()}, + {QStringLiteral("right"), r.right()}, {QStringLiteral("bottom"), r.bottom()}}; +} + /** @brief QetScriptApi::addPdfPage Render one page of a PDF file to an image and place it, as the "add diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 572168a0f..6b9e6bb9b 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -556,6 +556,8 @@ class QetScriptApi : public QObject Q_INVOKABLE QString elementTextProperty(int folioIndex, const QString &elementUuid, int textIndex, const QString &property) const; Q_INVOKABLE bool deleteElementText(int folioIndex, const QString &elementUuid, int textIndex); + Q_INVOKABLE QVariantMap elementTextGeometry(int folioIndex, const QString &elementUuid, + int textIndex) const; // -- copy elements (with the conductors between them) to a position -- Q_INVOKABLE QStringList duplicateElements(int fromFolioIndex, const QStringList &elementUuids, From 0bb0ea225f29a3f8132b6775b281413e2cce82bf Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 20:18:26 +1300 Subject: [PATCH 6/9] Live mode print: draw at 96 dpi, as the PDF export does At the printer's own resolution (600-1200 dpi) symbols and texts were replayed many times too big next to the wires and grid. Seen on a test print to PDF; the same page now matches qet_export's PDF. Co-Authored-By: Claude Opus 5.5 --- sources/scripting/liveserver.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 1939b61a0..1fbbba119 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -738,8 +738,11 @@ QJsonObject LiveServer::print(const QJsonObject &request) if (fi.exists()) return failure(QStringLiteral("%1 already exists; printing never replaces a file").arg(file)); } - QPrinter printer = file.isEmpty() ? QPrinter(info, QPrinter::HighResolution) - : QPrinter(QPrinter::HighResolution); + QPrinter printer = file.isEmpty() ? QPrinter(info) : QPrinter(); + //96 dpi, as the PDF export draws: symbols are replayed at the + //device's resolution, so at a printer's 600-1200 dpi they came out + //many times too big next to the wires (seen on a test print) + printer.setResolution(96); if (!file.isEmpty()) { printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(file); From 76d9ab2911ee4c830e3be8274b3d5e54beffc32a Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 20:33:19 +1300 Subject: [PATCH 7/9] Live mode: English for the print question Co-Authored-By: Claude Opus 5.5 --- lang/qet_en.ts | 240 +++++++++++++++++++++++++++---------------------- 1 file changed, 132 insertions(+), 108 deletions(-) diff --git a/lang/qet_en.ts b/lang/qet_en.ts index 55b13f184..bb95c6732 100644 --- a/lang/qet_en.ts +++ b/lang/qet_en.ts @@ -7185,12 +7185,12 @@ Please use the advanced editor for this. LiveServer - + Mode direct Live mode - + Le mode direct est activé : un assistant IA connecté pourra exécuter des scripts sur le projet ouvert. Chaque action s'annule d'un Ctrl+Z, et le bouton « Arrêter » de la barre d'état coupe la connexion. @@ -7203,53 +7203,77 @@ Each action is undone with one Ctrl+Z, and the “Stop” button in the status b This setting is in Configure QElectroTech > General > Projects. - + &Continuer &Continue - + &Pas pour cette session &Not this session - + &Désactiver &Turn off - - + + script script - + + Impression + Print + + + + L'assistant veut imprimer %n folio(s) de « %1 » sur « %2 ». + + The assistant wants to print %n folio of “%1” on “%2”. + The assistant wants to print %n folios of “%1” on “%2”. + + + + + &Imprimer + &Print + + + + &Annuler + &Cancel + + + + Assistant : %1 Assistant: %1 - + L'assistant veut exécuter un script The assistant wants to run a script - + « %1 » sur le projet ouvert. Une fois exécuté, Ctrl+Z l'annule. “%1” on the open project. Once it has run, Ctrl+Z undoes it. - + &Exécuter &Run - + &Refuser &Decline - + &Toujours pour cette session &Always this session @@ -7765,90 +7789,90 @@ Do you still want to link this slave contact? NewElementWizard - + Vous n'êtes pas obligé de préciser l'extension *.elmt. Elle sera ajoutée automatiquement. You don't have to specify the *.elmt extension. It will be added automatically. - + nouvel_element new_element - + &Suivant > &Next > - + Créer un nouvel élément : Assistant window title Create a new element: wizard - + Étape 1/3 : Catégorie parente wizard page title Step 1 of 3: Parent category - + Sélectionnez une catégorie dans laquelle enregistrer le nouvel élément. wizard page subtitle Select a category which to save the new element in. - + Étape 2/3 : Nom du fichier wizard page title Step 2 of 3: Filename - + Indiquez le nom du fichier dans lequel enregistrer le nouvel élément. wizard page subtitle Enter the name of the file for the new element. - + Étape 3/3 : Noms de l'élément wizard page title Step 3 of 3: Element names - + Indiquez le ou les noms de l'élément. wizard page subtitle Enter one or more names for the element. - + Nom du nouvel élément default name when creating a new element New element name - - - + + + Erreur message box title Error - + Vous devez sélectionner une catégorie. message box content You must select a category. - + Vous devez entrer un nom de fichier message box content You must enter a filename - + Un élément portant le même nom existe déjà An element with the same name already exists @@ -9812,7 +9836,7 @@ Available options: - + &Fermer &Close @@ -9874,8 +9898,8 @@ Available options: - - + + Supprimer le folio Delete this sheet @@ -10152,7 +10176,7 @@ Available options: Depth - + Groupe Group @@ -10364,7 +10388,7 @@ Available options: - + Échec de l'ouverture du projet message box title Unable to open project @@ -10377,8 +10401,8 @@ Available options: - - + + QElectroTech window title QElectroTech @@ -10648,8 +10672,8 @@ Hold Ctrl while moving to place freely. - - + + Recharger les dessins des éléments Reload element drawings @@ -11027,7 +11051,7 @@ Hold Ctrl while moving to place freely. - + Scripts Scripts @@ -11105,19 +11129,19 @@ If you save the project, these conductors will disappear from the file. Close it - + Suppression de borne impossible Unable to delete the terminal - + La suppression ne peut être effectué car la selection possède une ou plusieurs bornes ponté et/ou appartenant à une borne à niveau multiple. Déponter et/ou supprimer les niveaux des bornes concerné afin de pouvoir les supprimer The deletion cannot be performed because the selection contains one or more terminals that are bridged and/or belong to a multi-level terminal. Unbridge and/or remove the levels from the affected terminals so that they can be deleted - + %n objet(s) remis sur la grille %n item put back on the grid @@ -11125,7 +11149,7 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + %n objet(s) aligné(s) %n item aligned @@ -11133,22 +11157,22 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + La sélection est déjà sur la grille The selection is already on the grid - + Sélectionnez au moins deux éléments, images, textes, formes ou groupes non verrouillés Select at least two unlocked elements, images, texts, shapes or groups - + La sélection est déjà alignée, à la grille près The selection is already aligned, to the nearest grid step - + (%n objet(s) verrouillé(s) laissé(s) en place) (%n locked object left in place) @@ -11156,100 +11180,100 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + Éditer l'élement edit element Edit the element - + Éditer le champ de texte edit text field Edit the text field - + Éditer l'image edit image Edit the image - + Éditer le conducteur edit conductor Edit conductor - + Éditer l'objet sélectionné edit selected item Edit the selected object - + Une erreur est survenue lors de l'ouverture du fichier %1. message box content An error occurred while opening file %1. - + Active le projet « %1 » Activates the project « %1 » - + Êtes-vous sûr de vouloir supprimer ce folio ? Are you sure you want to delete this sheet? - + Supprimer les folios Delete the sheets - + Êtes-vous sûr de vouloir supprimer les %1 folios sélectionnés ? Are you sure you want to delete the selected %1 sheets ? - + Supprimer %1 folios Delete %1 sheets - - - - - - - + + + + + + + Déplacer les folios Move the sheets - + Erreur message box title Error - + Répéter : insérer « %1 » Repeat: insert "%1" - + Répéter : %1 Repeat: %1 - + folio %1 sheet %1 - + %n élément(s) redessiné(s). %n element redrawn. @@ -11257,7 +11281,7 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + %n élément(s) dont la définition est introuvable ou illisible : leur dessin actuel a été conservé. %n element for which no definition could be found or which were illegible: their current design has been retained. @@ -11265,7 +11289,7 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + %n élément(s) non redessiné(s) : leur taille, leur point de saisie ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée). %n element not redrawn: its size, grip point or terminals have changed (a terminal was added, removed or moved). @@ -11273,18 +11297,18 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + Pour les mettre à jour, il faut les supprimer puis les réinsérer. Attention : cette opération supprime les conducteurs déjà reliés à ces éléments, qu'il faudra retracer. To update them, they must be deleted then reinserted. Warning: this operation removes the conductors already connected to these elements, which will need to be redrawn. - - + + Exécuter un script Run a script - + Les scripts sont désactivés. Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. N'exécutez que des scripts dont vous connaissez l'origine. @@ -11297,28 +11321,28 @@ A script runs with your permissions: it can read and modify the open project and Enable scripts? This setting can be changed in Configure QElectroTech > General > Projects. - + Scripts JavaScript (*.js);;Tous les fichiers (*) JavaScript scripts (*.js);;All files (*) - + Ignoré : %1 Ignored: %1 - - + + Arrêter Stop - + Arrêter l'enregistrement de la macro Stop recording the macro - + ● Enregistrement : %n étape(s) ● Recording: %n step @@ -11326,12 +11350,12 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener - + Macro enregistrée Macro recorded - + « %1 » : %n étape(s). Pour en faire un script, demandez-le à votre assistant IA : le bouton ci-dessous copie la demande, il suffit de la coller dans sa fenêtre. @@ -11345,58 +11369,58 @@ To make a script of it, ask your AI assistant: the button below copies the reque - + &Copier la demande pour l'assistant &Copy the request for the assistant - + &Ouvrir le dossier &Open the folder - + Demande copiée : collez-la dans la fenêtre de l'assistant Request copied: paste it into the assistant's window - + Couper la connexion de l'assistant pour le reste de la session Close the assistant's connection for the rest of the session - + Mode direct : assistant connecté Live mode: assistant connected - + Mode direct : en attente d'un assistant Live mode: waiting for an assistant - - + + Assistant Assistant - + Demander avant d'exécuter un script écrit par l'assistant Ask before running a script the assistant wrote - + Pour cette session seulement : chaque démarrage redemande For this session only: every start asks again - + Mode direct : %1 %2 à %3 Live mode: %1 %2 at %3 - + %n type(s) d'objets masqué(s) %n kind of item hidden @@ -11404,8 +11428,8 @@ To make a script of it, ask your AI assistant: the button below copies the reque - - + + Textes %1 Texts %1 @@ -14342,22 +14366,22 @@ Do you want to replace it ? Creation of conductors - + To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br>Requires python 3.5 or above.<br><B><U> First install on Windows</B></U><br>1. Install, if required, python 3.5 or above<br> Visit :<br><a href='https://www.python.org/downloads/'>python.org</a><br>2. pip install qet_tb_generator<br><B><U> Update on Windows</B></U><br>python -m pip install --upgrade qet_tb_generator<br>>>user could launch in a terminal this script in this directory<br> C:\users\XXXX\AppData\Local\Programs\Python\Python36-32\Scripts <br> To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br>Requires python 3.5 or above.<br><B><U> First install on Windows</B></U><br>1. Install, if required, python 3.5 or above<br> Visit :<br><a href='https://www.python.org/downloads/'>python.org</a><br>2. pip install qet_tb_generator<br><B><U> Update on Windows</B></U><br>python -m pip install --upgrade qet_tb_generator<br>>>user could launch in a terminal this script in this directory<br> C:\users\XXXX\AppData\Local\Programs\Python\Python36-32\Scripts <br> - + To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><B><U> First install on macOSX</B></U><br>1. Install, if required, python 3.11 bundle only, <a href='https://www.python.org/ftp/python/3.11.2/python-3.11.2-macos11.pkg'>python-3.11.2-macos11.pkg</a><br>2 Run Profile.command script<br>because program use hardcoded PATH for localise qet-tb-generator plugin <br> Visit :<br><a href='https://qelectrotech.org/forum/viewtopic.php?pid=5674#p5674'>howto</a><br>2. pip3 install qet_tb_generator<br><B><U> Update on macOSX</B></U><br> pip3 install --upgrade qet_tb_generator<br> To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><B><U> First install on macOSX</B></U><br>1. Install, if required, python 3.11 bundle only, <a href='https://www.python.org/ftp/python/3.11.2/python-3.11.2-macos11.pkg'>python-3.11.2-macos11.pkg</a><br>2 Run Profile.command script<br>because program use hardcoded PATH for localise qet-tb-generator plugin <br> Visit :<br><a href='https://qelectrotech.org/forum/viewtopic.php?pid=5674#p5674'>howto</a><br>2. pip3 install qet_tb_generator<br><B><U> Update on macOSX</B></U><br> pip3 install --upgrade qet_tb_generator<br> - + To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><br>Requires python 3.5 or above.<br><br><B><U> First install on Linux</B></U><br>1. check you have pip3 installed: pip3 --version<br>If not install with: sudo apt-get install python3-pip<br>2. Install the program: sudo pip3 install qet_tb_generator<br>3. Run the program: qet_tb_generator<br><br><B><U> Update on Linux</B></U><br>sudo pip3 install --upgrade qet_tb_generator<br> To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><br>Requires python 3.5 or above.<br><br><B><U> First install on Linux</B></U><br>1. check you have pip3 installed: pip3 --version<br>If not install with: sudo apt-get install python3-pip<br>2. Install the program: sudo pip3 install qet_tb_generator<br>3. Run the program: qet_tb_generator<br><br><B><U> Update on Linux</B></U><br>sudo pip3 install --upgrade qet_tb_generator<br> - + Error launching qet_tb_generator plugin Error launching qet_tb_generator plugin @@ -14980,27 +15004,27 @@ Please download it by following the link and unzip it in the installation folder Rotate an image - + Modifier un texte d'élément Edit element text - + Numéroter automatiquement un élément Automatically number an element - + Renuméroter les éléments (%1) Renumber elements (%1) - + Rechercher et remplacer Search and replace - + Script From 4ceeeed3022e1e6e85598d56931c8ff4479a8890 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 20:34:14 +1300 Subject: [PATCH 8/9] qet-mcp README: the live project tools Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 0852d13a4..ad6e4c368 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -324,7 +324,7 @@ general (on the selected elements, say, not on these exact ones), ## Live mode: working in the QElectroTech you have open Every tool above works on files, with no QElectroTech window involved. The -three `qet_live_*` tools instead act on the project open in **your** +`qet_live_*` tools instead act on the project open in **your** QElectroTech, in front of you, so you can watch, stop or undo: | | | @@ -336,6 +336,15 @@ QElectroTech, in front of you, so you can watch, stop or undo: | `qet_live_show_folio` | show another folio | | `qet_live_undo_last` | undo the newest step, only if the assistant made it | | `qet_live_screenshot` | a picture of the folio on screen, as an MCP image, cropped to the folio | +| `qet_live_new_project` | a new project, as File > New makes it, made current; optional title, number of folios and a file to save it to (never over an existing one) | +| `qet_live_open_project` | open a saved project and make it current, with no dialog; one already open is only made current | +| `qet_live_switch_project` | make another open project current, by its index in `qet_live_status`'s `projects` or its file | + +Every `qet_live_*` call works on the current project. Pages and links +between pages need no tool of their own: in `qet_live_run_script`, +`qet.addFolio()` and `qet.setFolioTitle()` add and name folios, and +`qet.linkElements()` links a folio report arrow, or a coil and its +contacts, across folios. A script the assistant writes on the spot is shown to you first, with *Run*, *Decline* or *Always this session*; the Assistant From 8b130497e0647e6975c65a0f297439c04cae46a9 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 20:34:26 +1300 Subject: [PATCH 9/9] qet-mcp README: print, save/close, changes, live layout check, index cache, standard symbols Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index ad6e4c368..616ad5234 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -40,9 +40,9 @@ here read the model. | `qet_project_new` | **start from nothing** — an empty project with a title and folios | | `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count | | `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios, terminals with more than four wires, folio reports with several wires | -| `qet_layout_check` | **does the drawing read well?** — a 0–100 score; wires that jog because two symbols are a few pixels out of line, symbols off the grid, wires through symbols, overlaps, crossings; and the moves that fix them, ready for `qet_edit` | +| `qet_layout_check` | **does the drawing read well?** — a 0–100 score; wires that jog because two symbols are a few pixels out of line, symbols off the grid, wires through symbols, overlaps, crossings, labels over wires, 4-way junction dots, side branches out of line; and the moves that fix them, ready for `qet_edit` | | `qet_query` | **ask the project database** — read-only SQL over the views and tables | -| `qet_about` | **start here** — where QElectroTech keeps things, what is switched on, the stored scripts, the calls a script can make (from `qet-assistant.json`) | +| `qet_about` | **start here** — where QElectroTech keeps things, what is switched on, the stored scripts, the calls a script can make (from `qet-assistant.json`), and the installation's standard symbols if it has any | | `qet_script_api` | **what a script can call** — every `qet.*` call of this build, and the header that makes a script a button | | `qet_script_test` | **try a script** on a copy of a project: what it would change, what it logged, its errors | | `qet_script_install` | **make a button** — store a script (and an SVG icon) where QElectroTech shows it in Project > Scripts and the Scripts toolbar | @@ -233,6 +233,8 @@ file or one listed by whoever configured the server: | `QET_MCP_BINARIES` | other executables a call may name, separated like `QET_MCP_WORKSPACE` (for comparing two builds) | | `QET_MCP_ALLOW_ANY_BINARY=1` | turns the check off: a call can then run any program | | `QET_MCP_ELEMENTS` | element collections a call may name as `elements_dir` besides the workspace and the installed one | +| `QET_MCP_CACHE_DIR` | where the parsed element index is kept between runs (default `~/.cache/qet-mcp`); a new server then loads a collection in well under a second instead of parsing every symbol | +| `QET_MCP_STANDARD_SYMBOLS` | the installation's standard symbols, one per device role (default `standard-symbols.json` in QElectroTech's data folder); `qet_about` lists them | Anything else is refused, even a file inside the workspace: being there makes it readable, not runnable. Before this rule any executable a call @@ -339,6 +341,11 @@ QElectroTech, in front of you, so you can watch, stop or undo: | `qet_live_new_project` | a new project, as File > New makes it, made current; optional title, number of folios and a file to save it to (never over an existing one) | | `qet_live_open_project` | open a saved project and make it current, with no dialog; one already open is only made current | | `qet_live_switch_project` | make another open project current, by its index in `qet_live_status`'s `projects` or its file | +| `qet_live_save_project` | save the current project, or save it as a new file (never over an existing one) | +| `qet_live_close_project` | close a project, only when it has no unsaved changes | +| `qet_live_print` | print folios to the default or a named printer with no print dialog -- QElectroTech always asks you first -- or to a new PDF file | +| `qet_live_changes` | the undo history, each step marked assistant or you; `since` gives what changed after a point | +| `qet_live_layout_check` | `qet_layout_check` on the drawing as it is on screen, unsaved changes included, without saving it | Every `qet_live_*` call works on the current project. Pages and links between pages need no tool of their own: in `qet_live_run_script`,