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
<element> (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 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-07 11:01:27 +13:00
parent 0708338bce
commit a5b5d49b98
21 changed files with 923 additions and 33 deletions
+2
View File
@@ -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
+12 -1
View File
@@ -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"),
+15 -2
View File
@@ -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)
+42 -7
View File
@@ -37,6 +37,7 @@
#include "qetgraphicsitem/terminal.h"
#include "textlines.h"
#include <QFontMetricsF>
#include <QGraphicsSimpleTextItem>
#include <QSet>
#include <cmath>
@@ -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<Terminal *> 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));
+77 -4
View File
@@ -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<QUuid, int> 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<QUuid, int> 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;
+12 -1
View File
@@ -21,6 +21,8 @@
#include <QMutex>
#include <QSharedPointer>
#include <QHash>
#include <QPair>
#include <QTransform>
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<QUuid, QPicture> m_pictures_H;
QHash<QUuid, QPicture> m_low_pictures_H;
/// Drawings for mirrored elements, by element and mirror
/// (horizontal, vertical, both), see getMirroredPictures()
QHash<QPair<QUuid, int>, QPicture> m_mirrored_pictures_H;
QHash<QPair<QUuid, int>, QPicture> m_mirrored_low_pictures_H;
/// The mirror build() draws for, read by parseText()
QTransform m_build_mirror;
QHash<QUuid, QPixmap> m_pixmap_H;
QHash<QUuid, primitives> m_primitives_H;
static ElementPictureFactory* m_factory;
+24
View File
@@ -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();
+2
View File
@@ -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)
+7 -1
View File
@@ -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<QPointF> 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;
}
}
+198 -15
View File
@@ -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 <QAbstractTextDocumentLayout>
#include <QApplication>
#include <QCollator>
#include <QScreen>
@@ -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<QGraphicsItem *> children = childItems();
for (QGraphicsItem *child : children)
{
if (child->type() == DynamicElementTextItem::Type
|| child->type() == CrossRefItem::Type
|| dynamic_cast<ElementTextItemGroup *>(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;
}
+18
View File
@@ -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 <Terminal *> m_terminals;
QPicture m_picture;
QPicture m_low_zoom_picture;
QPicture m_mirrored_picture;
QPicture m_mirrored_low_zoom_picture;
ElementData m_data;
QList<QPointF> 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 <DynamicElementTextItem *> m_dynamic_text_list;
QList <ElementTextItemGroup *> m_texts_group;
@@ -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<DynamicElementTextItem *>(item))
@@ -232,6 +241,10 @@ void ElementTextItemGroup::updateAlignment()
prepareGeometryChange();
QList <DynamicElementTextItem *> 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);
@@ -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;
}
+9 -2
View File
@@ -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);
}
+2
View File
@@ -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"),
+38
View File
@@ -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;
+2
View File
@@ -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 --
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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<Element *> &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<Element *> MirrorSelectionCommand::selectedElements(const Diagram *diagram)
{
QList<Element *> elements;
if (!diagram || diagram->isReadOnly())
return elements;
const QList<QGraphicsItem *> items = diagram->selectedItems();
for (QGraphicsItem *item : items)
if (Element *element = qgraphicsitem_cast<Element *>(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;
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef MIRRORSELECTIONCOMMAND_H
#define MIRRORSELECTIONCOMMAND_H
#include <QList>
#include <QUndoCommand>
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<Element *> &elements,
Qt::Orientation orientation,
QUndoCommand *parent = nullptr);
bool isValid() const;
private:
static QList<Element *> selectedElements(const Diagram *diagram);
};
#endif // MIRRORSELECTIONCOMMAND_H
+13
View File
@@ -663,6 +663,19 @@ if(QET_HAS_SCRIPTING)
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
"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=\"$<TARGET_FILE:qelectrotech>\""
"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(
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// 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<bool>("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"