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:
parent
26905a4ba0
commit
18d614ec67
@ -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)
|
||||
|
||||
215
src/core/Router.cpp
Normal file
215
src/core/Router.cpp
Normal 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
42
src/core/Router.h
Normal 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
|
||||
@ -8,3 +8,4 @@ endfunction()
|
||||
|
||||
diag_add_test(tst_model diagcore)
|
||||
diag_add_test(tst_properties diagcore)
|
||||
diag_add_test(tst_router diagcore)
|
||||
|
||||
128
tests/tst_router.cpp
Normal file
128
tests/tst_router.cpp
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user