diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 6f5cbd9b6..dfa30c109 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -899,6 +899,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 397c6d30d..d61041d58 100644 --- a/sources/dxfexport.cpp +++ b/sources/dxfexport.cpp @@ -38,6 +38,7 @@ #include "qetinformation.h" #include "textlines.h" +#include #include #include #include @@ -48,14 +49,22 @@ namespace { Draw the drawing of @a elmt's definition: its static texts, lines, rectangles, circles, polygons and arcs, and its terminals when @a draw_terminals, as if it were placed at @a elem_pos_x, - @a elem_pos_y and turned @a rotation_angle degrees. Used for every - symbol drawn in full, and once per block for a block's content. + @a elem_pos_y and turned @a rotation_angle degrees, with @a elmt's + mirrors when @a mirrored. Used for every symbol drawn in full, and + once per block for a block's content. */ void drawSymbol(const QString &file_path, Element *elmt, qreal elem_pos_x, qreal elem_pos_y, - double rotation_angle, bool draw_terminals) + double rotation_angle, bool draw_terminals, + bool mirrored = false) { using namespace DxfExport; + //The mirrors of an element are about its own axes, before it is + //rotated, see Element::setMirror() + const bool mirror_h = mirrored && elmt -> hasHorizontalMirror(); + const bool mirror_v = mirrored && elmt -> hasVerticalMirror(); + const qreal mirror_x = mirror_h ? -1 : 1; + const qreal mirror_y = mirror_v ? -1 : 1; ElementPictureFactory::primitives primitives = ElementPictureFactory::instance()->getPrimitives(elmt->location()); Createdxf::layer = Layer::SymbolTexts; @@ -65,8 +74,26 @@ namespace { 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 (mirror_h || mirror_v) + { + //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; @@ -91,21 +118,21 @@ namespace { 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); } @@ -114,7 +141,7 @@ namespace { { 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); @@ -133,6 +160,18 @@ namespace { 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 (mirror_h) + { + x = elem_pos_x - arc.at(0) - w; + startAngle = 180 - startAngle - spanAngle; + } + if (mirror_v) + { + 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); @@ -142,7 +181,7 @@ namespace { // 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)); @@ -345,7 +384,7 @@ void DxfExport::write(Diagram *diagram, int width, int height, QSet used; for (Element *elmt : std::as_const(list_elements)) { const QString key = elmt -> location().toString(); - if (block_names.contains(key) + if (elmt -> isMirrored() || block_names.contains(key) || drawsNothing(elmt, properties.draw_terminals)) continue; QString file_name = elmt -> location().fileName(); @@ -363,6 +402,14 @@ void DxfExport::write(Diagram *diagram, int width, int height, block_models << elmt; } } + //A mirrored symbol is drawn in full: an INSERT with a negative + //scale would mirror its texts too, which QElectroTech keeps + //readable + const auto blockOf = [&block_names](Element *elmt) { + return elmt -> isMirrored() + ? QString() + : block_names.value(elmt -> location().toString()); + }; //With dxf_attributes, a symbol's own texts (its label, its //function...) become attributes of its INSERT, written where the @@ -384,9 +431,7 @@ void DxfExport::write(Diagram *diagram, int width, int height, break; auto *deti = qgraphicsitem_cast(dti); Element *elmt = deti ? deti -> parentElement() : nullptr; - const QString block = elmt - ? block_names.value(elmt -> location().toString()) - : QString(); + const QString block = elmt ? blockOf(elmt) : QString(); if (block.isEmpty()) continue; @@ -445,7 +490,7 @@ void DxfExport::write(Diagram *diagram, int width, int height, //repeated; the label formula is how the label is made, not //part data. for (Element *elmt : std::as_const(list_elements)) { - const QString block = block_names.value(elmt -> location().toString()); + const QString block = blockOf(elmt); if (block.isEmpty()) continue; const DiagramContext information = elmt -> elementInformations(); @@ -525,10 +570,10 @@ void DxfExport::write(Diagram *diagram, int width, int height, const qreal elem_pos_x = elmt -> pos().x(); const qreal elem_pos_y = elmt -> pos().y(); - const QString block = block_names.value(elmt -> location().toString()); + const QString block = blockOf(elmt); if (block.isEmpty()) { drawSymbol(file_path, elmt, elem_pos_x, elem_pos_y, - rotation_angle, properties.draw_terminals); + rotation_angle, properties.draw_terminals, true); } else { Createdxf::layer = Layer::Symbols; //QElectroTech turns a symbol clockwise, DXF counter-clockwise diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index 2603d1bf5..d060f46d9 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); //The text items belong to the cache; nothing else holds them. qDeleteAll(m_primitives_H.take(uuid).m_texts); @@ -217,11 +255,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(); @@ -281,6 +324,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; @@ -333,9 +378,24 @@ bool ElementPictureFactory::build(const ElementsLocation &location, //End of the drawing painter.end(); low_painter.end(); + m_build_mirror = QTransform(); qDeleteAll(low_primitives.m_texts); 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) { + 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_); @@ -616,8 +676,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; @@ -651,7 +709,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 5e72f57c2..2dee574ba 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -78,6 +78,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" @@ -982,6 +983,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")); @@ -992,6 +995,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); @@ -1085,12 +1091,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"); @@ -2384,6 +2394,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); @@ -2621,6 +2641,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 @@ -2644,6 +2666,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 0cc42c171..ee6292a52 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -271,6 +271,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..755898ee3 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; } @@ -2003,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/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..a14a5ef0c 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); @@ -630,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/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..3a8564ab3 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); } @@ -299,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); @@ -314,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 { @@ -327,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)); } 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 502887209..e02e9e26e 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -775,6 +775,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"