From a5b5d49b982fb48223d7feb16209c1cffb4e90b7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 11:01:27 +1300 Subject: [PATCH 1/2] Add horizontal and vertical mirror for symbols on a folio (#1335) Edit > "Miroir horizontal" (M) and "Miroir vertical" (F), also in the folio's right-click menu, mirror the selected symbols in place. The keys are the ones the element editor uses for the same two actions. An element keeps two mirrors about its own axes, applied before its rotation, and saves them as mirror="horizontal|vertical|both" on its (written only when set, so other projects save byte for byte as before). On a symbol turned by 90 or 270 degrees, a mirror of the folio is the other mirror of the symbol itself, so the rotation never changes: a label kept upright does not swing round, and "Pivoter" still turns a mirrored symbol clockwise. - Terminals face the mirrored way (Terminal::orientation()), so wires follow. - The symbol stays where it was: its centre is kept, on the grid, since the hotspot is often a corner. - Texts read normally. The element's texts, text groups and cross reference are mirrored a second time about the centre of their own box (Element::keepReadable()), and ElementPictureFactory draws the texts of the symbol itself the same way, in a cached picture per mirror. Groups and cross references held at the bottom of the folio stay centred under their element. - DXF export mirrors the symbol's lines, arcs and texts. - Scripting: qet.mirrorElement(folio, uuid, vertical) and qet.elementMirror(folio, uuid); live mode may run both menu commands; the MCP server gets a mirror_element op and qet_diff reports mirrors. Not done: the parts of a PLC table drawn at run time (Element::drawPlcTable()) are not kept readable on a mirrored PLC; the project database has no column for it, as it has none for the rotation. Test: tst_scriptmirror mirrors a symbol through --run, checks every terminal's side and facing, the round trip, undo, a save and reload, and a symbol turned by 90 degrees. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + misc/qet-mcp/qet_mcp.py | 13 +- misc/qet-mcp/test_qet_mcp.py | 17 +- sources/dxfexport.cpp | 49 +++- sources/factory/elementpicturefactory.cpp | 81 +++++- sources/factory/elementpicturefactory.h | 13 +- sources/qetdiagrameditor.cpp | 24 ++ sources/qetdiagrameditor.h | 2 + sources/qetgraphicsitem/crossrefitem.cpp | 8 +- sources/qetgraphicsitem/element.cpp | 213 ++++++++++++++-- sources/qetgraphicsitem/element.h | 18 ++ .../qetgraphicsitem/elementtextitemgroup.cpp | 18 ++ .../qetgraphicsitem/qgraphicsitemutility.cpp | 20 ++ sources/qetgraphicsitem/terminal.cpp | 11 +- sources/scripting/liveserver.cpp | 2 + sources/scripting/qetscriptapi.cpp | 38 +++ sources/scripting/qetscriptapi.h | 2 + .../undocommand/mirrorselectioncommand.cpp | 125 +++++++++ sources/undocommand/mirrorselectioncommand.h | 48 ++++ tests/qttest/CMakeLists.txt | 13 + tests/qttest/tst_scriptmirror.cpp | 239 ++++++++++++++++++ 21 files changed, 923 insertions(+), 33 deletions(-) create mode 100644 sources/undocommand/mirrorselectioncommand.cpp create mode 100644 sources/undocommand/mirrorselectioncommand.h create mode 100644 tests/qttest/tst_scriptmirror.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 8afd0f6e1..d54b872ba 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -885,6 +885,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/undocommand/groupitemscommand.h ${QET_DIR}/sources/undocommand/alignselectioncommand.cpp ${QET_DIR}/sources/undocommand/alignselectioncommand.h + ${QET_DIR}/sources/undocommand/mirrorselectioncommand.cpp + ${QET_DIR}/sources/undocommand/mirrorselectioncommand.h ${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp ${QET_DIR}/sources/undocommand/rotateselectioncommand.h ${QET_DIR}/sources/undocommand/promoteshapecommand.cpp diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index d0aba37ea..f614ba98a 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -682,13 +682,18 @@ def tool_diff(before: str, after: str) -> dict: # the only thing a rotation changes, so without it a rotated symbol # reads as untouched. r["orientation"] = e.get("orientation", "0") + # A mirrored symbol carries mirror="horizontal", "vertical" or + # "both", about its own axes; a mirror changes nothing else unless + # the symbol's hotspot is off its centre. + r["mirror"] = e.get("mirror", "") a_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r for i, e in _elements(_root(after)): r = _element_row(i, e) r["orientation"] = e.get("orientation", "0") + r["mirror"] = e.get("mirror", "") b_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r - moved, rotated, relabelled, changed_info = [], [], [], [] + moved, rotated, mirrored, relabelled, changed_info = [], [], [], [], [] for k, a in a_el.items(): b = b_el.get(k) if b is None: @@ -696,6 +701,9 @@ def tool_diff(before: str, after: str) -> dict: if a["orientation"] != b["orientation"]: rotated.append({"uuid": k, "name": a["name"], "folio": a["folio"], "orientation": [a["orientation"], b["orientation"]]}) + if a["mirror"] != b["mirror"]: + mirrored.append({"uuid": k, "name": a["name"], "folio": a["folio"], + "mirror": [a["mirror"], b["mirror"]]}) if (a["x"], a["y"]) != (b["x"], b["y"]): moved.append({ "uuid": k, "name": a["name"], "folio": a["folio"], @@ -764,6 +772,7 @@ def tool_diff(before: str, after: str) -> dict: "relabelled": relabelled[:50], "info_changed": changed_info[:50], "rotated": rotated[:50], + "mirrored": mirrored[:50], }, "conductors": { "before": len(a_co), "after": len(b_co), @@ -1401,6 +1410,8 @@ OPS = { ("dx", "num"), ("dy", "num")]), "rotate_element": ("rotateElement", [("folio", "folio"), ("element", "elmt"), ("angle", "num")]), + "mirror_element": ("mirrorElement", [("folio", "folio"), ("element", "elmt"), + ("vertical", "bool")]), "set_label": ("setElementLabel", [("folio", "folio"), ("element", "elmt"), ("label", "str")]), "set_info": ("setElementInfo", [("folio", "folio"), ("element", "elmt"), diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 173cb034a..4582e19da 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -379,6 +379,7 @@ class EditValidation(unittest.TestCase): "set_position": el + [{"op": "set_position", "folio": "$f", "element": "$e", "x": 1, "y": 1}], "move_element": el + [{"op": "move_element", "folio": "$f", "element": "$e", "dx": 1, "dy": 1}], "rotate_element": el + [{"op": "rotate_element", "folio": "$f", "element": "$e", "angle": 90}], + "mirror_element": el + [{"op": "mirror_element", "folio": "$f", "element": "$e", "vertical": False}], "set_label": el + [{"op": "set_label", "folio": "$f", "element": "$e", "label": "K"}], "set_info": el + [{"op": "set_info", "folio": "$f", "element": "$e", "key": "k", "value": "v"}], "add_conductor": two + [{"op": "add_conductor", "folio": "$f", "from": "$e", "from_terminal": 0, @@ -2065,13 +2066,13 @@ class DiffContracts(unittest.TestCase): "relabelled": [{"uuid": A, "name": "coil", "from": "K1", "to": "K2"}], "info_changed": [{"uuid": A, "name": "coil", "from": {"label": "K1", "comment": "c"}, "to": {"label": "K2", "comment": "d"}}], - "rotated": []}) + "rotated": [], "mirrored": []}) def test_an_unchanged_element_reports_nothing(self): p = self.qet(self.folio(self.el(self.A, 1, 2, "K1", "c"))) e = m.tool_diff(p, p)["elements"] self.assertEqual((e["moved"], e["relabelled"], e["info_changed"], e["added"], e["removed"], - e["rotated"]), ([], [], [], [], [], [])) + e["rotated"], e["mirrored"]), ([], [], [], [], [], [], [])) def test_equal_angles_written_differently_are_the_same(self): self.assertEqual([m._angle(v) for v in ("-270", "90", "-90", "270", "360", "0", "450", "12.5", "", "x")], @@ -2098,6 +2099,18 @@ class DiffContracts(unittest.TestCase): plain = self.qet(self.folio(self.el(self.A, 1, 2))) self.assertEqual(m.tool_diff(plain, rot(0))["elements"]["rotated"], []) + def test_a_mirror_is_reported(self): + """A mirror of a symbol changes its mirror attribute, which is absent + when the symbol is not mirrored.""" + plain = self.qet(self.folio(self.el(self.A, 1, 2))) + mirrored = self.qet(self.folio( + self.el(self.A, 1, 2).replace('x="1"', 'mirror="horizontal" x="1"'))) + d = m.tool_diff(plain, mirrored) + self.assertEqual(d["elements"]["mirrored"], + [{"uuid": self.A, "name": "coil", "folio": 1, "mirror": ["", "horizontal"]}]) + self.assertEqual((d["elements"]["moved"], d["elements"]["rotated"]), ([], [])) + self.assertEqual(m.tool_diff(plain, plain)["elements"]["mirrored"], []) + def test_conductors_section_exact(self): A, B, C = self.A, self.B, self.C els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0) diff --git a/sources/dxfexport.cpp b/sources/dxfexport.cpp index 6db86c5b4..cf96f02e9 100644 --- a/sources/dxfexport.cpp +++ b/sources/dxfexport.cpp @@ -37,6 +37,7 @@ #include "qetgraphicsitem/terminal.h" #include "textlines.h" +#include #include #include #include @@ -178,6 +179,10 @@ void DxfExport::write(Diagram *diagram, int width, int height, foreach(Element *elmt, list_elements) { double rotation_angle = elmt -> orientation() * 90; + //The mirrors of an element are about its own axes, before it is + //rotated, see Element::setMirror() + const qreal mirror_x = elmt -> hasHorizontalMirror() ? -1 : 1; + const qreal mirror_y = elmt -> hasVerticalMirror() ? -1 : 1; qreal elem_pos_x = elmt -> pos().x(); qreal elem_pos_y = elmt -> pos().y();// - (diagram -> margin / 2); @@ -191,8 +196,26 @@ void DxfExport::write(Diagram *diagram, int width, int height, if (fontSize < 0) fontSize = text->font().pixelSize(); - qreal x = elem_pos_x + text->pos().x(); - qreal y = elem_pos_y + text->pos().y(); + QPointF text_pos = text->pos(); + if (elmt -> isMirrored()) + { + //The text keeps reading normally: only its box is + //mirrored, about its own centre, as ElementPictureFactory + //draws it on the folio + QTransform box_transform; + box_transform.translate(text->pos().x(), text->pos().y()); + box_transform.rotate(text->rotation()); + const QFontMetricsF metrics(text->font()); + const QRectF box(0, -metrics.ascent(), + text->boundingRect().width(), + text->boundingRect().height()); + const QPointF centre = box_transform.mapRect(box).center(); + text_pos += QPointF(centre.x() * (mirror_x - 1), + centre.y() * (mirror_y - 1)); + } + + qreal x = elem_pos_x + text_pos.x(); + qreal y = elem_pos_y + text_pos.y(); qreal angle = text -> rotation() + rotation_angle; qreal angler = angle * M_PI/180; @@ -217,21 +240,21 @@ void DxfExport::write(Diagram *diagram, int width, int height, Createdxf::layer = Layer::Symbols; for (QLineF line : primitives.m_lines) { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); QLineF l = t.map(line); Createdxf::drawLine(file_path, l, 0); } for (QRectF rect : primitives.m_rectangles) { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); QRectF r = t.mapRect(rect); Createdxf::drawRectangle(file_path,r,0); } for (QRectF circle_rect : primitives.m_circles) { - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); QPointF c = t.map(QPointF(circle_rect.center().x(),circle_rect.center().y())); Createdxf::drawCircle(file_path,c,circle_rect.width()/2,0); } @@ -240,7 +263,7 @@ void DxfExport::write(Diagram *diagram, int width, int height, { if (polygon.size() == 0) continue; - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); QPolygonF poly = t.map(polygon); if(poly.isClosed()) Createdxf::drawPolygon(file_path,poly,0); @@ -259,6 +282,18 @@ void DxfExport::write(Diagram *diagram, int width, int height, qreal h = arc.at(3); qreal startAngle = arc.at(4); qreal spanAngle = arc .at(5); + //In a mirror the arc starts where it ended: an angle a + //becomes 180 - a in a horizontal one, -a in a vertical one + if (elmt -> hasHorizontalMirror()) + { + x = elem_pos_x - arc.at(0) - w; + startAngle = 180 - startAngle - spanAngle; + } + if (elmt -> hasVerticalMirror()) + { + y = elem_pos_y - arc.at(1) - h; + startAngle = -startAngle - spanAngle; + } QRectF r(x,y,w,h); QPointF hotspot(elem_pos_x,elem_pos_y); Createdxf::drawArcEllipse(file_path, r, startAngle, spanAngle, hotspot, rotation_angle, 0); @@ -268,7 +303,7 @@ void DxfExport::write(Diagram *diagram, int width, int height, // Draw terminals QList list_terminals = elmt->terminals(); QColor col("red"); - QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle); + QTransform t = QTransform().translate(elem_pos_x,elem_pos_y).rotate(rotation_angle).scale(mirror_x, mirror_y); foreach(Terminal *tp, list_terminals) { QPointF c = t.map(QPointF(tp->dock_elmt_.x(),tp->dock_elmt_.y())); Createdxf::drawCircle(file_path,c,3.0,Createdxf::dxfColor(col)); diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index f0ad1e22d..6de56ad79 100644 --- a/sources/factory/elementpicturefactory.cpp +++ b/sources/factory/elementpicturefactory.cpp @@ -94,6 +94,40 @@ void ElementPictureFactory::getPictures(const ElementsLocation &location, QPictu } } +/** + @brief ElementPictureFactory::getMirroredPictures + Same as getPictures(), for an element mirrored on its folio + (Element::setMirror()). The drawing is the same; only the texts differ: + each one is drawn so that, once the element's mirror is applied, its box + lands mirrored but its letters still read normally. + @param location + @param horizontal : the element's left and right swap + @param vertical : the element's top and bottom swap + @param picture + @param low_picture +*/ +void ElementPictureFactory::getMirroredPictures(const ElementsLocation &location, + bool horizontal, + bool vertical, + QPicture &picture, + QPicture &low_picture) +{ + if(!location.exist() || !(horizontal || vertical)) { + return; + } + + const QPair key(cacheKey(location), + (horizontal ? 1 : 0) | (vertical ? 2 : 0)); + + if(!m_mirrored_pictures_H.contains(key) + && !build(location, nullptr, nullptr, + QTransform::fromScale(horizontal ? -1 : 1, vertical ? -1 : 1))) { + return; + } + picture = m_mirrored_pictures_H.value(key); + low_picture = m_mirrored_low_pictures_H.value(key); +} + /** @brief ElementPictureFactory::dropCache Forget the cached drawing of the element at @p location, so the next @@ -113,6 +147,10 @@ void ElementPictureFactory::dropCache(const ElementsLocation &location) const QUuid uuid = cacheKey(location); m_pictures_H.remove(uuid); m_low_pictures_H.remove(uuid); + for (int mirror = 1 ; mirror <= 3 ; ++mirror) { + m_mirrored_pictures_H.remove(qMakePair(uuid, mirror)); + m_mirrored_low_pictures_H.remove(qMakePair(uuid, mirror)); + } m_pixmap_H.remove(uuid); m_primitives_H.remove(uuid); } @@ -216,11 +254,16 @@ ElementPictureFactory::~ElementPictureFactory() this function draw on it and don't store it. if null, this function create a QPicture for normal and low zoom, draw on it and store it in m_pictures_H and m_low_pictures_H + (m_mirrored_pictures_H and m_mirrored_low_pictures_H if @p mirror + is not the identity) + @param mirror : the mirror of an element to draw the drawing for, see + getMirroredPictures() @return */ bool ElementPictureFactory::build(const ElementsLocation &location, QPicture *picture, - QPicture *low_picture) + QPicture *low_picture, + const QTransform &mirror) { QDomElement dom = location.xml(); @@ -277,6 +320,8 @@ bool ElementPictureFactory::build(const ElementsLocation &location, tmp.setCosmetic(true); low_painter.setPen(tmp); + m_build_mirror = mirror; + //scroll of the Children of the Definition: Parts of the Drawing // Extract PLC master data for rendering plc_table parts QDomElement plc_master_data; @@ -330,8 +375,20 @@ bool ElementPictureFactory::build(const ElementsLocation &location, //End of the drawing painter.end(); low_painter.end(); + m_build_mirror = QTransform(); const auto uuid_ = cacheKey(location); + if (!mirror.isIdentity()) { + const QPair key(uuid_, (mirror.m11() < 0 ? 1 : 0) + | (mirror.m22() < 0 ? 2 : 0)); + if (!picture) { + m_mirrored_pictures_H.insert(key, pic); + } + if (!low_picture) { + m_mirrored_low_pictures_H.insert(key, low_pic); + } + return true; + } if (!picture) { m_pictures_H.insert(uuid_, pic); m_primitives_H.insert(uuid_, primitives_); @@ -612,8 +669,6 @@ void ElementPictureFactory::parseText(const QDomElement &dom, QPainter &painter, text_document.setPlainText(dom.attribute("text")); painter.setTransform(QTransform(), false); - painter.translate(dom.attribute("x").toDouble(), dom.attribute("y").toDouble()); - painter.rotate(dom.attribute("rotation", "0").toDouble()); /* Moves the QPainter's coordinate system to render in the right place; @@ -647,7 +702,25 @@ void ElementPictureFactory::parseText(const QDomElement &dom, QPainter &painter, } } - painter.translate(qpainter_offset); + QTransform text_transform; + text_transform.translate(dom.attribute("x").toDouble(), dom.attribute("y").toDouble()); + text_transform.rotate(dom.attribute("rotation", "0").toDouble()); + text_transform.translate(qpainter_offset.x(), qpainter_offset.y()); + + if (!m_build_mirror.isIdentity()) + { + //The element is mirrored about its own axes when it is drawn. + //Mirror the text the same way first, about the centre of its + //box: the two mirrors cancel on the letters, which read + //normally, and the box still ends up where the element's + //mirror puts it. + const QRectF box(QPointF(0, 0), text_document.size()); + const QPointF centre = text_transform.mapRect(box).center(); + painter.setTransform(QTransform::fromTranslate(-centre.x(), -centre.y()) + * m_build_mirror + * QTransform::fromTranslate(centre.x(), centre.y())); + } + painter.setTransform(text_transform, true); // force the palette used to render the QTextDocument QAbstractTextDocumentLayout::PaintContext ctx; diff --git a/sources/factory/elementpicturefactory.h b/sources/factory/elementpicturefactory.h index d1f2c307a..298442c3e 100644 --- a/sources/factory/elementpicturefactory.h +++ b/sources/factory/elementpicturefactory.h @@ -21,6 +21,8 @@ #include #include #include +#include +#include class ElementsLocation; class QPicture; @@ -83,6 +85,8 @@ class ElementPictureFactory } void getPictures(const ElementsLocation &location, QPicture &picture, QPicture &low_picture); + void getMirroredPictures(const ElementsLocation &location, bool horizontal, bool vertical, + QPicture &picture, QPicture &low_picture); QPixmap pixmap(const ElementsLocation &location); ElementPictureFactory::primitives getPrimitives(const ElementsLocation &location); void dropCache(const ElementsLocation &location); @@ -94,7 +98,8 @@ class ElementPictureFactory ~ElementPictureFactory(); static QUuid cacheKey(const ElementsLocation &location); - bool build(const ElementsLocation &location, QPicture *picture=nullptr, QPicture *low_picture=nullptr); + bool build(const ElementsLocation &location, QPicture *picture=nullptr, QPicture *low_picture=nullptr, + const QTransform &mirror=QTransform()); void parseElement(const QDomElement &dom, QPainter &painter, primitives &prim) const; void parseLine (const QDomElement &dom, QPainter &painter, primitives &prim) const; void parseRect (const QDomElement &dom, QPainter &painter, primitives &prim) const; @@ -108,6 +113,12 @@ class ElementPictureFactory QHash m_pictures_H; QHash m_low_pictures_H; + /// Drawings for mirrored elements, by element and mirror + /// (horizontal, vertical, both), see getMirroredPictures() + QHash, QPicture> m_mirrored_pictures_H; + QHash, QPicture> m_mirrored_low_pictures_H; + /// The mirror build() draws for, read by parseText() + QTransform m_build_mirror; QHash m_pixmap_H; QHash m_primitives_H; static ElementPictureFactory* m_factory; diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index bf6b01396..7a40892c9 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -76,6 +76,7 @@ #include "utils/qetutils.h" #include "undocommand/groupitemscommand.h" #include "undocommand/alignselectioncommand.h" +#include "undocommand/mirrorselectioncommand.h" #include "undocommand/rotateselectioncommand.h" #include "undocommand/rotatetextscommand.h" #include "diagram.h" @@ -978,6 +979,8 @@ void QETDiagramEditor::setUpActions() m_rotate_selection = m_selection_actions_group.addAction( QET::Icons::TransformRotate, tr("Pivoter") ); m_rotate_group_selection = m_selection_actions_group.addAction( QET::Icons::TransformRotate, tr("Pivoter le groupe") ); m_rotate_texts = m_selection_actions_group.addAction( QET::Icons::ObjectRotateRight, tr("Orienter les textes") ); + m_mirror_horizontal = m_selection_actions_group.addAction( QET::Icons::ImageFlipHorizontal, tr("Miroir horizontal") ); + m_mirror_vertical = m_selection_actions_group.addAction( QET::Icons::ImageFlipVertical, tr("Miroir vertical") ); m_find_element = m_selection_actions_group.addAction( QET::Icons::ZoomDraw, tr("Retrouver dans le panel") ); m_edit_selection = m_selection_actions_group.addAction( QET::Icons::ElementEdit, tr("Éditer l'item sélectionné") ); m_group_selected_texts = m_selection_actions_group.addAction( QET::Icons::textGroup, tr("Grouper les textes sélectionnés")); @@ -988,6 +991,9 @@ void QETDiagramEditor::setUpActions() ShortcutManager::instance().registerAction(m_rotate_selection, "diagrameditor.rotate_selection", tr("Éditeur de schémas"), Qt::Key_Space); ShortcutManager::instance().registerAction(m_rotate_group_selection, "diagrameditor.rotate_group_selection", tr("Éditeur de schémas"), Qt::SHIFT | Qt::Key_Space); ShortcutManager::instance().registerAction(m_rotate_texts, "diagrameditor.rotate_texts", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_Space); + //M and F, the keys of the same two actions in the element editor + ShortcutManager::instance().registerAction(m_mirror_horizontal, "diagrameditor.mirror_horizontal", tr("Éditeur de schémas"), Qt::Key_M); + ShortcutManager::instance().registerAction(m_mirror_vertical, "diagrameditor.mirror_vertical", tr("Éditeur de schémas"), Qt::Key_F); ShortcutManager::instance().registerAction(m_find_element, "diagrameditor.find_element", tr("Éditeur de schémas"), QKeySequence()); ShortcutManager::instance().registerAction(m_group_selected_texts, "diagrameditor.group_selected_texts", tr("Éditeur de schémas"), QKeySequence()); ShortcutManager::instance().registerAction(m_edit_selection, "diagrameditor.edit_selection", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_E); @@ -1081,12 +1087,16 @@ void QETDiagramEditor::setUpActions() m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); m_rotate_texts ->setStatusTip( tr("Pivote les textes sélectionnés à un angle précis", "status bar tip")); + m_mirror_horizontal->setStatusTip( tr("Retourne les éléments sélectionnés de gauche à droite", "status bar tip")); + m_mirror_vertical ->setStatusTip( tr("Retourne les éléments sélectionnés de haut en bas", "status bar tip")); m_find_element ->setStatusTip( tr("Retrouve l'élément sélectionné dans le panel", "status bar tip")); m_delete_selection ->setData("delete_selection"); m_rotate_selection ->setData("rotate_selection"); m_rotate_group_selection->setData("rotate_group_selection"); m_rotate_texts ->setData("rotate_selected_text"); + m_mirror_horizontal ->setData("mirror_horizontal"); + m_mirror_vertical ->setData("mirror_vertical"); m_find_element ->setData("find_selected_element"); m_edit_selection ->setData("edit_selected_element"); m_group_selected_texts->setData("group_selected_texts"); @@ -2333,6 +2343,16 @@ void QETDiagramEditor::selectionGroupTriggered(QAction *action) if(c->isValid()) diagram->undoStack().push(c); } + else if (value == "mirror_horizontal" || value == "mirror_vertical") + { + auto *c = new MirrorSelectionCommand( + diagram, + value == "mirror_horizontal" ? Qt::Horizontal : Qt::Vertical); + if (c->isValid()) + diagram->undoStack().push(c); + else + delete c; + } else if (value == "rotate_group_selection") { RotateSelectionCommand *c = new RotateSelectionCommand(diagram, 90, nullptr, true); @@ -2570,6 +2590,8 @@ void QETDiagramEditor::slot_updateComplexActions() << m_delete_selection << m_rotate_selection << m_rotate_group_selection + << m_mirror_horizontal + << m_mirror_vertical << m_edit_selection << m_group_selected_texts << m_group_selection @@ -2593,6 +2615,8 @@ void QETDiagramEditor::slot_updateComplexActions() // number of selected elements int selected_elements_count = dc.count(DiagramContent::Elements); m_find_element->setEnabled(selected_elements_count == 1); + m_mirror_horizontal->setEnabled(!ro && selected_elements_count); + m_mirror_vertical->setEnabled(!ro && selected_elements_count); //Actions that need items (elements, conductors, texts...) selected, to be enabled bool copiable_items = dc.hasCopiableItems(); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 500a90e91..431e27d9b 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -270,6 +270,8 @@ class QETDiagramEditor : public QETMainWindow *m_rotate_selection, ///< Rotate selected elements and text items by 90 degrees *m_rotate_group_selection = nullptr, ///< Rotate the selection as a whole around its shared center, instead of each item in place *m_rotate_texts, ///< Direct selected text items to a specific angle + *m_mirror_horizontal = nullptr, ///< Mirror the selected elements, left and right swap (#1335) + *m_mirror_vertical = nullptr, ///< Mirror the selected elements, top and bottom swap (#1335) *m_find_element, ///< Find the selected element in the panel *m_group_selected_texts = nullptr, *m_group_selection = nullptr, ///< Group the selected items (#1070) diff --git a/sources/qetgraphicsitem/crossrefitem.cpp b/sources/qetgraphicsitem/crossrefitem.cpp index 33b17c463..88184f30d 100644 --- a/sources/qetgraphicsitem/crossrefitem.cpp +++ b/sources/qetgraphicsitem/crossrefitem.cpp @@ -124,6 +124,7 @@ void CrossRefItem::setUpConnection() m_update_connection << connect(m_element, &Element::xChanged, this, &CrossRefItem::autoPos); m_update_connection << connect(m_element, &Element::yChanged, this, &CrossRefItem::autoPos); m_update_connection << connect(m_element, &Element::rotationChanged, this, &CrossRefItem::autoPos); + m_update_connection << connect(m_element, &Element::mirrorChanged, this, &CrossRefItem::autoPos); set=true; } // For PLC masters, always set up connections for update notifications @@ -288,6 +289,7 @@ void CrossRefItem::updateLabel() QList positions = m_element->plcTablePositions(); QPointF pos = positions.isEmpty() ? QPointF(0, 0) : positions.first(); setPos(pos); + m_element->keepReadable(this); // Populate m_hovered_contacts_map using drawAsPlcTable on a // dummy painter (m_update_map=true). @@ -338,6 +340,7 @@ void CrossRefItem::autoPos() stackAtBottom(); else QGIUtility::centerToParentBottom(this); + m_element->keepReadable(this); } /** @@ -409,9 +412,12 @@ void CrossRefItem::stackAtBottom() moved = true; } } + //From pos(), not scenePos(): on a mirrored element scenePos() + //also holds the mirror that keeps the cross readable if (self.rect.bottom() != bottom) xref->setPos(xref->parentItem()->mapFromScene( - xref->scenePos() + QPointF(0, self.rect.bottom() - bottom))); + xref->parentItem()->mapToScene(xref->pos()) + + QPointF(0, self.rect.bottom() - bottom))); placed << self; } } diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index b003b1046..67dbabca5 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -36,10 +36,12 @@ #include "../ui/elementpropertieswidget.h" #include "../undocommand/changeelementinformationcommand.h" #include "../undocommand/setautonumcontextcommand.h" +#include "crossrefitem.h" #include "dynamicelementtextitem.h" #include "elementtextitemgroup.h" #include "iostream" +#include #include #include #include @@ -161,6 +163,10 @@ Element::Element( connect(this, &Element::rotationChanged, update_docked_conductors); connect(this, &Element::xChanged, update_docked_conductors); connect(this, &Element::yChanged, update_docked_conductors); + connect(this, &Element::mirrorChanged, update_docked_conductors); + //The mirror is about the element's own axis, so it follows the + //rotation, see applyMirrorTransform() + connect(this, &Element::rotationChanged, this, &Element::applyMirrorTransform); } /** @@ -251,6 +257,152 @@ void Element::displayHelpLine(bool b) t->drawHelpLine(b); } +/** + @brief Element::setHorizontalMirror + Mirror this element about its own vertical axis (its left and right + swap), or put it back. See setMirror(). + @param mirror +*/ +void Element::setHorizontalMirror(bool mirror) +{ + setMirror(mirror, m_vertical_mirror); +} + +/** + @brief Element::setVerticalMirror + Mirror this element about its own horizontal axis (its top and bottom + swap), or put it back. See setMirror(). + @param mirror +*/ +void Element::setVerticalMirror(bool mirror) +{ + setMirror(m_horizontal_mirror, mirror); +} + +/** + @brief Element::setMirror + The mirrors are about the element's own axes, through its hotspot, and + are applied before the rotation: a mirrored element still turns + clockwise with "Pivoter", and orientation() keeps its meaning. On the + folio, a mirror of a turned element is the other mirror of the element + itself, see MirrorSelectionCommand. + The texts of the element, its cross reference and the texts drawn in + its symbol are moved to their mirrored place but keep reading normally. + @param horizontal : the left and right of the element swap + @param vertical : the top and bottom of the element swap +*/ +void Element::setMirror(bool horizontal, bool vertical) +{ + if (horizontal == m_horizontal_mirror && vertical == m_vertical_mirror) + return; + + m_horizontal_mirror = horizontal; + m_vertical_mirror = vertical; + + m_mirrored_picture = QPicture(); + m_mirrored_low_zoom_picture = QPicture(); + if (isMirrored()) + ElementPictureFactory::instance()->getMirroredPictures( + m_location, + horizontal, + vertical, + m_mirrored_picture, + m_mirrored_low_zoom_picture); + + applyMirrorTransform(); + keepChildrenReadable(); + update(); + emit mirrorChanged(); +} + +/** + @brief Element::mirrorTransform + @return the mirrors of this element, about its own axes +*/ +QTransform Element::mirrorTransform() const +{ + return QTransform::fromScale(m_horizontal_mirror ? -1 : 1, + m_vertical_mirror ? -1 : 1); +} + +/** + @brief Element::applyMirrorTransform + QGraphicsItem applies transform() after the rotation, so the mirrors of + the element's own axes (S, before rotating) are given here as the + mirrors about the rotated axes: R . S . R^-1, which is then applied + after R. +*/ +void Element::applyMirrorTransform() +{ + if (!isMirrored()) + { + if (!transform().isIdentity()) + setTransform(QTransform()); + return; + } + + //QTransform's product applies its left operand first + setTransform(QTransform().rotate(-rotation()) + * mirrorTransform() + * QTransform().rotate(rotation())); +} + +/** + @brief Element::keepReadable + Keep @p child, a text, a group of texts or a cross reference of this + element, reading normally when this element is mirrored. + The child is mirrored a second time, the same way, about the centre of + its own box: the box keeps its place in the element, so it lands + mirrored with the element, but the two mirrors cancel on what is drawn + inside it. + Does nothing for an item that is not a direct child of this element. + @param child +*/ +void Element::keepReadable(QGraphicsItem *child) const +{ + if (!child || child->parentItem() != this) + return; + + if (!isMirrored()) + { + if (!child->transform().isIdentity()) + child->resetTransform(); + return; + } + + //The rotation part of the child's own transformation, which + //QGraphicsItem applies before transform() + const QPointF origin = child->transformOriginPoint(); + QTransform rotation; + rotation.translate(origin.x(), origin.y()); + rotation.rotate(child->rotation()); + rotation.translate(-origin.x(), -origin.y()); + const QPointF centre = rotation.mapRect(child->boundingRect()).center(); + + const QTransform mirror = QTransform::fromTranslate(-centre.x(), -centre.y()) + * mirrorTransform() + * QTransform::fromTranslate(centre.x(), centre.y()); + if (child->transform() != mirror) + child->setTransform(mirror); +} + +/** + @brief Element::keepChildrenReadable + keepReadable() for every text, group of texts and cross reference + which is a direct child of this element. +*/ +void Element::keepChildrenReadable() const +{ + const QList children = childItems(); + for (QGraphicsItem *child : children) + { + if (child->type() == DynamicElementTextItem::Type + || child->type() == CrossRefItem::Type + || dynamic_cast(child)) + keepReadable(child); + } +} + /** @brief Element::paint @param painter @@ -275,13 +427,16 @@ void Element::paint( QBrush brush; painter->setPen(pen); painter->setBrush(brush); + const QPicture &picture = isMirrored() ? m_mirrored_picture : m_picture; + const QPicture &low_zoom_picture = isMirrored() ? m_mirrored_low_zoom_picture + : m_low_zoom_picture; if (options && options->levelOfDetailFromTransform(painter->worldTransform()) < 0.5) { - if (!m_low_zoom_picture.isNull()) - painter->drawPicture(0, 0, m_low_zoom_picture); + if (!low_zoom_picture.isNull()) + painter->drawPicture(0, 0, low_zoom_picture); } else { - if (!m_picture.isNull()) - painter->drawPicture(0, 0, m_picture); + if (!picture.isNull()) + painter->drawPicture(0, 0, picture); } painter->restore(); //Restore the QPainter after use drawPicture @@ -938,6 +1093,11 @@ bool Element::fromXml(QDomElement &e, for(DynamicElementTextItem *deti : m_dynamic_text_list) deti->m_block_alignment = false; + //Last, so that the texts and groups loaded above are kept readable + const QString mirror = e.attribute(QStringLiteral("mirror")); + setMirror(mirror == QLatin1String("horizontal") || mirror == QLatin1String("both"), + mirror == QLatin1String("vertical") || mirror == QLatin1String("both")); + m_state = QET::GIOK; return(true); } @@ -987,6 +1147,13 @@ QDomElement Element::toXml( element.setAttribute(QStringLiteral("y"), QString::number(pos().y())); element.setAttribute(QStringLiteral("z"), QString::number(this->zValue())); element.setAttribute(QStringLiteral("orientation"), QString::number(orientation())); + //Written only when set, so a project without a mirrored element + //saves exactly as before + if (isMirrored()) + element.setAttribute(QStringLiteral("mirror"), + !m_vertical_mirror ? QStringLiteral("horizontal") + : !m_horizontal_mirror ? QStringLiteral("vertical") + : QStringLiteral("both")); element.setAttribute(QStringLiteral("is_movable"), bool(is_movable_)); /* get the first id to use for the bounds of this element @@ -1133,18 +1300,25 @@ QDomElement Element::toXml( */ void Element::addDynamicTextItem(DynamicElementTextItem *deti) { - if (deti && !m_dynamic_text_list.contains(deti)) - { - m_dynamic_text_list.append(deti); - deti->setParentItem(this); - emit textAdded(deti); - } + if (!deti || m_dynamic_text_list.contains(deti)) + deti = new DynamicElementTextItem(this); else - { - DynamicElementTextItem *text = new DynamicElementTextItem(this); - m_dynamic_text_list.append(text); - emit textAdded(text); - } + deti->setParentItem(this); + + m_dynamic_text_list.append(deti); + + //On a mirrored element, the mirror of the text depends on the size + //and the rotation of the text, see keepReadable() + connect(deti->document()->documentLayout(), + &QAbstractTextDocumentLayout::documentSizeChanged, + this, [this, deti]() {keepReadable(deti);}); + connect(deti, &DynamicElementTextItem::rotationChanged, + this, [this, deti]() {keepReadable(deti);}); + connect(deti, &DynamicElementTextItem::rotationPointCenterChanged, + this, [this, deti]() {keepReadable(deti);}); + keepReadable(deti); + + emit textAdded(deti); } /** @@ -1156,10 +1330,18 @@ void Element::addDynamicTextItem(DynamicElementTextItem *deti) */ void Element::removeDynamicTextItem(DynamicElementTextItem *deti) { + //Undo the connections and the mirror of addDynamicTextItem() + auto forget = [this, deti]() { + disconnect(deti->document()->documentLayout(), nullptr, this, nullptr); + disconnect(deti, nullptr, this, nullptr); + deti->resetTransform(); + }; + if (m_dynamic_text_list.contains(deti)) { m_dynamic_text_list.removeOne(deti); deti->setParentItem(nullptr); + forget(); emit textRemoved(deti); return; } @@ -1171,6 +1353,7 @@ void Element::removeDynamicTextItem(DynamicElementTextItem *deti) removeTextFromGroup(deti, group); m_dynamic_text_list.removeOne(deti); deti->setParentItem(nullptr); + forget(); emit textRemoved(deti); return; } diff --git a/sources/qetgraphicsitem/element.h b/sources/qetgraphicsitem/element.h index f4c82853f..e5b3d2295 100644 --- a/sources/qetgraphicsitem/element.h +++ b/sources/qetgraphicsitem/element.h @@ -46,6 +46,9 @@ class Element : public QetGraphicsItem friend class DynamicElementTextItem; Q_OBJECT + Q_PROPERTY(bool horizontalMirror READ hasHorizontalMirror WRITE setHorizontalMirror NOTIFY mirrorChanged) + Q_PROPERTY(bool verticalMirror READ hasVerticalMirror WRITE setVerticalMirror NOTIFY mirrorChanged) + public: /** @brief The kind enum @@ -111,6 +114,7 @@ class Element : public QetGraphicsItem void textRemovedFromGroup( DynamicElementTextItem *text, ElementTextItemGroup *group); + void mirrorChanged(); public slots: void updateConductorTexts(); @@ -185,6 +189,13 @@ class Element : public QetGraphicsItem int> &) const; QUuid uuid() const; int orientation() const; + bool hasHorizontalMirror() const {return m_horizontal_mirror;} + bool hasVerticalMirror() const {return m_vertical_mirror;} + bool isMirrored() const {return m_horizontal_mirror || m_vertical_mirror;} + void setHorizontalMirror(bool mirror); + void setVerticalMirror(bool mirror); + QTransform mirrorTransform() const; + void keepReadable(QGraphicsItem *child) const; //METHODS related to texts void addDynamicTextItem(DynamicElementTextItem *deti = nullptr); @@ -296,10 +307,15 @@ class Element : public QetGraphicsItem QList m_terminals; QPicture m_picture; QPicture m_low_zoom_picture; + QPicture m_mirrored_picture; + QPicture m_mirrored_low_zoom_picture; ElementData m_data; QList m_plc_table_positions; // Positions of plc_table parts in the element definition void drawPlcTable(QPainter *painter); + void setMirror(bool horizontal, bool vertical); + void applyMirrorTransform(); + void keepChildrenReadable() const; public: /// Positions where the PLC IO table is drawn (from the .elmt file). @@ -310,6 +326,8 @@ class Element : public QetGraphicsItem QSize dimensions; QPoint hotspot_coord; bool m_mouse_over = false; + bool m_horizontal_mirror = false; + bool m_vertical_mirror = false; QString m_prefix; QList m_dynamic_text_list; QList m_texts_group; diff --git a/sources/qetgraphicsitem/elementtextitemgroup.cpp b/sources/qetgraphicsitem/elementtextitemgroup.cpp index 86f6cd054..b9dc47a91 100644 --- a/sources/qetgraphicsitem/elementtextitemgroup.cpp +++ b/sources/qetgraphicsitem/elementtextitemgroup.cpp @@ -119,6 +119,12 @@ void ElementTextItemGroup::addToGroup(QGraphicsItem *item) if((item->rotation() != rotation()) && !m_block_alignment_update) item->setRotation(rotation()); + //On a mirrored element, QGraphicsItemGroup::addToGroup() would + //fold the mirror that keeps the text and this group readable + //(Element::keepReadable()) into the text's transform. The group + //gets its own back from updateAlignment(). + item->resetTransform(); + resetTransform(); QGraphicsItemGroup::addToGroup(item); updateAlignment(); @@ -153,6 +159,7 @@ void ElementTextItemGroup::addToGroup(QGraphicsItem *item) */ void ElementTextItemGroup::removeFromGroup(QGraphicsItem *item) { + resetTransform(); //See addToGroup(), given back by updateAlignment() QGraphicsItemGroup::removeFromGroup(item); //the item transformation is not reset, we must do it ourselves, // because for example if the group rotation is 45° @@ -162,6 +169,8 @@ void ElementTextItemGroup::removeFromGroup(QGraphicsItem *item) item->resetTransform(); item->setRotation(this->rotation()); item->setFlag(QGraphicsItem::ItemIsSelectable, true); + if (m_parent_element) + m_parent_element->keepReadable(item); updateAlignment(); if(DynamicElementTextItem *deti = qgraphicsitem_cast(item)) @@ -232,6 +241,10 @@ void ElementTextItemGroup::updateAlignment() prepareGeometryChange(); QList texts = this->texts(); + + //The mirror of a mirrored element's group depends on the size of + //the group: drop it while the texts are laid out, set it back after + resetTransform(); qreal rotation_ = rotation(); @@ -302,6 +315,8 @@ void ElementTextItemGroup::updateAlignment() //Restore the rotation setRotation(rotation_); + if (m_parent_element) + m_parent_element->keepReadable(this); if(m_Xref_item) m_Xref_item->autoPos(); @@ -357,6 +372,7 @@ void ElementTextItemGroup::setHoldToBottomPage(bool hold) setFlag(QGraphicsItem::ItemIsMovable, false); connect(m_parent_element, &Element::yChanged, this, &ElementTextItemGroup::autoPos); connect(m_parent_element, &Element::rotationChanged, this, &ElementTextItemGroup::autoPos); + connect(m_parent_element, &Element::mirrorChanged, this, &ElementTextItemGroup::autoPos); if(m_parent_element->linkType() == Element::Master) { //We use timer to let the time of the parent element @@ -396,6 +412,8 @@ void ElementTextItemGroup::setHoldToBottomPage(bool hold) this, &ElementTextItemGroup::autoPos); disconnect(m_parent_element, &Element::rotationChanged, this, &ElementTextItemGroup::autoPos); + disconnect(m_parent_element, &Element::mirrorChanged, + this, &ElementTextItemGroup::autoPos); if(m_parent_element->linkType() == Element::Master) { disconnect(m_linked_changed_timer); diff --git a/sources/qetgraphicsitem/qgraphicsitemutility.cpp b/sources/qetgraphicsitem/qgraphicsitemutility.cpp index 85da0396e..34fdf3ecf 100644 --- a/sources/qetgraphicsitem/qgraphicsitemutility.cpp +++ b/sources/qetgraphicsitem/qgraphicsitemutility.cpp @@ -75,6 +75,9 @@ bool centerToBottomDiagram (QGraphicsItem *item_to_center, Element *element_to_f item_to_center -> setPos(0,0); //Due to a weird behavior or bug, before setting the new position and rotation, item_to_center -> setRotation(0); //we must set the position and rotation to 0. + //Same for the mirror that keeps it readable on a mirrored element, + //given back below + item_to_center -> resetTransform(); item_to_center->setPos(item_to_center->mapFromScene(point)); @@ -87,6 +90,23 @@ bool centerToBottomDiagram (QGraphicsItem *item_to_center, Element *element_to_f if(rot != 0) { item_to_center->setRotation(item_to_center->rotation() - rot); } + element_to_follow->keepReadable(item_to_center); + + //On a mirrored element the item's box extends from its pos() the + //mirrored way, so it is not where the lines above meant. Put it + //there: centred on the element, its top at the same height. + if (element_to_follow->isMirrored() && item_to_center->parentItem()) + { + const QRectF placed = item_to_center->sceneBoundingRect(); + const QPointF wanted( + element_to_follow->sceneBoundingRect().center().x() + - placed.width() / 2, + border.bottom() - item_to_center->boundingRect().height() - offset); + QGraphicsItem *parent = item_to_center->parentItem(); + item_to_center->setPos(parent->mapFromScene( + parent->mapToScene(item_to_center->pos()) + + wanted - placed.topLeft())); + } return true; } diff --git a/sources/qetgraphicsitem/terminal.cpp b/sources/qetgraphicsitem/terminal.cpp index e9e114a05..70f7df795 100644 --- a/sources/qetgraphicsitem/terminal.cpp +++ b/sources/qetgraphicsitem/terminal.cpp @@ -118,13 +118,20 @@ Qet::Orientation Terminal::orientation() const // orientations actuelle et par defaut de l'element // current and default element orientations int ori_cur = elt -> orientation(); - if (ori_cur == 0) return(d->m_orientation); + // The mirrors of an element are about its own axes, before it is + // rotated (Element::setMirror()): a horizontal one swaps east and + // west, a vertical one north and south + Qet::Orientation ori_elmt = d->m_orientation; + if (Qet::isHorizontal(ori_elmt) ? elt -> hasHorizontalMirror() + : elt -> hasVerticalMirror()) + ori_elmt = (Qet::Orientation)((ori_elmt + 2) % 4); + if (ori_cur == 0) return(ori_elmt); else { // calcul l'angle de rotation implique par l'orientation de l'element parent // angle de rotation de la borne sur la scene, divise par 90 // calculates the angle of rotation implied by the orientation of the parent // element angle of rotation of the terminal on the scene, divided by 90 - int angle = ori_cur + d->m_orientation; + int angle = ori_cur + ori_elmt; while (angle >= 4) angle -= 4; return((Qet::Orientation)angle); } diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index f76aaaaec..a2da5bbe7 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -550,6 +550,8 @@ QStringList LiveServer::allowedCommands() QStringLiteral("diagrameditor.zoom_fit"), QStringLiteral("diagrameditor.zoom_reset"), QStringLiteral("diagrameditor.rotate_selection"), + QStringLiteral("diagrameditor.mirror_horizontal"), + QStringLiteral("diagrameditor.mirror_vertical"), QStringLiteral("diagrameditor.rotate_texts"), QStringLiteral("diagrameditor.snap_selection_to_grid"), QStringLiteral("diagrameditor.group_selection"), diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 3ddbb537a..d5c69ea89 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -61,6 +61,7 @@ #include "../qetgraphicsitem/dynamicelementtextitem.h" #include "../qetgraphicsitem/independenttextitem.h" #include "../qetgraphicsitem/qetshapeitem.h" +#include "../undocommand/mirrorselectioncommand.h" #include "../undocommand/promoteshapecommand.h" #include "../TerminalStrip/UndoCommand/addterminalstripcommand.h" #include "../TerminalStrip/UndoCommand/addterminaltostripcommand.h" @@ -833,6 +834,43 @@ bool QetScriptApi::rotateElement(int folioIndex, const QString &elementUuid, dou return true; } +/** + @brief QetScriptApi::mirrorElement + Mirror an element in place, as "Miroir horizontal" (left and right + swap) or, with @p vertical, "Miroir vertical" (top and bottom swap) + do on a selection. Mirroring twice the same way puts it back. + @return false if the element is not found or the project is read only +*/ +bool QetScriptApi::mirrorElement(int folioIndex, const QString &elementUuid, bool vertical) +{ + if (m_project && m_project->isReadOnly()) { + log(QStringLiteral("qet.mirrorElement: project is read-only")); + return false; + } + Element *element = findElement(folioIndex, elementUuid); + if (!element) return false; + + m_project->undoStack()->push(new MirrorSelectionCommand( + {element}, vertical ? Qt::Vertical : Qt::Horizontal)); + return true; +} + +/** + @brief QetScriptApi::elementMirror + @return the mirrors of the element about its own axes, as saved in the + project (Element::setMirror()): "horizontal", "vertical", "both", or "" + if it is not mirrored or not found. On a turned element, a horizontal + mirror of the folio is a vertical mirror of the element. +*/ +QString QetScriptApi::elementMirror(int folioIndex, const QString &elementUuid) const +{ + const Element *element = findElement(folioIndex, elementUuid); + if (!element || !element->isMirrored()) return QString(); + if (!element->hasVerticalMirror()) return QStringLiteral("horizontal"); + if (!element->hasHorizontalMirror()) return QStringLiteral("vertical"); + return QStringLiteral("both"); +} + QStringList QetScriptApi::elementUuids(int folioIndex) const { QStringList uuids; diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index be9bdf061..b6baa85b7 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -398,6 +398,8 @@ class QetScriptApi : public QObject Q_INVOKABLE bool setElementPosition(int folioIndex, const QString &elementUuid, double x, double y); Q_INVOKABLE bool moveElement(int folioIndex, const QString &elementUuid, double dx, double dy); Q_INVOKABLE bool rotateElement(int folioIndex, const QString &elementUuid, double angle); + Q_INVOKABLE bool mirrorElement(int folioIndex, const QString &elementUuid, bool vertical = false); + Q_INVOKABLE QString elementMirror(int folioIndex, const QString &elementUuid) const; Q_INVOKABLE bool deleteElement(int folioIndex, const QString &elementUuid); // -- address what is already there -- diff --git a/sources/undocommand/mirrorselectioncommand.cpp b/sources/undocommand/mirrorselectioncommand.cpp new file mode 100644 index 000000000..01c5783a5 --- /dev/null +++ b/sources/undocommand/mirrorselectioncommand.cpp @@ -0,0 +1,125 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "mirrorselectioncommand.h" + +#include "../QPropertyUndoCommand/qpropertyundocommand.h" +#include "../diagram.h" +#include "../qetgraphicsitem/element.h" + +/** + @brief MirrorSelectionCommand::MirrorSelectionCommand + Mirror the elements selected in @p diagram; the other selected items + are left as they are. See the other constructor. + @param diagram : the diagram whose selection is mirrored + @param orientation : Qt::Horizontal swaps left and right, + Qt::Vertical swaps top and bottom + @param parent : parent undo command +*/ +MirrorSelectionCommand::MirrorSelectionCommand(Diagram *diagram, + Qt::Orientation orientation, + QUndoCommand *parent) : + MirrorSelectionCommand(selectedElements(diagram), orientation, parent) +{} + +/** + @brief MirrorSelectionCommand::MirrorSelectionCommand + The mirrors of an element are about its own axes, before it is rotated + (Element::setMirror()). Turned by 0 or 180 degrees, the element's axes + are the folio's, so a horizontal mirror on the folio is the element's + own horizontal mirror; turned by 90 or 270 degrees, they are swapped, + so it is the element's vertical mirror. The rotation never changes. + Each element is mirrored in place: its centre stays where it was. + @param elements : the elements to mirror + @param orientation : Qt::Horizontal swaps left and right on the folio, + Qt::Vertical swaps top and bottom + @param parent : parent undo command +*/ +MirrorSelectionCommand::MirrorSelectionCommand(const QList &elements, + Qt::Orientation orientation, + QUndoCommand *parent) : + QUndoCommand(parent) +{ + setText(orientation == Qt::Horizontal + ? QObject::tr("Miroir horizontal") + : QObject::tr("Miroir vertical")); + + for (Element *element : elements) + { + if (!element) + continue; + + const bool turned = element->orientation() % 2; + const bool horizontal = (orientation == Qt::Horizontal) != turned; + + bool new_horizontal = element->hasHorizontalMirror(); + bool new_vertical = element->hasVerticalMirror(); + if (horizontal) + { + new_horizontal = !new_horizontal; + new QPropertyUndoCommand(element, "horizontalMirror", + !new_horizontal, new_horizontal, this); + } + else + { + new_vertical = !new_vertical; + new QPropertyUndoCommand(element, "verticalMirror", + !new_vertical, new_vertical, this); + } + + //The mirror is about the element's hotspot, which is often a + //corner of the symbol: move it so that it stays where it was, + //centre on centre, and on the grid + QPointF centre = element->boundingRect().center(); + centre = QTransform::fromScale(new_horizontal ? -1 : 1, new_vertical ? -1 : 1) + .map(centre); + centre = QTransform().rotate(element->rotation()).map(centre); + + const QPointF old_pos = element->pos(); + const QPointF new_pos = Diagram::snapToGrid( + element->mapToScene(element->boundingRect().center()) - centre); + if (new_pos != old_pos) + new QPropertyUndoCommand(element, "pos", old_pos, new_pos, this); + } +} + +/** + @brief MirrorSelectionCommand::selectedElements + @param diagram + @return the elements selected in @p diagram, none if it is read only +*/ +QList MirrorSelectionCommand::selectedElements(const Diagram *diagram) +{ + QList elements; + if (!diagram || diagram->isReadOnly()) + return elements; + + const QList items = diagram->selectedItems(); + for (QGraphicsItem *item : items) + if (Element *element = qgraphicsitem_cast(item)) + elements << element; + return elements; +} + +/** + @brief MirrorSelectionCommand::isValid + @return true if at least one element is mirrored by this command +*/ +bool MirrorSelectionCommand::isValid() const +{ + return childCount() > 0; +} diff --git a/sources/undocommand/mirrorselectioncommand.h b/sources/undocommand/mirrorselectioncommand.h new file mode 100644 index 000000000..b2f3a13b5 --- /dev/null +++ b/sources/undocommand/mirrorselectioncommand.h @@ -0,0 +1,48 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef MIRRORSELECTIONCOMMAND_H +#define MIRRORSELECTIONCOMMAND_H + +#include +#include + +class Diagram; +class Element; + +/** + @brief The MirrorSelectionCommand class + Mirror elements in place, horizontally (left and right swap) or + vertically (top and bottom swap), on the folio. See Element::setMirror() +*/ +class MirrorSelectionCommand : public QUndoCommand +{ + public: + MirrorSelectionCommand(Diagram *diagram, + Qt::Orientation orientation, + QUndoCommand *parent = nullptr); + MirrorSelectionCommand(const QList &elements, + Qt::Orientation orientation, + QUndoCommand *parent = nullptr); + + bool isValid() const; + + private: + static QList selectedElements(const Diagram *diagram); +}; + +#endif // MIRRORSELECTIONCOMMAND_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index b03046a5a..6be7351e9 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -663,6 +663,19 @@ if(QET_HAS_SCRIPTING) "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # Mirroring a symbol on its folio (#1335): its terminals swap sides and + # face the other way, a second mirror puts it back, and the mirror is + # kept by a save. Through the real binary's --run on perceuse.qet. + add_executable( + tst_scriptmirror + tst_scriptmirror.cpp) + add_test(NAME tst_scriptmirror COMMAND tst_scriptmirror) + add_dependencies(tst_scriptmirror qelectrotech) + target_link_libraries(tst_scriptmirror PRIVATE Qt::Test Qt::Xml) + target_compile_definitions(tst_scriptmirror PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # Undoing a crop of a picture restores its crop rectangle too, so a # project saved after the undo does not record the undone crop. add_executable( diff --git a/tests/qttest/tst_scriptmirror.cpp b/tests/qttest/tst_scriptmirror.cpp new file mode 100644 index 000000000..338b5e19d --- /dev/null +++ b/tests/qttest/tst_scriptmirror.cpp @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Mirroring a symbol on its folio (#1335), through the real binary's --run +// on perceuse.qet: qet.mirrorElement() pushes the same undo command as +// Edit > Miroir horizontal / vertical. A horizontal mirror swaps the +// terminals left and right and turns the ones facing east to face west; a +// vertical one does the same top and bottom; mirroring twice the same way +// puts the symbol back exactly; and a save keeps the mirror. +class tst_scriptmirror : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // Run @p script on @p project in a sandbox of its own and return the + // JSON object it logged. + QJsonObject run(const QString &script, const QString &project) + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write(script.toUtf8()); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, project}); + if (!proc.waitForFinished(120000)) return {}; + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + const QString mark = QStringLiteral("PROBE "); + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(mark); + if (i >= 0) + return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object(); + } + return {}; + } + + static QString facing(const QJsonArray &terminals, int i) + { + return terminals.at(i).toObject().value(QStringLiteral("facing")).toString(); + } + static double coord(const QJsonArray &terminals, int i, const char *axis) + { + return terminals.at(i).toObject().value(QLatin1String(axis)).toDouble(); + } + + // The facing of a terminal once mirrored: across a vertical line + // (horizontal mirror) east and west swap, across a horizontal one north + // and south do. + static QString mirrored(const QString &facing, bool vertical) + { + const QString swap = vertical ? QStringLiteral("ns") : QStringLiteral("ew"); + const int i = swap.indexOf(facing); + return i < 0 ? facing : QString(swap.at(1 - i)); + } + + // @p after is @p before mirrored horizontally (or vertically): the + // terminals face the mirrored way, keep the other coordinate, and are + // mirrored across one same line, so x + x' (or y + y') is the same for + // all of them. + static void checkMirrored(const QJsonArray &before, const QJsonArray &after, bool vertical) + { + QCOMPARE(after.size(), before.size()); + const char *across = vertical ? "y" : "x"; + const char *along = vertical ? "x" : "y"; + const double axis = coord(before, 0, across) + coord(after, 0, across); + const double shift = coord(after, 0, along) - coord(before, 0, along); + for (int i = 0; i < before.size(); ++i) { + QCOMPARE(facing(after, i), mirrored(facing(before, i), vertical)); + QCOMPARE(coord(before, i, across) + coord(after, i, across), axis); + QCOMPARE(coord(after, i, along) - coord(before, i, along), shift); + } + } + + static QString script(const QString &body) + { + return QStringLiteral( + "function terms(f, el) {\n" + " var r = [], n = qet.elementTerminals(f, el).length;\n" + " for (var i = 0; i < n; i++) r.push(qet.terminalPosition(f, el, i));\n" + " return r;\n" + "}\n") + body; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void mirrorBackAndSave() + { + const QString saved = m_dir.filePath(QStringLiteral("mirrored.qet")); + const QJsonObject r = run(script(QStringLiteral( + // a symbol with terminals at different x and different y, one + // of them facing east or west + "var pick = null;\n" + "for (var f = 0; f < qet.folioCount() && !pick; f++) {\n" + " var els = qet.elementUuids(f);\n" + " for (var e = 0; e < els.length && !pick; e++) {\n" + " var t = terms(f, els[e]);\n" + " if (t.length < 2) continue;\n" + " var side = t.some(function (p) { return p.facing === 'e' || p.facing === 'w'; });\n" + " var xs = t.some(function (p) { return p.x !== t[0].x; });\n" + " var ys = t.some(function (p) { return p.y !== t[0].y; });\n" + " if (side && xs && ys) pick = {f: f, e: els[e]};\n" + " }\n" + "}\n" + "var f = pick.f, el = pick.e, out = {element: el};\n" + "out.before = terms(f, el);\n" + "qet.mirrorElement(f, el, false);\n" + "out.h = terms(f, el); out.hMirror = qet.elementMirror(f, el);\n" + "qet.mirrorElement(f, el, false);\n" + "out.back = terms(f, el); out.backMirror = qet.elementMirror(f, el);\n" + "qet.mirrorElement(f, el, true);\n" + "out.v = terms(f, el); out.vMirror = qet.elementMirror(f, el);\n" + "qet.undo();\n" + "out.undone = terms(f, el);\n" + "qet.mirrorElement(f, el, true);\n" + "out.folio = f;\n" + "out.saved = qet.save(%1);\n" + "qet.log('PROBE ' + JSON.stringify(out));\n") + .arg(QString::fromUtf8(QJsonDocument(QJsonArray{saved}).toJson(QJsonDocument::Compact)).mid(1).chopped(1))), + QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + const QJsonArray before = r.value(QStringLiteral("before")).toArray(); + QVERIFY(before.size() >= 2); + + QVERIFY(!r.value(QStringLiteral("hMirror")).toString().isEmpty()); + checkMirrored(before, r.value(QStringLiteral("h")).toArray(), false); + + QCOMPARE(r.value(QStringLiteral("backMirror")).toString(), QString()); + QCOMPARE(r.value(QStringLiteral("back")).toArray(), before); + + checkMirrored(before, r.value(QStringLiteral("v")).toArray(), true); + QVERIFY(r.value(QStringLiteral("vMirror")).toString() + != r.value(QStringLiteral("hMirror")).toString()); + QCOMPARE(r.value(QStringLiteral("undone")).toArray(), before); + + //The save keeps the mirror, and only on that element + QVERIFY(r.value(QStringLiteral("saved")).toBool()); + QFile file(saved); + QVERIFY(file.open(QIODevice::ReadOnly)); + QDomDocument doc; + QVERIFY(bool(doc.setContent(&file))); + const QString element = r.value(QStringLiteral("element")).toString(); + const QDomNodeList elements = doc.elementsByTagName(QStringLiteral("element")); + int mirrored_count = 0; + for (int i = 0; i < elements.size(); ++i) { + const QDomElement e = elements.at(i).toElement(); + if (e.hasAttribute(QStringLiteral("mirror"))) { + ++mirrored_count; + QCOMPARE(e.attribute(QStringLiteral("uuid")), element); + QCOMPARE(e.attribute(QStringLiteral("mirror")), + r.value(QStringLiteral("vMirror")).toString()); + } + } + QCOMPARE(mirrored_count, 1); + + const QJsonObject reloaded = run(script(QStringLiteral( + "var f = %1, el = '%2';\n" + "qet.log('PROBE ' + JSON.stringify({t: terms(f, el), m: qet.elementMirror(f, el)}));\n") + .arg(r.value(QStringLiteral("folio")).toInt()).arg(element)), saved); + QCOMPARE(reloaded.value(QStringLiteral("m")).toString(), + r.value(QStringLiteral("vMirror")).toString()); + QCOMPARE(reloaded.value(QStringLiteral("t")).toArray(), + r.value(QStringLiteral("v")).toArray()); + } + + /** + On a turned symbol a mirror of the folio is the other mirror of the + symbol itself, and its rotation does not change: a label kept + upright does not swing round. + */ + void turnedElement_data() + { + QTest::addColumn("vertical"); + QTest::newRow("horizontal") << false; + QTest::newRow("vertical") << true; + } + + void turnedElement() + { + QFETCH(bool, vertical); + const QJsonObject r = run(script(QStringLiteral( + "var pick = null;\n" + "for (var f = 0; f < qet.folioCount() && !pick; f++) {\n" + " var els = qet.elementUuids(f);\n" + " for (var e = 0; e < els.length && !pick; e++) {\n" + " var t = terms(f, els[e]);\n" + " if (t.length >= 2 && t.some(function (p) { return p.x !== t[0].x; })\n" + " && t.some(function (p) { return p.y !== t[0].y; })) pick = {f: f, e: els[e]};\n" + " }\n" + "}\n" + "var f = pick.f, el = pick.e, out = {};\n" + "qet.rotateElement(f, el, 90);\n" + "out.before = terms(f, el);\n" + "qet.mirrorElement(f, el, %1);\n" + "out.after = terms(f, el); out.mirror = qet.elementMirror(f, el);\n" + "qet.log('PROBE ' + JSON.stringify(out));\n") + .arg(vertical ? QStringLiteral("true") : QStringLiteral("false"))), + QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + checkMirrored(r.value(QStringLiteral("before")).toArray(), + r.value(QStringLiteral("after")).toArray(), vertical); + //turned by 90 degrees, the symbol's own axes are the other way + QCOMPARE(r.value(QStringLiteral("mirror")).toString(), + vertical ? QStringLiteral("horizontal") : QStringLiteral("vertical")); + } +}; + +QTEST_APPLESS_MAIN(tst_scriptmirror) + +#include "tst_scriptmirror.moc" From a3b1468b0f7e37eced6f85b220f137f86a7d6833 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 7 Oct 2026 12:13:27 +1300 Subject: [PATCH 2/2] Mirror: terminal names, reload drawings, turned text groups, a leak Found by review of #1354: - Terminal names (shown on the folio) read backwards on a mirrored symbol: Terminal::paint() now mirrors them back about the centre of their box, in the frame they are drawn in. - "Reload the drawings of the symbols" left mirrored symbols with the old drawing: reloadPicture() refreshes the mirrored pictures too. - Turning a group of texts on a mirrored symbol left the group's readable mirror about the centre of its old box until the next layout: ElementTextItemGroup::setRotation() reapplies it, as texts already do. - The mirrored build of a symbol's drawing leaked the texts of its primitives, which only the drawing without mirror keeps. Co-Authored-By: Claude Opus 5.5 --- sources/factory/elementpicturefactory.cpp | 3 +++ sources/qetgraphicsitem/element.cpp | 7 +++++++ .../qetgraphicsitem/elementtextitemgroup.cpp | 4 ++++ sources/qetgraphicsitem/terminal.cpp | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+) diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index 6de56ad79..ba2892aa2 100644 --- a/sources/factory/elementpicturefactory.cpp +++ b/sources/factory/elementpicturefactory.cpp @@ -379,6 +379,9 @@ bool ElementPictureFactory::build(const ElementsLocation &location, const auto uuid_ = cacheKey(location); if (!mirror.isIdentity()) { + //The primitives are those of the drawing without mirror, + //already kept by the build of that one + qDeleteAll(primitives_.m_texts); const QPair key(uuid_, (mirror.m11() < 0 ? 1 : 0) | (mirror.m22() < 0 ? 2 : 0)); if (!picture) { diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index 67dbabca5..755898ee3 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -2186,6 +2186,13 @@ Element::ReloadPictureResult Element::reloadPicture() m_picture = picture; m_low_zoom_picture = low_zoom_picture; + if (isMirrored()) + ElementPictureFactory::instance()->getMirroredPictures( + m_location, + m_horizontal_mirror, + m_vertical_mirror, + m_mirrored_picture, + m_mirrored_low_zoom_picture); update(); return ReloadPictureResult::Reloaded; } diff --git a/sources/qetgraphicsitem/elementtextitemgroup.cpp b/sources/qetgraphicsitem/elementtextitemgroup.cpp index b9dc47a91..a14a5ef0c 100644 --- a/sources/qetgraphicsitem/elementtextitemgroup.cpp +++ b/sources/qetgraphicsitem/elementtextitemgroup.cpp @@ -648,6 +648,10 @@ QRectF ElementTextItemGroup::boundingRect() const void ElementTextItemGroup::setRotation(qreal angle) { QGraphicsItemGroup::setRotation(angle); + //On a mirrored element, the mirror that keeps this group readable + //is about the centre of its turned box + if (m_parent_element) + m_parent_element->keepReadable(this); emit rotationChanged(angle); } diff --git a/sources/qetgraphicsitem/terminal.cpp b/sources/qetgraphicsitem/terminal.cpp index 70f7df795..3a8564ab3 100644 --- a/sources/qetgraphicsitem/terminal.cpp +++ b/sources/qetgraphicsitem/terminal.cpp @@ -306,6 +306,23 @@ void Terminal::paint( QFontMetrics fm(d->m_label_font); QSizeF text_size = fm.size(Qt::TextSingleLine, display_name); + // On a mirrored element, mirror the name a second time about + // the centre of its box, in the frame it is drawn in (turned + // by label_rotation): its box stays where the element's mirror + // puts it, but it reads normally (Element::keepReadable()) + const Element *mirrored = qgraphicsitem_cast(parentItem()); + if (mirrored && !mirrored->isMirrored()) mirrored = nullptr; + auto keep_readable = [painter, mirrored](const QRectF &rect, qreal label_rotation) { + if (!mirrored) return; + const QTransform mirror = QTransform().rotate(label_rotation) + * mirrored->mirrorTransform() + * QTransform().rotate(-label_rotation); + const QPointF c = rect.center(); + painter->setTransform(QTransform::fromTranslate(-c.x(), -c.y()) + * mirror + * QTransform::fromTranslate(c.x(), c.y()), true); + }; + if (!qFuzzyIsNull(d->m_label_rotation)) { painter->save(); painter->translate(label_pos); @@ -321,6 +338,7 @@ void Terminal::paint( else if (d->m_label_valignment & Qt::AlignBottom) ry = -text_size.height(); QRectF text_rect(QPointF(rx, ry), text_size); + keep_readable(text_rect, d->m_label_rotation); painter->drawText(text_rect, static_cast(d->m_label_halignment | d->m_label_valignment), display_name); painter->restore(); } else { @@ -334,6 +352,7 @@ void Terminal::paint( else if (d->m_label_valignment & Qt::AlignBottom) dy = -text_size.height(); QRectF text_rect(label_pos + QPointF(dx, dy), text_size); + keep_readable(text_rect, 0); if (d->m_label_frame) { painter->drawRect(text_rect.adjusted(-1, -1, 1, 1)); }