From 18d614ec6775eafe4d46860cb61ba1c5aee516cb Mon Sep 17 00:00:00 2001 From: Ilya Ashikhmin Date: Thu, 2 Jul 2026 23:14:11 +0200 Subject: [PATCH] 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 --- src/CMakeLists.txt | 2 + src/core/Router.cpp | 215 +++++++++++++++++++++++++++++++++++++++++++ src/core/Router.h | 42 +++++++++ tests/CMakeLists.txt | 1 + tests/tst_router.cpp | 128 ++++++++++++++++++++++++++ 5 files changed, 388 insertions(+) create mode 100644 src/core/Router.cpp create mode 100644 src/core/Router.h create mode 100644 tests/tst_router.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b8c2e75..7415783 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -6,6 +6,8 @@ add_library(diagcore STATIC core/TypeRegistry.cpp core/NetworkModel.h core/NetworkModel.cpp + core/Router.h + core/Router.cpp ) target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui) diff --git a/src/core/Router.cpp b/src/core/Router.cpp new file mode 100644 index 0000000..26cf635 --- /dev/null +++ b/src/core/Router.cpp @@ -0,0 +1,215 @@ +#include "Router.h" + +#include +#include + +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& 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 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 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 Router::crossings(const QList& poly, const QList& other) +{ + QList 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& poly, const QList& 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 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& 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& 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 diff --git a/src/core/Router.h b/src/core/Router.h new file mode 100644 index 0000000..5651d4c --- /dev/null +++ b/src/core/Router.h @@ -0,0 +1,42 @@ +#pragma once + +#include "Types.h" + +#include + +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 route(QPointF start, Side startSide, QPointF end, Side endSide, + double gridStep); + + // Interior right-angle crossing points of `poly` over `other`. + static QList crossings(const QList& poly, const QList& 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& poly, const QList& hops, + double arcRadius); + + static double snap(double v, double gridStep); + static QPointF snapPoint(QPointF p, double gridStep); + static double polylineLength(const QList& poly); + // Point at normalized position t in [0,1] along the polyline. + static QPointF pointAt(const QList& poly, double t); +}; + +} // namespace diag diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 449baa0..07a3064 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,3 +8,4 @@ endfunction() diag_add_test(tst_model diagcore) diag_add_test(tst_properties diagcore) +diag_add_test(tst_router diagcore) diff --git a/tests/tst_router.cpp b/tests/tst_router.cpp new file mode 100644 index 0000000..3ada54b --- /dev/null +++ b/tests/tst_router.cpp @@ -0,0 +1,128 @@ +#include "core/Router.h" + +#include + +using namespace diag; + +namespace { + +bool isOrthogonal(const QList& 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 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 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 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 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 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 horizontal{{0, 100}, {200, 100}}; + const QList 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 a{{0, 100}, {200, 100}}; + const QList 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 touching{{100, 0}, {100, 100}}; + QCOMPARE(Router::crossings(a, touching).size(), 0); + } + + void toPath_arcAddsDetourLength() + { + const QList 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 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 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"