Add optional hops where wires cross (#436)

Where two wires cross without being connected, a project can now draw a
small arc (a hop) on one of them, instead of the user inserting a jump
symbol and splitting the wire. It is a project setting, off by default:
Project properties > Général > "Croisements de conducteurs", with no
hops, hops on horizontal wires, or hops on vertical wires. Choosing the
orientation rather than following drawing order keeps a project
consistent.

Only the drawing changes: no element is added and no wire is split. A
wire that ends or bends on another one is a junction and never hops,
and a crossing too close to the end of a segment for the arc to fit is
drawn plainly. PDF, SVG and image export and printing show the hops,
since they paint through Conductor::paint(); DXF export, which writes
the segments itself, does not.

The setting is saved as <wire_crossings hop="..."/> under the project
root, next to <usage>, and only when hops are on, so a project that
never used them saves exactly as before.

The geometry is in wirehops.cpp, free of any graphics item, and tested
on its own. Conductor::paintedPath() feeds it the conductors of the
folio from a snapshot of their scene points, rebuilt only when a
conductor changes shape, moves, or enters or leaves a folio; each hop
path is cached the same way. Asking the scene for the conductors in a
rect instead strokes every candidate's shape, which made a 366-wire
folio render 0.6 s slower; with the snapshot the difference is within
measurement noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-01 21:18:12 +13:00
parent d777b8d44d
commit 79a8637251
11 changed files with 700 additions and 3 deletions
+2
View File
@@ -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
+115 -3
View File
@@ -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
*/
+5
View File
@@ -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
+59
View File
@@ -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
+6
View File
@@ -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 {
+211
View File
@@ -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;
}
+63
View File
@@ -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
+14
View File
@@ -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
+192
View File
@@ -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"