mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-02 08:24:14 +02:00
Merge pull request #1200 from ispyisail/feature/wire-crossing-hops
Add optional hops where wires cross (#436)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -32,10 +32,34 @@
|
||||
#include "element.h"
|
||||
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
|
||||
#include "../utils/qetutils.h"
|
||||
#include "../wirehops.h"
|
||||
|
||||
#include <QMultiHash>
|
||||
#include <QtDebug>
|
||||
|
||||
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<QPointF> points;
|
||||
};
|
||||
const Diagram *s_hop_candidates_diagram = nullptr;
|
||||
quint64 s_hop_candidates_generation = 0;
|
||||
QVector<HopCandidate> 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<Conductor *> 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<QPointF> points = segmentsToPoints();
|
||||
if (points.size() < 2) {
|
||||
return m_hops_path;
|
||||
}
|
||||
QVector<QPointF> 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<QVector<QPointF>> 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<QPointF> scene_hops = WireHops::crossings(wire, others, mode);
|
||||
if (!scene_hops.isEmpty())
|
||||
{
|
||||
QList<QPointF> hops;
|
||||
for (const QPointF &hop : scene_hops) {
|
||||
hops.append(mapFromScene(hop));
|
||||
}
|
||||
m_hops_path = WireHops::path(QVector<QPointF>(points.begin(), points.end()), hops, mode);
|
||||
}
|
||||
return m_hops_path;
|
||||
}
|
||||
|
||||
/**
|
||||
@return la liste des positions des jonctions avec d'autres conducteurs
|
||||
*/
|
||||
|
||||
@@ -143,6 +143,7 @@ class Conductor : public QGraphicsObject
|
||||
void setSequenceNum(const autonum::sequentialNumbers& sn);
|
||||
|
||||
QList<QPointF> junctions() const;
|
||||
QPainterPath paintedPath() const;
|
||||
|
||||
private:
|
||||
void setUpConnectionForFormula(
|
||||
@@ -187,6 +188,10 @@ class Conductor : public QGraphicsObject
|
||||
QVector<QetGraphicsHandlerItem *> 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
|
||||
|
||||
@@ -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 <wire_crossings> 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 <wire_crossings> 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
|
||||
|
||||
@@ -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 <QString, NumerotationContext> 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
//######################################################################################//
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include "wirehops.h"
|
||||
|
||||
#include <QtMath>
|
||||
#include <algorithm>
|
||||
|
||||
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<QPointF> hopsOnSegment(const QPointF &a, const QPointF &b,
|
||||
const QList<QPointF> &hops, bool horizontal)
|
||||
{
|
||||
QList<QPointF> 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<QPointF> WireHops::crossings(const QVector<QPointF> &wire,
|
||||
const QList<QVector<QPointF>> &others,
|
||||
Mode mode)
|
||||
{
|
||||
QList<QPointF> 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<QPointF> 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<QPointF> &wire,
|
||||
const QList<QPointF> &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;
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#ifndef WIREHOPS_H
|
||||
#define WIREHOPS_H
|
||||
|
||||
#include <QList>
|
||||
#include <QPainterPath>
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
/**
|
||||
@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<QPointF> crossings(const QVector<QPointF> &wire,
|
||||
const QList<QVector<QPointF>> &others,
|
||||
Mode mode);
|
||||
QPainterPath path(const QVector<QPointF> &wire,
|
||||
const QList<QPointF> &hops,
|
||||
Mode mode);
|
||||
}
|
||||
|
||||
#endif // WIREHOPS_H
|
||||
@@ -376,6 +376,20 @@ target_compile_definitions(tst_wiringlistexport PRIVATE
|
||||
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
|
||||
"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=\"$<TARGET_FILE:qelectrotech>\"")
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include <QtTest>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#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 <wire_crossings> 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<QPointF> 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<QVector<QPointF>>("wire");
|
||||
QTest::addColumn<QVector<QPointF>>("other");
|
||||
QTest::addColumn<int>("mode");
|
||||
QTest::addColumn<QList<QPointF>>("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<QPointF>();
|
||||
QTest::newRow("horizontal hops") << horizontal << vertical << h << QList<QPointF>{{50, 0}};
|
||||
QTest::newRow("horizontal mode, vertical wire") << vertical << horizontal << h << QList<QPointF>();
|
||||
QTest::newRow("vertical hops") << vertical << horizontal << v << QList<QPointF>{{50, 0}};
|
||||
QTest::newRow("vertical mode, horizontal wire") << horizontal << vertical << v << QList<QPointF>();
|
||||
QTest::newRow("reversed wire") << line({100, 0}, {0, 0}) << vertical << h << QList<QPointF>{{50, 0}};
|
||||
QTest::newRow("T: other ends on the wire") << horizontal << line({50, 0}, {50, 20}) << h << QList<QPointF>();
|
||||
QTest::newRow("T: other starts on the wire") << horizontal << line({50, -20}, {50, 0}) << h << QList<QPointF>();
|
||||
QTest::newRow("too near the wire's end") << horizontal << line({2, -20}, {2, 20}) << h << QList<QPointF>();
|
||||
QTest::newRow("parallel") << horizontal << line({0, 10}, {100, 10}) << h << QList<QPointF>();
|
||||
QTest::newRow("misses") << horizontal << line({150, -20}, {150, 20}) << h << QList<QPointF>();
|
||||
QTest::newRow("bent wire, second segment")
|
||||
<< QVector<QPointF>{{0, -40}, {0, 0}, {100, 0}} << vertical << h << QList<QPointF>{{50, 0}};
|
||||
}
|
||||
|
||||
void crossing()
|
||||
{
|
||||
QFETCH(QVector<QPointF>, wire);
|
||||
QFETCH(QVector<QPointF>, other);
|
||||
QFETCH(int, mode);
|
||||
QFETCH(QList<QPointF>, 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<QPointF>{{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<QPointF>{{50, 0}, {60, 0}}));
|
||||
}
|
||||
|
||||
void pathWithoutHopsIsThePlainWire()
|
||||
{
|
||||
const QVector<QPointF> 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<QVector<QPointF>>("wire");
|
||||
QTest::addColumn<int>("mode");
|
||||
QTest::addColumn<QRectF>("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<QPointF>, 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("<newdiagrams"));
|
||||
QVERIFY(newdiagrams > 0);
|
||||
text.insert(newdiagrams, QStringLiteral("<wire_crossings hop=\"vertical\"/>\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("<wire_crossings hop=\"vertical\"/>")),
|
||||
"the setting was lost on save");
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(tst_wirehops)
|
||||
|
||||
#include "tst_wirehops.moc"
|
||||
Reference in New Issue
Block a user