diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 41380463c..4593d5d56 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -177,6 +177,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/conductornumexport.cpp ${QET_DIR}/sources/wiringlistexport.h ${QET_DIR}/sources/wiringlistexport.cpp + ${QET_DIR}/sources/wirehops.h + ${QET_DIR}/sources/wirehops.cpp ${QET_DIR}/sources/ui/wiringlistdialog.h ${QET_DIR}/sources/ui/wiringlistdialog.cpp ${QET_DIR}/sources/conductornumexport.h diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index a82dc5ced..4d658129f 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -32,10 +32,34 @@ #include "element.h" #include "../QetGraphicsItemModeler/qetgraphicshandleritem.h" #include "../utils/qetutils.h" +#include "../wirehops.h" #include #include +namespace { + //Bumped whenever a conductor changes shape, moves, or enters or + //leaves a folio: the hops of every conductor may then change. + //0 is never current, so a fresh cache is always rebuilt. + quint64 s_conductor_geometry_generation = 1; + void conductorGeometryChanged() { ++s_conductor_geometry_generation; } + + //The conductors of the last folio whose hops were computed, as + //scene points and plain path bounds. Rebuilt only when the folio + //or the generation changes, so a repaint (pan, zoom, selection) + //costs no scene query: asking the scene for the conductors in a + //rect strokes each one's shape, and on a folio of a few hundred + //conductors that made every repaint 0.6 s slower. + struct HopCandidate { + const Conductor *conductor; + QRectF rect; + QVector points; + }; + const Diagram *s_hop_candidates_diagram = nullptr; + quint64 s_hop_candidates_generation = 0; + QVector s_hop_candidates; +} + #define PR(x) qDebug() << #x " = " << x; bool Conductor::pen_and_brush_initialized = false; @@ -147,6 +171,7 @@ Conductor::Conductor(Terminal *p1, Terminal* p2) : */ Conductor::~Conductor() { + conductorGeometryChanged(); removeHandler(); terminal1->removeConductor(this); terminal2->removeConductor(this); @@ -602,8 +627,9 @@ void Conductor::paint(QPainter *painter, const QStyleOptionGraphicsItem *options painter -> setPen(final_conductor_pen); - //Draw the conductor - painter -> drawPath(path()); + //Draw the conductor, with a hop at each crossing if the project asks for them + const QPainterPath painted_path = paintedPath(); + painter -> drawPath(painted_path); //Draw the second color if(m_properties.m_bicolor) { @@ -614,7 +640,7 @@ void Conductor::paint(QPainter *painter, const QStyleOptionGraphicsItem *options final_conductor_pen.setDashPattern(dash_pattern); painter->save(); painter->setPen(final_conductor_pen); - painter->drawPath(path()); + painter->drawPath(painted_path); painter->restore(); } @@ -790,6 +816,7 @@ QVariant Conductor::itemChange(GraphicsItemChange change, const QVariant &value) } else if (change == QGraphicsItem::ItemSceneHasChanged) { + conductorGeometryChanged(); calculateTextItemPosition(); if(!scene()) @@ -803,6 +830,9 @@ QVariant Conductor::itemChange(GraphicsItemChange change, const QVariant &value) else if (change == QGraphicsItem::ItemPositionHasChanged && isSelected()) { adjustHandlerPos(); } + else if (change == QGraphicsItem::ItemScenePositionHasChanged) { + conductorGeometryChanged(); + } return(QGraphicsObject::itemChange(change, value)); } @@ -1657,6 +1687,7 @@ void Conductor::setPath(const QPainterPath &path) prepareGeometryChange(); m_path = path; + conductorGeometryChanged(); update(); } @@ -1942,6 +1973,87 @@ bool isContained(const QPointF &a, const QPointF &b, const QPointF &c) { ); } +/** + @brief Conductor::paintedPath + @return the path drawn for this conductor: path(), with a hop at each + place it crosses another conductor when the project draws hops + (issue #436, QETProject::wireHops()). Cached until a conductor changes + shape, moves, or enters or leaves a folio, so a repaint costs nothing. + A moved conductor still repaints the hops of the others: a crossing + lies inside the bounding rect of both conductors, which Qt repaints + when either moves. +*/ +QPainterPath Conductor::paintedPath() const +{ + const Diagram *parent_diagram = diagram(); + if (!parent_diagram || !parent_diagram->project()) { + return path(); + } + const WireHops::Mode mode = parent_diagram->project()->wireHops(); + if (mode == WireHops::Mode::None) { + return path(); + } + if (m_hops_generation == s_conductor_geometry_generation + && m_hops_mode == int(mode)) { + return m_hops_path; + } + + if (s_hop_candidates_diagram != parent_diagram + || s_hop_candidates_generation != s_conductor_geometry_generation) + { + s_hop_candidates.clear(); + const QList conductors = parent_diagram->conductors(); + for (const Conductor *conductor : conductors) + { + HopCandidate candidate; + candidate.conductor = conductor; + candidate.rect = conductor->mapRectToScene(conductor->path().boundingRect()); + for (const QPointF &point : conductor->segmentsToPoints()) { + candidate.points.append(conductor->mapToScene(point)); + } + s_hop_candidates.append(candidate); + } + s_hop_candidates_diagram = parent_diagram; + s_hop_candidates_generation = s_conductor_geometry_generation; + } + + m_hops_path = path(); + m_hops_generation = s_conductor_geometry_generation; + m_hops_mode = int(mode); + + const QList points = segmentsToPoints(); + if (points.size() < 2) { + return m_hops_path; + } + QVector wire; + for (const QPointF &point : points) { + wire.append(mapToScene(point)); + } + + //A conductor crossing this one has path bounds overlapping its own; + //adjusted by 1 because a straight conductor's bounds have no width + const QRectF rect = mapRectToScene(path().boundingRect()).adjusted(-1, -1, 1, 1); + QList> others; + for (const HopCandidate &candidate : std::as_const(s_hop_candidates)) + { + if (candidate.conductor != this + && rect.intersects(candidate.rect.adjusted(-1, -1, 1, 1))) { + others.append(candidate.points); + } + } + + const QList scene_hops = WireHops::crossings(wire, others, mode); + if (!scene_hops.isEmpty()) + { + QList hops; + for (const QPointF &hop : scene_hops) { + hops.append(mapFromScene(hop)); + } + m_hops_path = WireHops::path(QVector(points.begin(), points.end()), hops, mode); + } + return m_hops_path; +} + /** @return la liste des positions des jonctions avec d'autres conducteurs */ diff --git a/sources/qetgraphicsitem/conductor.h b/sources/qetgraphicsitem/conductor.h index c2f3232e5..46976129a 100644 --- a/sources/qetgraphicsitem/conductor.h +++ b/sources/qetgraphicsitem/conductor.h @@ -143,6 +143,7 @@ class Conductor : public QGraphicsObject void setSequenceNum(const autonum::sequentialNumbers& sn); QList junctions() const; + QPainterPath paintedPath() const; private: void setUpConnectionForFormula( @@ -187,6 +188,10 @@ class Conductor : public QGraphicsObject QVector m_handler_vector; int m_vector_index = -1; bool m_mouse_over; + ///Hop path cache for paintedPath(), valid while no conductor geometry changed + mutable QPainterPath m_hops_path; + mutable quint64 m_hops_generation = 0; + mutable int m_hops_mode = -1; /// Functional properties ConductorProperties m_properties; /// Text input for non simple, non-singleline conductors diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index d126b45f9..21c6f83ce 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -1232,6 +1232,31 @@ void QETProject::setAutoConductor(bool ac) m_auto_conductor = ac; } +/** + @brief QETProject::wireHops + @return which wire of a crossing draws a hop (issue #436), + WireHops::Mode::None when crossings are drawn as plain lines. +*/ +WireHops::Mode QETProject::wireHops() const { + return m_wire_hops; +} + +/** + @brief QETProject::setWireHops + Set which wire of a crossing draws a hop, and redraw every folio. + @param mode +*/ +void QETProject::setWireHops(WireHops::Mode mode) +{ + if (mode == m_wire_hops) { + return; + } + m_wire_hops = mode; + for (Diagram *diagram : diagrams()) { + diagram->update(); + } +} + /** @brief QETProject::autoBreakConductor @return true if use of auto break conductor is authorized. @@ -1341,6 +1366,7 @@ QDomDocument QETProject::toXml() // local, non-transmitted usage tracking (time spent on this project) writeUsageXml(project_root); + writeWireHopsXml(project_root); // Properties for news diagrams QDomElement new_diagrams_properties = xml_doc.createElement("newdiagrams"); @@ -1850,6 +1876,7 @@ void QETProject::readProjectXml(QDomDocument &xml_project) //Load the local, non-transmitted usage tracking readUsageXml(xml_project); + readWireHopsXml(xml_project); //Load the default properties for the new diagrams readDefaultPropertiesXml(xml_project); @@ -2028,6 +2055,21 @@ void QETProject::readUsageXml(QDomDocument &xml_project) m_project_properties_handler.usageTracker().fromXml(xml_project.documentElement()); } +/** + @brief QETProject::readWireHopsXml + Read the element of the project, if any. + A project without it draws no hop. + @param xml_project : the xml description of the project +*/ +void QETProject::readWireHopsXml(QDomDocument &xml_project) +{ + const QDomElement crossings = xml_project.documentElement() + .firstChildElement(QStringLiteral("wire_crossings")); + m_wire_hops = crossings.isNull() + ? WireHops::Mode::None + : WireHops::fromString(crossings.attribute(QStringLiteral("hop"))); +} + /** @brief QETProject::readDefaultPropertiesXml load default properties for new diagram, found in the xml of this project @@ -2173,6 +2215,23 @@ void QETProject::writeUsageXml(QDomElement &xml_element) { m_project_properties_handler.usageTracker().toXml(xml_element); } +/** + @brief QETProject::writeWireHopsXml + Export which wire of a crossing hops as a child of + \a xml_element. Written only when hops are on, so a project that never + used them saves exactly as before. +*/ +void QETProject::writeWireHopsXml(QDomElement &xml_element) +{ + if (m_wire_hops == WireHops::Mode::None) { + return; + } + QDomElement crossings = xml_element.ownerDocument() + .createElement(QStringLiteral("wire_crossings")); + crossings.setAttribute(QStringLiteral("hop"), WireHops::toString(m_wire_hops)); + xml_element.appendChild(crossings); +} + /** @brief QETProject::writeDefaultPropertiesXml Export all defaults properties used by a new diagram and his content diff --git a/sources/qetproject.h b/sources/qetproject.h index 9e260bcc4..d2fe564f2 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -28,6 +28,7 @@ #include "properties/xrefproperties.h" #include "titleblock/templatescollection.h" #include "titleblockproperties.h" +#include "wirehops.h" #include "diagram.h" #ifdef BUILD_WITHOUT_KF # include "ui/nokde/kautosavefile.h" @@ -208,6 +209,8 @@ class QETProject : public QObject bool autoElement () const; bool autoFolio () const; void setAutoConductor (bool ac); + WireHops::Mode wireHops() const; + void setWireHops(WireHops::Mode mode); void setAutoBreakConductor (bool abc); void setAutoElement (bool ae); void autoFolioNumberingNewFolios (); @@ -304,10 +307,12 @@ class QETProject : public QObject void readDefaultPropertiesXml(QDomDocument &xml_project); void readTerminalStripXml(const QDomDocument &xml_project); void readUsageXml(QDomDocument &xml_project); + void readWireHopsXml(QDomDocument &xml_project); void writeProjectPropertiesXml(QDomElement &); void writeDefaultPropertiesXml(QDomElement &); void writeUsageXml(QDomElement &); + void writeWireHopsXml(QDomElement &); void addDiagram(Diagram *diagram, int pos = -1); void detachDiagram(Diagram *diagram); void writeBackup(); @@ -365,6 +370,7 @@ class QETProject : public QObject QHash m_element_autonum; //Title and NumContext hash QString m_current_element_autonum; bool m_auto_conductor = true; + WireHops::Mode m_wire_hops = WireHops::Mode::None; bool m_auto_break_conductor = false; XmlElementCollection *m_elements_collection = nullptr; bool m_freeze_new_elements = false; diff --git a/sources/ui/configpage/projectconfigpages.cpp b/sources/ui/configpage/projectconfigpages.cpp index 0d38d6f76..feae7a034 100644 --- a/sources/ui/configpage/projectconfigpages.cpp +++ b/sources/ui/configpage/projectconfigpages.cpp @@ -170,6 +170,12 @@ void ProjectMainConfigPage::applyProjectConf() modified_project = true; } + const auto wire_hops = WireHops::fromString(wire_hops_cb_ -> currentData().toString()); + if (m_project -> wireHops() != wire_hops) { + m_project -> setWireHops(wire_hops); + modified_project = true; + } + if (modified_project) { m_project -> setModified(true); } @@ -206,6 +212,19 @@ void ProjectMainConfigPage::initWidgets() usage_enabled_cb_ = new QCheckBox(tr("Suivre le temps passé sur ce projet (uniquement enregistré localement dans ce fichier)", "checkbox label")); usage_reset_pb_ = new QPushButton(tr("Réinitialiser", "button label")); connect(usage_reset_pb_, &QPushButton::clicked, this, &ProjectMainConfigPage::resetUsageTracker); + + //Hops where two conductors cross without being connected (issue #436) + wire_hops_label_ = new QLabel(tr("Croisements de conducteurs :", "label when configuring")); + wire_hops_cb_ = new QComboBox(); + wire_hops_cb_ -> addItem(tr("Sans saut", "wire crossings"), + WireHops::toString(WireHops::Mode::None)); + wire_hops_cb_ -> addItem(tr("Saut sur les conducteurs horizontaux", "wire crossings"), + WireHops::toString(WireHops::Mode::Horizontal)); + wire_hops_cb_ -> addItem(tr("Saut sur les conducteurs verticaux", "wire crossings"), + WireHops::toString(WireHops::Mode::Vertical)); + wire_hops_cb_ -> setToolTip(tr("Dessine un petit arc là où deux conducteurs se croisent sans être reliés. " + "Seul le dessin change : aucun élément n'est ajouté et aucun conducteur n'est coupé.", + "tooltip")); } /** @@ -231,6 +250,13 @@ void ProjectMainConfigPage::initLayout() usage_layout0 -> addWidget(usage_reset_pb_); main_layout0 -> addLayout(usage_layout0); main_layout0 -> addWidget(usage_enabled_cb_); + main_layout0 -> addSpacing(10); + + QHBoxLayout *wire_hops_layout0 = new QHBoxLayout(); + wire_hops_layout0 -> addWidget(wire_hops_label_); + wire_hops_layout0 -> addWidget(wire_hops_cb_); + wire_hops_layout0 -> addStretch(); + main_layout0 -> addLayout(wire_hops_layout0); setLayout(main_layout0); this -> setMinimumWidth(680); @@ -251,6 +277,9 @@ void ProjectMainConfigPage::readValuesFromProject() .arg(total_seconds / 3600) .arg((total_seconds % 3600) / 60)); usage_enabled_cb_ -> setChecked(usage_tracker.isEnabled()); + + const int wire_hops_index = wire_hops_cb_ -> findData(WireHops::toString(m_project -> wireHops())); + wire_hops_cb_ -> setCurrentIndex(qMax(0, wire_hops_index)); } /** @@ -277,6 +306,7 @@ void ProjectMainConfigPage::adjustReadOnly() title_value_ -> setReadOnly(is_read_only); usage_enabled_cb_ -> setDisabled(is_read_only); usage_reset_pb_ -> setDisabled(is_read_only); + wire_hops_cb_ -> setDisabled(is_read_only); } //######################################################################################// diff --git a/sources/ui/configpage/projectconfigpages.h b/sources/ui/configpage/projectconfigpages.h index fa9929e6b..7375d83ff 100644 --- a/sources/ui/configpage/projectconfigpages.h +++ b/sources/ui/configpage/projectconfigpages.h @@ -20,6 +20,7 @@ #include "configpage.h" class QLabel; +class QComboBox; class QLineEdit; class QCheckBox; class QPushButton; @@ -125,6 +126,8 @@ class ProjectMainConfigPage : public ProjectConfigPage { QLabel *usage_value_; QCheckBox *usage_enabled_cb_; QPushButton *usage_reset_pb_; + QLabel *wire_hops_label_; + QComboBox *wire_hops_cb_; }; class ProjectAutoNumConfigPage : public ProjectConfigPage { diff --git a/sources/wirehops.cpp b/sources/wirehops.cpp new file mode 100644 index 000000000..fd48bb129 --- /dev/null +++ b/sources/wirehops.cpp @@ -0,0 +1,211 @@ +/* + 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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "wirehops.h" + +#include +#include + +namespace { + //Coordinates closer than this are the same; points are on a grid + constexpr qreal tolerance = 0.01; + + bool isHorizontal(const QPointF &a, const QPointF &b) { + return qAbs(a.y() - b.y()) < tolerance && qAbs(a.x() - b.x()) >= tolerance; + } + + bool isVertical(const QPointF &a, const QPointF &b) { + return qAbs(a.x() - b.x()) < tolerance && qAbs(a.y() - b.y()) >= tolerance; + } + + //True if @a value is strictly between @a a and @a b, by more than @a margin + bool strictlyBetween(qreal value, qreal a, qreal b, qreal margin) { + return value > qMin(a, b) + margin && value < qMax(a, b) - margin; + } + + //The hops of @a hops lying on the segment @a a - @a b, ordered from @a a + QList hopsOnSegment(const QPointF &a, const QPointF &b, + const QList &hops, bool horizontal) + { + QList on_segment; + for (const QPointF &hop : hops) + { + if (horizontal + ? (qAbs(hop.y() - a.y()) < tolerance && strictlyBetween(hop.x(), a.x(), b.x(), 0)) + : (qAbs(hop.x() - a.x()) < tolerance && strictlyBetween(hop.y(), a.y(), b.y(), 0))) + { + on_segment.append(hop); + } + } + std::sort(on_segment.begin(), on_segment.end(), [&a](const QPointF &p, const QPointF &q) { + return QLineF(a, p).length() < QLineF(a, q).length(); + }); + return on_segment; + } +} + +/** + @brief WireHops::toString + @return the value written in the project file for @a mode +*/ +QString WireHops::toString(Mode mode) +{ + switch (mode) { + case Mode::Horizontal: return QStringLiteral("horizontal"); + case Mode::Vertical: return QStringLiteral("vertical"); + case Mode::None: break; + } + return QStringLiteral("none"); +} + +/** + @brief WireHops::fromString + @return the mode written as @a string in a project file, + Mode::None for anything else, so an unknown value draws no hop. +*/ +WireHops::Mode WireHops::fromString(const QString &string) +{ + if (string == QLatin1String("horizontal")) return Mode::Horizontal; + if (string == QLatin1String("vertical")) return Mode::Vertical; + return Mode::None; +} + +/** + @brief WireHops::crossings + @param wire : the points of the wire, in scene coordinates + @param others : the points of every other wire that may cross it, + in scene coordinates + @param mode : which wire of a crossing hops + @return the points where @a wire hops, in the order of @a wire. + + A crossing is kept only if it lies strictly inside the segment of the + other wire (a wire ending or bending on this one is a junction, not a + crossing) and at least one hop radius from the ends of this wire's + segment, so the arc fits. Of two crossings closer than one arc + width, only the first hops. +*/ +QList WireHops::crossings(const QVector &wire, + const QList> &others, + Mode mode) +{ + QList hops; + if (mode == Mode::None || wire.size() < 2) { + return hops; + } + + const bool horizontal = mode == Mode::Horizontal; + for (int i = 0 ; i < wire.size() - 1 ; ++i) + { + const QPointF a = wire.at(i); + const QPointF b = wire.at(i + 1); + if (horizontal ? !isHorizontal(a, b) : !isVertical(a, b)) { + continue; + } + + QList on_segment; + for (const auto &other : others) + { + for (int j = 0 ; j < other.size() - 1 ; ++j) + { + const QPointF c = other.at(j); + const QPointF d = other.at(j + 1); + if (horizontal ? !isVertical(c, d) : !isHorizontal(c, d)) { + continue; + } + + const QPointF crossing = horizontal ? QPointF(c.x(), a.y()) + : QPointF(a.x(), c.y()); + const bool inside_wire = horizontal + ? strictlyBetween(crossing.x(), a.x(), b.x(), radius) + : strictlyBetween(crossing.y(), a.y(), b.y(), radius); + const bool inside_other = horizontal + ? strictlyBetween(crossing.y(), c.y(), d.y(), tolerance) + : strictlyBetween(crossing.x(), c.x(), d.x(), tolerance); + if (inside_wire && inside_other) { + on_segment.append(crossing); + } + } + } + + std::sort(on_segment.begin(), on_segment.end(), [&a](const QPointF &p, const QPointF &q) { + return QLineF(a, p).length() < QLineF(a, q).length(); + }); + for (const QPointF &crossing : std::as_const(on_segment)) + { + if (!hops.isEmpty() + && QLineF(hops.last(), crossing).length() < 2 * radius + tolerance) { + continue; + } + hops.append(crossing); + } + } + return hops; +} + +/** + @brief WireHops::path + @param wire : the points of the wire + @param hops : the points where the wire hops, from WireHops::crossings, + in the same coordinates as @a wire + @param mode : which wire of a crossing hops + @return the path of the wire, with a half circle at each hop: above a + horizontal wire, right of a vertical one. With no hop, the same path + as the plain wire. +*/ +QPainterPath WireHops::path(const QVector &wire, + const QList &hops, + Mode mode) +{ + QPainterPath path; + if (wire.isEmpty()) { + return path; + } + + const bool horizontal = mode == Mode::Horizontal; + path.moveTo(wire.first()); + for (int i = 0 ; i < wire.size() - 1 ; ++i) + { + const QPointF a = wire.at(i); + const QPointF b = wire.at(i + 1); + const bool hops_here = mode != Mode::None + && (horizontal ? isHorizontal(a, b) : isVertical(a, b)); + + if (hops_here) + { + //1 when the wire runs towards +x (or +y), -1 otherwise + const qreal direction = horizontal ? (b.x() > a.x() ? 1 : -1) + : (b.y() > a.y() ? 1 : -1); + for (const QPointF &hop : hopsOnSegment(a, b, hops, horizontal)) + { + const QRectF circle(hop.x() - radius, hop.y() - radius, + 2 * radius, 2 * radius); + if (horizontal) { + //Over the top: from the left end to the right one, or back + path.lineTo(hop.x() - direction * radius, hop.y()); + path.arcTo(circle, direction > 0 ? 180 : 0, direction > 0 ? -180 : 180); + } else { + //On the right: from the top end to the bottom one, or back + path.lineTo(hop.x(), hop.y() - direction * radius); + path.arcTo(circle, direction > 0 ? 90 : 270, direction > 0 ? -180 : 180); + } + } + } + path.lineTo(b); + } + return path; +} diff --git a/sources/wirehops.h b/sources/wirehops.h new file mode 100644 index 000000000..ce99570a8 --- /dev/null +++ b/sources/wirehops.h @@ -0,0 +1,63 @@ +/* + 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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef WIREHOPS_H +#define WIREHOPS_H + +#include +#include +#include +#include +#include + +/** + @brief The WireHops namespace + Hops (small arcs) drawn where two wires cross without being connected + (issue #436). Only the drawing changes: no element is added and no wire + is split. Kept free of any QGraphicsItem so the geometry can be tested + on its own. + + Wires in QElectroTech are made of horizontal and vertical segments + only, so a crossing is a segment of one orientation passing through a + segment of the other, away from the ends of both. A wire that ends or + bends on another one is a junction, not a crossing, and never hops. +*/ +namespace WireHops +{ + ///Which wire of a crossing draws the hop + enum class Mode { + None, ///< no hop, the default: crossings are drawn as plain lines + Horizontal, ///< the horizontal wire hops over the vertical one + Vertical ///< the vertical wire hops over the horizontal one + }; + + ///Radius of a hop, in scene units (a grid step is 10) + constexpr qreal radius = 4.0; + + QString toString(Mode mode); + Mode fromString(const QString &string); + + QList crossings(const QVector &wire, + const QList> &others, + Mode mode); + QPainterPath path(const QVector &wire, + const QList &hops, + Mode mode); +} + +#endif // WIREHOPS_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 43ef7ee93..e428b6d49 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -376,6 +376,20 @@ target_compile_definitions(tst_wiringlistexport PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") +# Hops where wires cross (issue #436): the geometry in wirehops.cpp, and +# through the real binary, that --resave keeps the project setting and +# adds nothing to a project without it. +add_executable( + tst_wirehops + tst_wirehops.cpp + ${QET_DIR}/sources/wirehops.cpp) +add_test(NAME tst_wirehops COMMAND tst_wirehops) +add_dependencies(tst_wirehops qelectrotech) +target_include_directories(tst_wirehops PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_wirehops PRIVATE Qt::Test Qt::Gui) +target_compile_definitions(tst_wirehops PRIVATE + "QET_TEST_BINARY_PATH=\"$\"") + # qet.conductorUuids() / qet.conductorEnds(): a script lists a folio's # conductors by uuid and finds each one's two ends. Runs a script through # the real binary's --run on fixtures/qet_bug_repro_resaved.qet, so only diff --git a/tests/qttest/tst_wirehops.cpp b/tests/qttest/tst_wirehops.cpp new file mode 100644 index 000000000..a765ae1c8 --- /dev/null +++ b/tests/qttest/tst_wirehops.cpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include + +#include "wirehops.h" + +// Hops where two wires cross (issue #436). The geometry is tested on its +// own; the project setting is tested through the real binary: --resave +// must keep a element, and must not add one to a project +// that never had it, so existing projects save exactly as before. +class tst_wirehops : public QObject +{ + Q_OBJECT + + using Mode = WireHops::Mode; + QTemporaryDir m_dir; + int m_run = 0; + + static QVector line(QPointF a, QPointF b) { return {a, b}; } + + // Runs --resave on @p in, returns the saved file's text (empty on failure) + QString resave(const QString &in) + { + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run)); + const QString tmp = m_dir.filePath(QStringLiteral("tmp%1").arg(m_run)); + const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run++)); + QDir().mkpath(home); + QDir().mkpath(tmp); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/.config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/.local/share")); + env.insert(QStringLiteral("TMPDIR"), tmp); + + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--resave"), in, out}); + if (!proc.waitForFinished(120000) || proc.exitCode() != 0) + return {}; + QFile file(out); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + return QString::fromUtf8(file.readAll()); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + } + + void modeNames() + { + QCOMPARE(int(WireHops::fromString(WireHops::toString(Mode::Horizontal))), int(Mode::Horizontal)); + QCOMPARE(int(WireHops::fromString(WireHops::toString(Mode::Vertical))), int(Mode::Vertical)); + QCOMPARE(int(WireHops::fromString(WireHops::toString(Mode::None))), int(Mode::None)); + QCOMPARE(int(WireHops::fromString(QStringLiteral("diagonal"))), int(Mode::None)); + QCOMPARE(int(WireHops::fromString(QString())), int(Mode::None)); + } + + void crossing_data() + { + QTest::addColumn>("wire"); + QTest::addColumn>("other"); + QTest::addColumn("mode"); + QTest::addColumn>("expected"); + + const auto horizontal = line({0, 0}, {100, 0}); + const auto vertical = line({50, -20}, {50, 20}); + const int h = int(Mode::Horizontal), v = int(Mode::Vertical), none = int(Mode::None); + + QTest::newRow("off") << horizontal << vertical << none << QList(); + QTest::newRow("horizontal hops") << horizontal << vertical << h << QList{{50, 0}}; + QTest::newRow("horizontal mode, vertical wire") << vertical << horizontal << h << QList(); + QTest::newRow("vertical hops") << vertical << horizontal << v << QList{{50, 0}}; + QTest::newRow("vertical mode, horizontal wire") << horizontal << vertical << v << QList(); + QTest::newRow("reversed wire") << line({100, 0}, {0, 0}) << vertical << h << QList{{50, 0}}; + QTest::newRow("T: other ends on the wire") << horizontal << line({50, 0}, {50, 20}) << h << QList(); + QTest::newRow("T: other starts on the wire") << horizontal << line({50, -20}, {50, 0}) << h << QList(); + QTest::newRow("too near the wire's end") << horizontal << line({2, -20}, {2, 20}) << h << QList(); + QTest::newRow("parallel") << horizontal << line({0, 10}, {100, 10}) << h << QList(); + QTest::newRow("misses") << horizontal << line({150, -20}, {150, 20}) << h << QList(); + QTest::newRow("bent wire, second segment") + << QVector{{0, -40}, {0, 0}, {100, 0}} << vertical << h << QList{{50, 0}}; + } + + void crossing() + { + QFETCH(QVector, wire); + QFETCH(QVector, other); + QFETCH(int, mode); + QFETCH(QList, expected); + QCOMPARE(WireHops::crossings(wire, {other}, Mode(mode)), expected); + } + + void closeCrossings() + { + const auto wire = line({0, 0}, {100, 0}); + // 6 apart: the arcs would overlap, so only the first hops + QCOMPARE(WireHops::crossings(wire, {line({40, -20}, {40, 20}), line({46, -20}, {46, 20})}, Mode::Horizontal), + (QList{{40, 0}})); + // 10 apart, one grid step: both hop, in the wire's order + QCOMPARE(WireHops::crossings(wire, {line({60, -20}, {60, 20}), line({50, -20}, {50, 20})}, Mode::Horizontal), + (QList{{50, 0}, {60, 0}})); + } + + void pathWithoutHopsIsThePlainWire() + { + const QVector wire{{0, -40}, {0, 0}, {100, 0}}; + QPainterPath plain; + plain.moveTo(wire.at(0)); + plain.lineTo(wire.at(1)); + plain.lineTo(wire.at(2)); + QCOMPARE(WireHops::path(wire, {}, Mode::Horizontal), plain); + QCOMPARE(WireHops::path(wire, {{50, 0}}, Mode::None), plain); + } + + void pathArcs_data() + { + QTest::addColumn>("wire"); + QTest::addColumn("mode"); + QTest::addColumn("bounds"); + + const qreal r = WireHops::radius; + // The arc goes above a horizontal wire, right of a vertical one, + // whichever way the wire runs + QTest::newRow("left to right") << line({0, 0}, {100, 0}) << int(Mode::Horizontal) + << QRectF(0, -r, 100, r); + QTest::newRow("right to left") << line({100, 0}, {0, 0}) << int(Mode::Horizontal) + << QRectF(0, -r, 100, r); + QTest::newRow("top to bottom") << line({50, -20}, {50, 20}) << int(Mode::Vertical) + << QRectF(50, -20, r, 40); + QTest::newRow("bottom to top") << line({50, 20}, {50, -20}) << int(Mode::Vertical) + << QRectF(50, -20, r, 40); + } + + void pathArcs() + { + QFETCH(QVector, wire); + QFETCH(int, mode); + QFETCH(QRectF, bounds); + + const QPointF hop(50, 0); + const QPainterPath path = WireHops::path(wire, {hop}, Mode(mode)); + const QRectF actual = path.boundingRect(); + QVERIFY2(qAbs(actual.left() - bounds.left()) < 0.01 && qAbs(actual.top() - bounds.top()) < 0.01 + && qAbs(actual.right() - bounds.right()) < 0.01 && qAbs(actual.bottom() - bounds.bottom()) < 0.01, + qPrintable(QStringLiteral("bounds %1,%2 %3x%4").arg(actual.x()).arg(actual.y()) + .arg(actual.width()).arg(actual.height()))); + QCOMPARE(path.pointAtPercent(0), wire.first()); + QCOMPARE(path.pointAtPercent(1), wire.last()); + } + + void savedSettingSurvivesResave() + { + const QString fixture = QFINDTESTDATA("fixtures/wiring_list_arrows.qet"); + QVERIFY2(!fixture.isEmpty(), "fixture project not found"); + + // The fixture has no setting: a resave must not add one + const QString plain = resave(fixture); + QVERIFY2(!plain.isEmpty(), "--resave failed"); + QVERIFY(!plain.contains(QLatin1String("wire_crossings"))); + + // With the setting, a resave keeps it + QFile source(fixture); + QVERIFY(source.open(QIODevice::ReadOnly | QIODevice::Text)); + QString text = QString::fromUtf8(source.readAll()); + const int newdiagrams = text.indexOf(QLatin1String(" 0); + text.insert(newdiagrams, QStringLiteral("\n ")); + const QString with_setting = m_dir.filePath(QStringLiteral("with_setting.qet")); + QFile out(with_setting); + QVERIFY(out.open(QIODevice::WriteOnly | QIODevice::Text)); + out.write(text.toUtf8()); + out.close(); + + const QString saved = resave(with_setting); + QVERIFY2(!saved.isEmpty(), "--resave failed"); + QVERIFY2(saved.contains(QLatin1String("")), + "the setting was lost on save"); + } +}; + +QTEST_GUILESS_MAIN(tst_wirehops) + +#include "tst_wirehops.moc"