feat(core): add orthogonal grid router with intersection jump-over arcs

Manhattan routing snapped to the rectangular grid with port-side-aware exit
stubs, right-angle crossing detection between routed polylines, and painter
paths bridging crossings with semicircular arcs (configurable radius).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:14:11 +02:00
parent 26905a4ba0
commit 18d614ec67
5 changed files with 388 additions and 0 deletions

View File

@ -6,6 +6,8 @@ add_library(diagcore STATIC
core/TypeRegistry.cpp core/TypeRegistry.cpp
core/NetworkModel.h core/NetworkModel.h
core/NetworkModel.cpp core/NetworkModel.cpp
core/Router.h
core/Router.cpp
) )
target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui) target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui)

215
src/core/Router.cpp Normal file
View File

@ -0,0 +1,215 @@
#include "Router.h"
#include <QLineF>
#include <QtMath>
namespace diag {
namespace {
constexpr double kEps = 1e-6;
bool isHorizontal(QPointF a, QPointF b)
{
return qAbs(a.y() - b.y()) < kEps && qAbs(a.x() - b.x()) > kEps;
}
bool isVertical(QPointF a, QPointF b)
{
return qAbs(a.x() - b.x()) < kEps && qAbs(a.y() - b.y()) > kEps;
}
void appendPoint(QList<QPointF>& poly, QPointF p)
{
if (!poly.isEmpty() && QLineF(poly.last(), p).length() < kEps)
return;
// Merge collinear runs: replace the middle point when direction repeats.
if (poly.size() >= 2) {
const QPointF a = poly.at(poly.size() - 2);
const QPointF b = poly.last();
if ((isHorizontal(a, b) && isHorizontal(b, p)) || (isVertical(a, b) && isVertical(b, p))) {
poly.last() = p;
return;
}
}
poly.append(p);
}
} // namespace
double Router::snap(double v, double gridStep)
{
if (gridStep <= 0)
return v;
return qRound(v / gridStep) * gridStep;
}
QPointF Router::snapPoint(QPointF p, double gridStep)
{
return {snap(p.x(), gridStep), snap(p.y(), gridStep)};
}
QList<QPointF> Router::route(QPointF start, Side startSide, QPointF end, Side endSide,
double gridStep)
{
const double step = gridStep > 0 ? gridStep : 20.0;
const QPointF sv = sideVector(startSide);
const QPointF ev = sideVector(endSide);
// Stub points one grid step away from each port, in the port's direction.
QPointF p = start + sv * step;
QPointF q = end + ev * step;
const bool startHoriz = qAbs(sv.x()) > 0.5;
const bool endHoriz = qAbs(ev.x()) > 0.5;
// Snap the stubs' free coordinate to the grid so middle segments run on
// grid lines. The coordinate shared with the port stays, keeping the stub
// axis-aligned with the port point.
if (startHoriz)
p.setX(snap(p.x(), step));
else
p.setY(snap(p.y(), step));
if (endHoriz)
q.setX(snap(q.x(), step));
else
q.setY(snap(q.y(), step));
QList<QPointF> poly;
poly << start;
appendPoint(poly, p);
if (startHoriz && endHoriz) {
const double midX = snap((p.x() + q.x()) / 2.0, step);
appendPoint(poly, {midX, p.y()});
appendPoint(poly, {midX, q.y()});
} else if (!startHoriz && !endHoriz) {
const double midY = snap((p.y() + q.y()) / 2.0, step);
appendPoint(poly, {p.x(), midY});
appendPoint(poly, {q.x(), midY});
} else if (startHoriz) {
appendPoint(poly, {q.x(), p.y()});
} else {
appendPoint(poly, {p.x(), q.y()});
}
appendPoint(poly, q);
appendPoint(poly, end);
return poly;
}
QList<QPointF> Router::crossings(const QList<QPointF>& poly, const QList<QPointF>& other)
{
QList<QPointF> out;
for (int i = 0; i + 1 < poly.size(); ++i) {
const QPointF a1 = poly.at(i);
const QPointF a2 = poly.at(i + 1);
for (int j = 0; j + 1 < other.size(); ++j) {
const QPointF b1 = other.at(j);
const QPointF b2 = other.at(j + 1);
QPointF hit;
if (isHorizontal(a1, a2) && isVertical(b1, b2)) {
const double x = b1.x();
const double y = a1.y();
if (x > qMin(a1.x(), a2.x()) + kEps && x < qMax(a1.x(), a2.x()) - kEps
&& y > qMin(b1.y(), b2.y()) + kEps && y < qMax(b1.y(), b2.y()) - kEps)
hit = {x, y};
else
continue;
} else if (isVertical(a1, a2) && isHorizontal(b1, b2)) {
const double x = a1.x();
const double y = b1.y();
if (y > qMin(a1.y(), a2.y()) + kEps && y < qMax(a1.y(), a2.y()) - kEps
&& x > qMin(b1.x(), b2.x()) + kEps && x < qMax(b1.x(), b2.x()) - kEps)
hit = {x, y};
else
continue;
} else {
continue;
}
if (!out.contains(hit))
out.append(hit);
}
}
return out;
}
QPainterPath Router::toPath(const QList<QPointF>& poly, const QList<QPointF>& hops,
double arcRadius)
{
QPainterPath path;
if (poly.size() < 2)
return path;
path.moveTo(poly.first());
const double r = qMax(1.0, arcRadius);
for (int i = 0; i + 1 < poly.size(); ++i) {
const QPointF a = poly.at(i);
const QPointF b = poly.at(i + 1);
const double segLen = QLineF(a, b).length();
// Hops on this segment, ordered by distance from its start, skipping
// those too close to segment ends for a clean arc.
QList<QPointF> segHops;
for (const QPointF& h : hops) {
const bool onSeg = (isHorizontal(a, b) && qAbs(h.y() - a.y()) < kEps
&& h.x() > qMin(a.x(), b.x()) + kEps
&& h.x() < qMax(a.x(), b.x()) - kEps)
|| (isVertical(a, b) && qAbs(h.x() - a.x()) < kEps
&& h.y() > qMin(a.y(), b.y()) + kEps && h.y() < qMax(a.y(), b.y()) - kEps);
if (!onSeg)
continue;
const double d = QLineF(a, h).length();
if (d < r || segLen - d < r)
continue;
segHops.append(h);
}
std::sort(segHops.begin(), segHops.end(), [&a](QPointF l, QPointF rp) {
return QLineF(a, l).length() < QLineF(a, rp).length();
});
for (const QPointF& h : segHops) {
const QRectF rect(h.x() - r, h.y() - r, 2 * r, 2 * r);
if (isHorizontal(a, b)) {
const bool ltr = b.x() > a.x();
path.lineTo(h.x() + (ltr ? -r : r), h.y());
// Semicircle above the line, in travel direction.
path.arcTo(rect, ltr ? 180 : 0, ltr ? -180 : 180);
} else {
const bool ttb = b.y() > a.y();
path.lineTo(h.x(), h.y() + (ttb ? -r : r));
// Semicircle to the right of the line, in travel direction.
path.arcTo(rect, ttb ? 90 : 270, ttb ? -180 : 180);
}
}
path.lineTo(b);
}
return path;
}
double Router::polylineLength(const QList<QPointF>& poly)
{
double len = 0;
for (int i = 0; i + 1 < poly.size(); ++i)
len += QLineF(poly.at(i), poly.at(i + 1)).length();
return len;
}
QPointF Router::pointAt(const QList<QPointF>& poly, double t)
{
if (poly.isEmpty())
return {};
if (poly.size() == 1)
return poly.first();
const double total = polylineLength(poly);
double target = qBound(0.0, t, 1.0) * total;
for (int i = 0; i + 1 < poly.size(); ++i) {
const QLineF seg(poly.at(i), poly.at(i + 1));
const double len = seg.length();
if (target <= len || i + 2 == poly.size())
return seg.pointAt(len > 0 ? qBound(0.0, target / len, 1.0) : 0.0);
target -= len;
}
return poly.last();
}
} // namespace diag

42
src/core/Router.h Normal file
View File

@ -0,0 +1,42 @@
#pragma once
#include "Types.h"
#include <QPainterPath>
namespace diag {
struct RouteConfig {
double gridStep = 20.0;
bool snapToGrid = true;
bool arcOnIntersection = true;
double arcRadius = 6.0;
};
// Orthogonal (Manhattan) edge routing along the rectangular grid, plus
// geometry helpers: crossing detection between routed polylines and painter
// paths with semicircular "jump-over" arcs at crossings.
class Router {
public:
// Polyline from `start` (leaving the node toward `startSide`) to `end`
// (entering from `endSide`). All segments are axis-aligned; intermediate
// coordinates are snapped to the grid.
static QList<QPointF> route(QPointF start, Side startSide, QPointF end, Side endSide,
double gridStep);
// Interior right-angle crossing points of `poly` over `other`.
static QList<QPointF> crossings(const QList<QPointF>& poly, const QList<QPointF>& other);
// Painter path for `poly` with semicircular arcs bridging each hop point.
// Hops too close to a corner are drawn as plain line intersections.
static QPainterPath toPath(const QList<QPointF>& poly, const QList<QPointF>& hops,
double arcRadius);
static double snap(double v, double gridStep);
static QPointF snapPoint(QPointF p, double gridStep);
static double polylineLength(const QList<QPointF>& poly);
// Point at normalized position t in [0,1] along the polyline.
static QPointF pointAt(const QList<QPointF>& poly, double t);
};
} // namespace diag

View File

@ -8,3 +8,4 @@ endfunction()
diag_add_test(tst_model diagcore) diag_add_test(tst_model diagcore)
diag_add_test(tst_properties diagcore) diag_add_test(tst_properties diagcore)
diag_add_test(tst_router diagcore)

128
tests/tst_router.cpp Normal file
View File

@ -0,0 +1,128 @@
#include "core/Router.h"
#include <QTest>
using namespace diag;
namespace {
bool isOrthogonal(const QList<QPointF>& poly)
{
for (int i = 0; i + 1 < poly.size(); ++i) {
const QPointF a = poly.at(i);
const QPointF b = poly.at(i + 1);
if (qAbs(a.x() - b.x()) > 1e-6 && qAbs(a.y() - b.y()) > 1e-6)
return false;
}
return true;
}
} // namespace
class TestRouter : public QObject {
Q_OBJECT
private slots:
void route_isOrthogonalAndKeepsEndpoints()
{
const QList<QPointF> poly =
Router::route({63, 37}, Side::Right, {305, 143}, Side::Left, 20);
QVERIFY(poly.size() >= 2);
QCOMPARE(poly.first(), QPointF(63, 37));
QCOMPARE(poly.last(), QPointF(305, 143));
QVERIFY(isOrthogonal(poly));
}
void route_intermediatePointsOnGrid()
{
const QList<QPointF> poly =
Router::route({63, 37}, Side::Right, {305, 143}, Side::Left, 20);
// The vertical middle segment must run on a grid line.
bool foundGridX = false;
for (int i = 1; i + 1 < poly.size(); ++i) {
const double x = poly.at(i).x();
if (qAbs(x - Router::snap(x, 20)) < 1e-6)
foundGridX = true;
}
QVERIFY(foundGridX);
}
void route_leavesPortInSideDirection()
{
const QList<QPointF> down =
Router::route({50, 50}, Side::Bottom, {250, 250}, Side::Top, 20);
QVERIFY(down.at(1).y() > down.at(0).y());
QCOMPARE(down.at(1).x(), down.at(0).x());
const QList<QPointF> left =
Router::route({300, 50}, Side::Left, {50, 50}, Side::Right, 20);
QVERIFY(left.at(1).x() < left.at(0).x());
}
void route_mixedAxesSingleCorner()
{
const QList<QPointF> poly =
Router::route({100, 100}, Side::Right, {200, 300}, Side::Top, 20);
QVERIFY(isOrthogonal(poly));
QCOMPARE(poly.first(), QPointF(100, 100));
QCOMPARE(poly.last(), QPointF(200, 300));
}
void crossings_detectsPerpendicularCross()
{
const QList<QPointF> horizontal{{0, 100}, {200, 100}};
const QList<QPointF> vertical{{100, 0}, {100, 200}};
const auto hits = Router::crossings(horizontal, vertical);
QCOMPARE(hits.size(), 1);
QCOMPARE(hits.first(), QPointF(100, 100));
// Symmetric case reports the same point.
QCOMPARE(Router::crossings(vertical, horizontal).size(), 1);
}
void crossings_ignoresParallelAndTouching()
{
const QList<QPointF> a{{0, 100}, {200, 100}};
const QList<QPointF> parallel{{0, 120}, {200, 120}};
QCOMPARE(Router::crossings(a, parallel).size(), 0);
// Vertical segment ending exactly on the line: a T-joint, not a cross.
const QList<QPointF> touching{{100, 0}, {100, 100}};
QCOMPARE(Router::crossings(a, touching).size(), 0);
}
void toPath_arcAddsDetourLength()
{
const QList<QPointF> poly{{0, 100}, {200, 100}};
const QPainterPath plain = Router::toPath(poly, {}, 6);
const QPainterPath hopped = Router::toPath(poly, {QPointF(100, 100)}, 6);
QVERIFY(hopped.length() > plain.length() + 1.0);
// The arc bulges upward: path bounding box extends above the line.
QVERIFY(hopped.boundingRect().top() < 100.0 - 4.0);
QCOMPARE(hopped.currentPosition(), QPointF(200, 100));
}
void toPath_hopTooCloseToCornerSkipped()
{
const QList<QPointF> poly{{0, 100}, {200, 100}};
const QPainterPath plain = Router::toPath(poly, {}, 6);
const QPainterPath nearEnd = Router::toPath(poly, {QPointF(198, 100)}, 6);
QCOMPARE(nearEnd.length(), plain.length());
}
void polyline_lengthAndPointAt()
{
const QList<QPointF> poly{{0, 0}, {100, 0}, {100, 100}};
QCOMPARE(Router::polylineLength(poly), 200.0);
QCOMPARE(Router::pointAt(poly, 0.25), QPointF(50, 0));
QCOMPARE(Router::pointAt(poly, 0.75), QPointF(100, 50));
QCOMPARE(Router::pointAt(poly, 1.0), QPointF(100, 100));
}
void snap_roundsToGrid()
{
QCOMPARE(Router::snap(47, 20), 40.0);
QCOMPARE(Router::snap(51, 20), 60.0);
QCOMPARE(Router::snapPoint({47, 51}, 20), QPointF(40, 60));
}
};
QTEST_GUILESS_MAIN(TestRouter)
#include "tst_router.moc"