mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-03 01:04:13 +02:00
Merge pull request #1245 from ispyisail/feature/mcp-route-around
Add wire routing around symbols to the scripting API and MCP
This commit is contained in:
@@ -179,6 +179,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/wiringlistexport.cpp
|
||||
${QET_DIR}/sources/wirehops.h
|
||||
${QET_DIR}/sources/wirehops.cpp
|
||||
${QET_DIR}/sources/conductorrouter.h
|
||||
${QET_DIR}/sources/conductorrouter.cpp
|
||||
${QET_DIR}/sources/ui/wiringlistdialog.h
|
||||
${QET_DIR}/sources/ui/wiringlistdialog.cpp
|
||||
${QET_DIR}/sources/conductornumexport.h
|
||||
|
||||
@@ -532,6 +532,25 @@ Python, plus the hang guard on `addConductor` and the database refresh in
|
||||
the file until it is saved once: since #1107 QElectroTech works one out
|
||||
from the wire's two ends on load and writes it on the next save, so it
|
||||
appears after a first `qet_edit`.
|
||||
- **Wires can be routed around symbols.** By default a new conductor gets
|
||||
QElectroTech's own two or three straight segments, which run through
|
||||
whatever symbol or wire lies between the two terminals. Give
|
||||
`add_conductor` `"route": "avoid"` to redraw it around the symbols
|
||||
instead, or use `route_conductor` (addressed like `move_conductor_segment`,
|
||||
by `element` + `terminal` or by `"conductor": "{uuid}"`) to redraw one
|
||||
already drawn. The route leaves and enters each terminal in its own
|
||||
direction, runs on the folio grid, stays inside the folio's border, and is
|
||||
the cheapest found by a search that charges for length, for each bend
|
||||
and, less heavily, for running along or crossing another wire. Obstacles
|
||||
are each symbol's own rectangle plus half a grid step; texts, images,
|
||||
shapes and tables are not obstacles. The path is saved as a hand-edited
|
||||
one, so it survives a reload and one undo puts the default back. Where
|
||||
no route exists, the wire keeps its path: `route_conductor` returns
|
||||
`"no-route"` and both ops say so in `note` -- it is not a failure, and
|
||||
the run goes on. Like a hand-edited path, it is stretched rather than
|
||||
rerouted when a symbol is moved afterwards; route again after moving
|
||||
things. Needs `qet.routeConductor()` / `qet.routeConductorBetween()` in
|
||||
the build, and only an edit that routes requires them.
|
||||
- **A terminal can be named by its uuid**: `terminal`, `from_terminal` and
|
||||
`to_terminal` take the terminal's uuid (as `qet_element_info` lists it)
|
||||
in place of its index, on the op's own element (for `add_conductor`, on
|
||||
|
||||
+44
-2
@@ -1361,6 +1361,10 @@ OPS = {
|
||||
"move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"),
|
||||
("terminal", "term"), ("segment", "num"),
|
||||
("dx", "num"), ("dy", "num")]),
|
||||
# Redraws one conductor around the symbols in its way; the result is
|
||||
# "routed", or "no-route" when there is none, the path then unchanged.
|
||||
"route_conductor": ("routeConductor", [("folio", "folio"), ("element", "elmt"),
|
||||
("terminal", "term")]),
|
||||
"link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"),
|
||||
("to_folio", "folio"), ("to", "elmt")]),
|
||||
"link_plc_io": ("linkElements", [("folio", "folio"), ("element", "elmt"),
|
||||
@@ -1495,7 +1499,16 @@ FOLIO_MAKING_OPS = ("add_folio", "insert_folio")
|
||||
|
||||
# The ops that address one conductor by element + terminal, and so also
|
||||
# take "conductor": "{uuid}" (qet_conductors reports each one's uuid).
|
||||
CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor")
|
||||
CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor",
|
||||
"route_conductor")
|
||||
|
||||
# add_conductor's "route": "default" is the application's own two or three
|
||||
# segments; "avoid" then redraws the new conductor around the symbols.
|
||||
ROUTE_MODES = ["default", "avoid"]
|
||||
|
||||
# Routing arrived after the other drawing verbs, so it is required only by
|
||||
# an edit that routes: every other edit still runs on a build without it.
|
||||
_ROUTE_METHODS = {"routeConductor", "routeConductorBetween"}
|
||||
|
||||
# Accepted by set_conductor. The names are the project file's own, so what
|
||||
# a script sets is what qet_conductors reports back.
|
||||
@@ -1514,7 +1527,7 @@ CONDUCTOR_DEFAULT_PROPERTIES = ["onetextperfolio"] + CONDUCTOR_PROPERTIES
|
||||
# verbs. Probed in the script rather than assumed, because the failure mode
|
||||
# otherwise is a TypeError on line N of a generated file the caller never
|
||||
# sees, reported as "the edit failed".
|
||||
_REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} |
|
||||
_REQUIRED_METHODS = sorted(({m for m, _ in OPS.values() if m} - _ROUTE_METHODS) |
|
||||
{"save", "folioCount", "conductorCount", "elementCount"})
|
||||
|
||||
_MARKER = "QETEDIT "
|
||||
@@ -1745,6 +1758,14 @@ def _build_script(operations: list, output: str) -> str:
|
||||
raise ValueError(f"operation {i}: unknown conductor property "
|
||||
f"{op.get('property')!r}; expected one of "
|
||||
f"{', '.join(CONDUCTOR_PROPERTIES)}")
|
||||
route = op.get("route", "default") if name == "add_conductor" else "default"
|
||||
if route not in ROUTE_MODES:
|
||||
raise ValueError(f"operation {i}: unknown route {route!r}; "
|
||||
f"expected one of {', '.join(ROUTE_MODES)}")
|
||||
if name == "route_conductor":
|
||||
uuid_methods.add("routeConductor")
|
||||
if route == "avoid":
|
||||
uuid_methods.add("routeConductorBetween")
|
||||
if name == "set_folio" and op.get("property") not in FOLIO_PROPERTIES:
|
||||
raise ValueError(f"operation {i}: unknown folio property "
|
||||
f"{op.get('property')!r}; expected one of "
|
||||
@@ -1830,6 +1851,18 @@ def _build_script(operations: list, output: str) -> str:
|
||||
lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});")
|
||||
call = f"(e{i} ? {call} : false)"
|
||||
lines.append(f" var v{i} = {call};")
|
||||
if route == "avoid":
|
||||
# The wire exists either way; a route not found leaves it on the
|
||||
# default path, which is a note on the op, not a failure of it.
|
||||
lines.append(f" if (v{i} === true) {{ var r{i} = qet.routeConductorBetween("
|
||||
f"{', '.join(args)}); if (r{i} !== 'routed') qet.log({_js(_MARKER)} + "
|
||||
f"JSON.stringify({{kind: 'op_note', index: {i}, note: 'no route "
|
||||
f"around the symbols was found; the conductor keeps the default "
|
||||
f"path'}})); }}")
|
||||
if name == "route_conductor":
|
||||
lines.append(f" if (v{i} === 'no-route') qet.log({_js(_MARKER)} + "
|
||||
f"JSON.stringify({{kind: 'op_note', index: {i}, note: 'no route "
|
||||
f"around the symbols was found; the conductor keeps its path'}}));")
|
||||
if ident is not None:
|
||||
lines.append(f" R[{_js(ident)}] = v{i};")
|
||||
refs.add(ident)
|
||||
@@ -3524,6 +3557,15 @@ TOOLS = [
|
||||
"movable ones, then a static segment, in that order from the "
|
||||
"first terminal; call with a guessed index and read \"succeeded\" "
|
||||
"to check it landed on a movable one. "
|
||||
"add_conductor takes an optional \"route\": \"avoid\" to "
|
||||
"redraw the new wire around the symbols in its way instead of "
|
||||
"QElectroTech's default two or three straight segments; "
|
||||
"route_conductor does the same to an existing conductor "
|
||||
"(addressed like move_conductor_segment, or by \"conductor\") "
|
||||
"and returns \"routed\" or \"no-route\". Where no route "
|
||||
"exists the wire keeps its path and the op's note says so -- "
|
||||
"not a failure. Route after placing everything: moving a "
|
||||
"symbol later stretches the routed path, it does not reroute it. "
|
||||
"link_elements takes a folio for each end, since a master "
|
||||
"and its slave are usually on different ones. "
|
||||
"delete_conductor removes only the conductor on the named "
|
||||
|
||||
@@ -282,6 +282,35 @@ class EditValidation(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"):
|
||||
self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}])
|
||||
|
||||
def test_route_ops(self):
|
||||
"""route_conductor and add_conductor's "route": "avoid" call the
|
||||
router; neither makes a missing route a failure of the run, and the
|
||||
router methods are required only by an edit that routes."""
|
||||
E, F = "{11111111-2222-4333-8444-555555555555}", "{21111111-2222-4333-8444-555555555555}"
|
||||
U = "{31111111-2222-4333-8444-555555555555}"
|
||||
plain = self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
|
||||
"to": F, "to_terminal": 0}])
|
||||
self.assertNotIn("routeConductor", plain)
|
||||
self.assertEqual(plain, self.build([{"op": "add_conductor", "folio": 0, "from": E,
|
||||
"from_terminal": 1, "to": F, "to_terminal": 0,
|
||||
"route": "default"}]))
|
||||
s = self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
|
||||
"to": F, "to_terminal": 0, "route": "avoid"}])
|
||||
self.assertIn(f'if (v0 === true) {{ var r0 = qet.routeConductorBetween(0, "{E}", 1, "{F}", 0);', s)
|
||||
self.assertIn('"routeConductorBetween"', s)
|
||||
self.assertNotIn('"routeConductor",', s)
|
||||
# the op's own result is still the conductor being added
|
||||
self.assertIn("result: v0", s)
|
||||
s = self.build([{"op": "route_conductor", "folio": 2, "conductor": U}])
|
||||
self.assertIn("(e0 ? qet.routeConductor(2, e0.element, e0.terminal) : false)", s)
|
||||
self.assertIn("if (v0 === 'no-route')", s)
|
||||
self.assertIn('"routeConductor"', s)
|
||||
s = self.build([{"op": "route_conductor", "folio": 0, "element": E, "terminal": 1}])
|
||||
self.assertIn(f'qet.routeConductor(0, "{E}", 1)', s)
|
||||
with self.assertRaisesRegex(ValueError, "unknown route"):
|
||||
self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
|
||||
"to": F, "to_terminal": 0, "route": "around"}])
|
||||
|
||||
def test_terminal_by_uuid(self):
|
||||
"""A terminal named by uuid is turned into its index at run time, on
|
||||
the element it belongs to: the op's element, or each add_conductor
|
||||
@@ -344,6 +373,8 @@ class EditValidation(unittest.TestCase):
|
||||
"move_conductor_segment": el + [{"op": "move_conductor_segment", "folio": "$f",
|
||||
"element": "$e", "terminal": 0, "segment": 1,
|
||||
"dx": 10, "dy": 0}],
|
||||
"route_conductor": el + [{"op": "route_conductor", "folio": "$f", "element": "$e",
|
||||
"terminal": 0}],
|
||||
"delete_conductor": el + [{"op": "delete_conductor", "folio": "$f", "element": "$e", "terminal": 0}],
|
||||
"link_elements": two + [{"op": "link_elements", "folio": "$f", "element": "$e",
|
||||
"to_folio": "$f", "to": "$e2"}],
|
||||
@@ -3845,6 +3876,65 @@ class Integration(unittest.TestCase):
|
||||
"terminal": 0, "segment": 99, "dx": 1, "dy": 1}])
|
||||
self.assertFalse(r["ok"])
|
||||
|
||||
def _column_with_a_symbol_between(self):
|
||||
"""Three coils in a column; a wire from the bottom of the first to
|
||||
the top of the third runs straight through the middle one by
|
||||
default."""
|
||||
return [{"op": "add_folio", "id": "f"},
|
||||
{"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 200, "y": 100},
|
||||
{"op": "add_element", "id": "b", "folio": "$f", "path": COIL, "x": 200, "y": 250},
|
||||
{"op": "add_element", "id": "c", "folio": "$f", "path": COIL, "x": 200, "y": 400}]
|
||||
|
||||
@staticmethod
|
||||
def _crosses_the_middle_coil(xml):
|
||||
"""Whether the saved path enters the middle coil. The path starts
|
||||
at the first coil's A2 terminal, (200, 120); relative to that the
|
||||
middle coil (40 x 60, hotspot 17,32, placed at 200,250) covers
|
||||
x -17..23, y 98..158."""
|
||||
segs = re.findall(r'<segment length="([-0-9.]+)" orientation="(\w+)"', xml)
|
||||
x = y = 0.0
|
||||
for length, orientation in segs:
|
||||
nx, ny = (x + float(length), y) if orientation == "horizontal" else (x, y + float(length))
|
||||
lo_x, hi_x, lo_y, hi_y = min(x, nx), max(x, nx), min(y, ny), max(y, ny)
|
||||
if lo_x < 23 and hi_x > -17 and lo_y < 158 and hi_y > 98:
|
||||
return True
|
||||
x, y = nx, ny
|
||||
return False
|
||||
|
||||
def test_route_avoid_goes_around_the_symbol_between(self):
|
||||
base = self.sb.new()
|
||||
ops = self._column_with_a_symbol_between()
|
||||
wire = {"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 1,
|
||||
"to": "$c", "to_terminal": 0}
|
||||
# the arm that must differ: the default path is straight, saved
|
||||
# without segments, and so through the middle coil
|
||||
plain = self.ok(self.sb.edit(base, ops + [wire], out="plain.qet"))
|
||||
self.assertNotIn("<segment", Path(plain["output"]).read_text(encoding="utf-8"))
|
||||
r = self.ok(self.sb.edit(base, ops + [{**wire, "route": "avoid"}], out="routed.qet"))
|
||||
self.assertNotIn("note", r["operations"][-1])
|
||||
xml = Path(r["output"]).read_text(encoding="utf-8")
|
||||
self.assertIn("<segment", xml, "a routed path is saved as segments")
|
||||
self.assertFalse(self._crosses_the_middle_coil(xml))
|
||||
# the check can fail: the straight default path, as segments
|
||||
self.assertTrue(self._crosses_the_middle_coil(
|
||||
'<segment length="260" orientation="vertical"/>'))
|
||||
# one undo puts the default path back
|
||||
u = self.ok(self.sb.edit(base, ops + [{**wire, "route": "avoid"}, {"op": "undo"}],
|
||||
out="undone.qet"))
|
||||
self.assertNotIn("<segment", Path(u["output"]).read_text(encoding="utf-8"))
|
||||
|
||||
def test_route_conductor_reroutes_an_existing_wire(self):
|
||||
base = self.sb.new()
|
||||
ops = self._column_with_a_symbol_between() + [
|
||||
{"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 1,
|
||||
"to": "$c", "to_terminal": 0},
|
||||
{"op": "route_conductor", "folio": "$f", "element": "$a", "terminal": 1}]
|
||||
r = self.ok(self.sb.edit(base, ops))
|
||||
self.assertEqual(r["operations"][-1]["result"], "routed")
|
||||
xml = Path(r["output"]).read_text(encoding="utf-8")
|
||||
self.assertIn("<segment", xml)
|
||||
self.assertFalse(self._crosses_the_middle_coil(xml))
|
||||
|
||||
# ---- search and replace ----
|
||||
|
||||
def test_search_and_replace_element_info_across_folios(self):
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
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 "conductorrouter.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <queue>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using ConductorRouter::Direction;
|
||||
|
||||
constexpr qreal eps = 1e-6;
|
||||
///Larger than any folio needs: a 2000 x 2000 grid
|
||||
constexpr int max_nodes = 4000000;
|
||||
|
||||
///What each thing costs, in scene units of length. A bend is worth
|
||||
///three grid steps of extra wire, so the search prefers a slightly
|
||||
///longer route to a more crooked one, as a person drawing would.
|
||||
constexpr qreal bend_steps = 3.0;
|
||||
///Running along another wire costs this much more per unit of length
|
||||
constexpr qreal along_factor = 3.0;
|
||||
///Each crossing of another wire costs two grid steps
|
||||
constexpr qreal cross_steps = 2.0;
|
||||
|
||||
QPointF step(Direction d)
|
||||
{
|
||||
switch (d) {
|
||||
case Direction::North: return {0, -1};
|
||||
case Direction::East: return {1, 0};
|
||||
case Direction::South: return {0, 1};
|
||||
case Direction::West: return {-1, 0};
|
||||
}
|
||||
return {0, 0};
|
||||
}
|
||||
|
||||
Direction opposite(Direction d)
|
||||
{
|
||||
return static_cast<Direction>((static_cast<int>(d) + 2) % 4);
|
||||
}
|
||||
|
||||
bool strictlyInside(const QPointF &p, const QRectF &r)
|
||||
{
|
||||
return p.x() > r.left() + eps && p.x() < r.right() - eps
|
||||
&& p.y() > r.top() + eps && p.y() < r.bottom() - eps;
|
||||
}
|
||||
|
||||
bool insideAny(const QPointF &p, const QList<QRectF> &rects)
|
||||
{
|
||||
for (const QRectF &r : rects)
|
||||
if (strictlyInside(p, r)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
///A wire segment, kept as the fixed coordinate and the range along the
|
||||
///other axis
|
||||
struct Span { qreal at, from, to; };
|
||||
|
||||
///The first point of a route after a terminal: one grid step out in the
|
||||
///terminal's direction, snapped to the grid the way
|
||||
///Conductor::extendTerminal() snaps it, then on until it is clear of
|
||||
///every obstacle -- of the terminal's own symbol above all.
|
||||
bool exitPoint(const QPointF &dock, Direction d, const ConductorRouter::Request &r,
|
||||
const QList<QRectF> &obstacles, QPointF &out)
|
||||
{
|
||||
const QPointF s = step(d);
|
||||
QPointF p = dock;
|
||||
if (s.x() != 0)
|
||||
p.setX(std::round((dock.x() + s.x() * r.grid) / r.grid) * r.grid);
|
||||
else
|
||||
p.setY(std::round((dock.y() + s.y() * r.grid) / r.grid) * r.grid);
|
||||
for (int i = 0; i < 200; ++i) {
|
||||
if (!insideAny(p, obstacles)) {
|
||||
out = p;
|
||||
return true;
|
||||
}
|
||||
p += s * r.grid;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
///Sorted, without duplicates: the grid lines inside [low, high], plus
|
||||
///the extra coordinates (the two exit points, which need not be on the
|
||||
///grid when a terminal is not)
|
||||
QVector<qreal> axis(qreal low, qreal high, qreal grid, std::initializer_list<qreal> extra)
|
||||
{
|
||||
QVector<qreal> v;
|
||||
for (qreal c = std::ceil(low / grid) * grid; c <= high + eps; c += grid)
|
||||
v << c;
|
||||
for (qreal c : extra) v << c;
|
||||
std::sort(v.begin(), v.end());
|
||||
v.erase(std::unique(v.begin(), v.end(),
|
||||
[](qreal a, qreal b) { return std::abs(a - b) < eps; }),
|
||||
v.end());
|
||||
return v;
|
||||
}
|
||||
|
||||
int indexOf(const QVector<qreal> &v, qreal c)
|
||||
{
|
||||
auto it = std::lower_bound(v.begin(), v.end(), c - eps);
|
||||
return (it != v.end() && std::abs(*it - c) < eps) ? int(it - v.begin()) : -1;
|
||||
}
|
||||
|
||||
///[first, last) of the sorted v: the values strictly between low and high
|
||||
std::pair<int, int> openRange(const QVector<qreal> &v, qreal low, qreal high)
|
||||
{
|
||||
const int first = int(std::upper_bound(v.begin(), v.end(), low) - v.begin());
|
||||
const int last = int(std::lower_bound(v.begin(), v.end(), high) - v.begin());
|
||||
return {first, std::max(first, last)};
|
||||
}
|
||||
|
||||
///The cost of every edge along one axis, or infinity where an obstacle
|
||||
///blocks it. Edge (k, i) runs from pos[i] to pos[i + 1] at the fixed
|
||||
///coordinate lines[k], and is stored at [k * pos.size() + i];
|
||||
///horizontal says which axis pos is. Each obstacle and wire visits only
|
||||
///the edges it can touch, so the cost grows with the folio, not with
|
||||
///the folio times everything drawn on it.
|
||||
std::vector<qreal> edgeCosts(bool horizontal,
|
||||
const QVector<qreal> &pos, const QVector<qreal> &lines,
|
||||
const QList<QRectF> &obstacles,
|
||||
const QVector<Span> &along, const QVector<Span> &across,
|
||||
qreal grid)
|
||||
{
|
||||
const int np = pos.size(), nl = lines.size();
|
||||
std::vector<qreal> cost(size_t(np) * nl, std::numeric_limits<qreal>::infinity());
|
||||
for (int k = 0; k < nl; ++k)
|
||||
for (int i = 0; i + 1 < np; ++i)
|
||||
cost[size_t(k) * np + i] = pos[i + 1] - pos[i];
|
||||
|
||||
// The edges i that can overlap (low, high): pos[i + 1] > low and
|
||||
// pos[i] < high. The exact test is applied to each one found.
|
||||
const auto edges = [&](qreal low, qreal high) {
|
||||
const int first = int(std::upper_bound(pos.begin(), pos.end(), low) - pos.begin()) - 1;
|
||||
const int last = int(std::lower_bound(pos.begin(), pos.end(), high) - pos.begin());
|
||||
return std::pair<int, int>{std::max(0, first), std::min(np - 1, last)};
|
||||
};
|
||||
|
||||
for (const QRectF &r : obstacles) {
|
||||
const qreal lo = horizontal ? r.left() : r.top();
|
||||
const qreal hi = horizontal ? r.right() : r.bottom();
|
||||
const qreal flo = horizontal ? r.top() : r.left();
|
||||
const qreal fhi = horizontal ? r.bottom(): r.right();
|
||||
const auto [k0, k1] = openRange(lines, flo, fhi);
|
||||
const auto [i0, i1] = edges(lo, hi);
|
||||
for (int k = k0; k < k1; ++k) {
|
||||
if (!(lines[k] > flo + eps && lines[k] < fhi - eps)) continue;
|
||||
for (int i = i0; i < i1; ++i)
|
||||
if (std::min(pos[i + 1], hi) - std::max(pos[i], lo) > eps)
|
||||
cost[size_t(k) * np + i] = std::numeric_limits<qreal>::infinity();
|
||||
}
|
||||
}
|
||||
for (const Span &s : along) {
|
||||
const int k0 = int(std::lower_bound(lines.begin(), lines.end(), s.at - 0.5) - lines.begin());
|
||||
const auto [i0, i1] = edges(s.from, s.to);
|
||||
for (int k = k0; k < nl && lines[k] <= s.at + 0.5; ++k) {
|
||||
if (std::abs(s.at - lines[k]) > 0.5) continue;
|
||||
for (int i = i0; i < i1; ++i) {
|
||||
const qreal overlap = std::min(pos[i + 1], s.to) - std::max(pos[i], s.from);
|
||||
if (overlap > eps) cost[size_t(k) * np + i] += overlap * along_factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const Span &s : across) {
|
||||
// Half-open (a, b], so a crossing on a grid node is counted
|
||||
// once, by the edge that ends on it; and only through the
|
||||
// other wire's interior, not at its end, which is a junction.
|
||||
const auto [k0, k1] = openRange(lines, s.from, s.to);
|
||||
const auto [i0, i1] = edges(s.at - 1.0, s.at + 1.0);
|
||||
for (int k = k0; k < k1; ++k) {
|
||||
if (!(lines[k] > s.from + 0.5 && lines[k] < s.to - 0.5)) continue;
|
||||
for (int i = i0; i < i1; ++i)
|
||||
if (s.at > pos[i] + eps && s.at <= pos[i + 1] + eps)
|
||||
cost[size_t(k) * np + i] += cross_steps * grid;
|
||||
}
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ConductorRouter::Result ConductorRouter::route(const Request &r)
|
||||
{
|
||||
Result result;
|
||||
if (r.grid <= 0) {
|
||||
result.error = QStringLiteral("the grid step must be positive");
|
||||
return result;
|
||||
}
|
||||
|
||||
QList<QRectF> obstacles;
|
||||
for (const QRectF &o : r.obstacles)
|
||||
obstacles << o.normalized().adjusted(-r.margin, -r.margin, r.margin, r.margin);
|
||||
|
||||
QPointF s1, s2;
|
||||
if (!exitPoint(r.start, r.start_direction, r, obstacles, s1)
|
||||
|| !exitPoint(r.end, r.end_direction, r, obstacles, s2)) {
|
||||
result.error = QStringLiteral("a terminal has no way out of the symbols around it");
|
||||
return result;
|
||||
}
|
||||
|
||||
// The area searched: everything involved, with room to go round
|
||||
// it, kept on the folio when there is one.
|
||||
QRectF box = QRectF(s1, s2).normalized();
|
||||
for (const QRectF &o : obstacles) box = box.united(o);
|
||||
box.adjust(-3 * r.grid, -3 * r.grid, 3 * r.grid, 3 * r.grid);
|
||||
if (r.bounds.isValid()) box = box.intersected(r.bounds);
|
||||
box = box.united(QRectF(s1, s2).normalized());
|
||||
|
||||
const QVector<qreal> xs = axis(box.left(), box.right(), r.grid, {s1.x(), s2.x()});
|
||||
const QVector<qreal> ys = axis(box.top(), box.bottom(), r.grid, {s1.y(), s2.y()});
|
||||
const int nx = xs.size(), ny = ys.size();
|
||||
if (qint64(nx) * ny > max_nodes) {
|
||||
result.error = QStringLiteral("the area to search is too large");
|
||||
return result;
|
||||
}
|
||||
|
||||
QVector<Span> horizontal_wires, vertical_wires;
|
||||
for (const QVector<QPointF> &w : r.wires) {
|
||||
for (int i = 0; i + 1 < w.size(); ++i) {
|
||||
const QPointF a = w.at(i), b = w.at(i + 1);
|
||||
if (std::abs(a.y() - b.y()) < 0.5 && std::abs(a.x() - b.x()) > eps)
|
||||
horizontal_wires << Span{a.y(), std::min(a.x(), b.x()), std::max(a.x(), b.x())};
|
||||
else if (std::abs(a.x() - b.x()) < 0.5 && std::abs(a.y() - b.y()) > eps)
|
||||
vertical_wires << Span{a.x(), std::min(a.y(), b.y()), std::max(a.y(), b.y())};
|
||||
}
|
||||
}
|
||||
|
||||
// The cost of the edge from each node to the next one east,
|
||||
// h_cost[j * nx + i], and south, v_cost[i * ny + j]; infinity
|
||||
// where blocked or none.
|
||||
const qreal inf = std::numeric_limits<qreal>::infinity();
|
||||
const std::vector<qreal> h_cost = edgeCosts(true, xs, ys, obstacles,
|
||||
horizontal_wires, vertical_wires, r.grid);
|
||||
const std::vector<qreal> v_cost = edgeCosts(false, ys, xs, obstacles,
|
||||
vertical_wires, horizontal_wires, r.grid);
|
||||
|
||||
const int start_node = indexOf(ys, s1.y()) * nx + indexOf(xs, s1.x());
|
||||
const int goal_node = indexOf(ys, s2.y()) * nx + indexOf(xs, s2.x());
|
||||
const qreal bend = bend_steps * r.grid;
|
||||
// The wire arrives at the second terminal moving opposite to the
|
||||
// way it points.
|
||||
const Direction arrival = opposite(r.end_direction);
|
||||
|
||||
// Dijkstra over (node, direction of travel): the direction is what
|
||||
// lets a bend be charged for.
|
||||
const size_t states = size_t(nx) * ny * 4;
|
||||
std::vector<qreal> dist(states, inf);
|
||||
std::vector<int> previous(states, -1);
|
||||
using Entry = std::pair<qreal, int>;
|
||||
std::priority_queue<Entry, std::vector<Entry>, std::greater<Entry>> queue;
|
||||
const int first = start_node * 4 + int(r.start_direction);
|
||||
dist[size_t(first)] = 0;
|
||||
queue.push({0, first});
|
||||
|
||||
qreal best = inf;
|
||||
int best_state = -1;
|
||||
while (!queue.empty()) {
|
||||
const auto [cost, state] = queue.top();
|
||||
queue.pop();
|
||||
if (cost > dist[size_t(state)] || cost >= best) {
|
||||
if (cost >= best) break;
|
||||
continue;
|
||||
}
|
||||
const int node = state / 4;
|
||||
const Direction d = static_cast<Direction>(state % 4);
|
||||
if (node == goal_node && d != r.end_direction) {
|
||||
const qreal total = cost + (d == arrival ? 0 : bend);
|
||||
if (total < best) { best = total; best_state = state; }
|
||||
}
|
||||
const int i = node % nx, j = node / nx;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
const Direction nd = static_cast<Direction>(k);
|
||||
if (nd == opposite(d)) continue;
|
||||
int ni = i, nj = j;
|
||||
qreal edge = inf;
|
||||
switch (nd) {
|
||||
case Direction::East: if (i + 1 < nx) { ni = i + 1; edge = h_cost[size_t(j) * nx + i]; } break;
|
||||
case Direction::West: if (i > 0) { ni = i - 1; edge = h_cost[size_t(j) * nx + ni]; } break;
|
||||
case Direction::South: if (j + 1 < ny) { nj = j + 1; edge = v_cost[size_t(i) * ny + j]; } break;
|
||||
case Direction::North: if (j > 0) { nj = j - 1; edge = v_cost[size_t(i) * ny + nj]; } break;
|
||||
}
|
||||
if (edge == inf) continue;
|
||||
const qreal next_cost = cost + edge + (nd == d ? 0 : bend);
|
||||
const int next = (nj * nx + ni) * 4 + k;
|
||||
if (next_cost < dist[size_t(next)]) {
|
||||
dist[size_t(next)] = next_cost;
|
||||
previous[size_t(next)] = state;
|
||||
queue.push({next_cost, next});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best_state < 0) {
|
||||
result.error = QStringLiteral("no path around the symbols was found");
|
||||
return result;
|
||||
}
|
||||
|
||||
QList<QPointF> chain;
|
||||
for (int s = best_state; s >= 0; s = previous[size_t(s)]) {
|
||||
const int node = s / 4;
|
||||
chain.prepend(QPointF(xs[node % nx], ys[node / nx]));
|
||||
}
|
||||
|
||||
// Keep only the corners. The two exit points stay even when
|
||||
// straight on, as Conductor::generateConductorPath() keeps them:
|
||||
// the segment from a terminal to its exit point is the one the
|
||||
// application holds fixed when a wire is edited by hand.
|
||||
QList<QPointF> corners;
|
||||
for (int k = 0; k < chain.size(); ++k) {
|
||||
const QPointF p = chain.at(k);
|
||||
if (k > 0 && k + 1 < chain.size()) {
|
||||
const QPointF a = chain.at(k - 1), b = chain.at(k + 1);
|
||||
const bool straight = (std::abs(a.x() - p.x()) < eps && std::abs(b.x() - p.x()) < eps)
|
||||
|| (std::abs(a.y() - p.y()) < eps && std::abs(b.y() - p.y()) < eps);
|
||||
if (straight) continue;
|
||||
}
|
||||
corners << p;
|
||||
}
|
||||
|
||||
result.points << r.start;
|
||||
for (const QPointF &p : corners)
|
||||
if (result.points.last() != p) result.points << p;
|
||||
if (result.points.last() != r.end) result.points << r.end;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
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 CONDUCTORROUTER_H
|
||||
#define CONDUCTORROUTER_H
|
||||
|
||||
#include <QList>
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
/**
|
||||
@brief The ConductorRouter namespace
|
||||
Finds a path for a conductor that goes around obstacles -- the bounding
|
||||
rectangles of the symbols on the folio -- instead of the default two or
|
||||
three straight segments, which run through whatever lies between the two
|
||||
terminals. Kept free of any QGraphicsItem so the geometry can be tested
|
||||
on its own.
|
||||
|
||||
The path is made of horizontal and vertical segments only, like every
|
||||
conductor. It leaves each terminal in the terminal's own direction,
|
||||
runs on the folio grid, and is the cheapest one found by a search that
|
||||
charges for length, for every bend, and for running along or crossing
|
||||
the wires already drawn.
|
||||
*/
|
||||
namespace ConductorRouter
|
||||
{
|
||||
///The direction a terminal points, outward from its symbol.
|
||||
///Same order as Qet::Orientation.
|
||||
enum class Direction { North, East, South, West };
|
||||
|
||||
struct Request
|
||||
{
|
||||
QPointF start; ///< the first terminal's docking point
|
||||
Direction start_direction = Direction::North;
|
||||
QPointF end; ///< the second terminal's docking point
|
||||
Direction end_direction = Direction::North;
|
||||
///Areas no segment may cross. A margin is added to each.
|
||||
QList<QRectF> obstacles;
|
||||
///The other wires on the folio, each as its list of points.
|
||||
///Running along one or crossing one costs extra.
|
||||
QList<QVector<QPointF>> wires;
|
||||
///The route stays inside this, when it is valid (the folio).
|
||||
QRectF bounds;
|
||||
qreal grid = 10.0;
|
||||
///Added around each obstacle, so a route keeps clear of it
|
||||
qreal margin = 5.0;
|
||||
};
|
||||
|
||||
struct Result
|
||||
{
|
||||
///From start to end, both included; empty if none was found
|
||||
QList<QPointF> points;
|
||||
///Why there is no route, when points is empty
|
||||
QString error;
|
||||
};
|
||||
|
||||
Result route(const Request &request);
|
||||
}
|
||||
|
||||
#endif // CONDUCTORROUTER_H
|
||||
@@ -1389,6 +1389,46 @@ bool Conductor::moveSegment(int index, qreal dx, qreal dy)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Conductor::setPathPoints
|
||||
Replace this conductor's path by the one through @p scene_points, and
|
||||
push one undo step for it -- the same ChangeConductorCommand a manual
|
||||
handle drag or moveSegment() pushes, so the path is saved as a
|
||||
modified one and survives a reload.
|
||||
@param scene_points in scene coordinates, from terminal1's docking
|
||||
point to terminal2's, every segment horizontal or vertical
|
||||
@return false, changing nothing, if the points do not make such a path
|
||||
*/
|
||||
bool Conductor::setPathPoints(const QList<QPointF> &scene_points)
|
||||
{
|
||||
if (scene_points.size() < 2 || !terminal1 || !terminal2) return false;
|
||||
const auto near = [](const QPointF &a, const QPointF &b) {
|
||||
return qAbs(a.x() - b.x()) < 0.5 && qAbs(a.y() - b.y()) < 0.5;
|
||||
};
|
||||
if (!near(scene_points.first(), terminal1->dockConductor()) ||
|
||||
!near(scene_points.last(), terminal2->dockConductor()))
|
||||
return false;
|
||||
QList<QPointF> points;
|
||||
for (int i = 0; i < scene_points.size(); ++i) {
|
||||
const QPointF p = scene_points.at(i);
|
||||
if (i && qAbs(p.x() - scene_points.at(i - 1).x()) > 0.01
|
||||
&& qAbs(p.y() - scene_points.at(i - 1).y()) > 0.01)
|
||||
return false;
|
||||
points << mapFromScene(p);
|
||||
}
|
||||
// A two-point path has nothing to segment between: let the
|
||||
// application draw it, as for a conductor with no profile.
|
||||
if (points.size() < 3) return false;
|
||||
|
||||
before_mov_text_pos_ = m_text_item->pos();
|
||||
pointsToSegments(points);
|
||||
modified_path = true;
|
||||
segmentsToPath();
|
||||
calculateTextItemPosition();
|
||||
saveProfile();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief Conductor::length
|
||||
@return the length of this conductor
|
||||
|
||||
@@ -116,6 +116,7 @@ class Conductor : public QGraphicsObject
|
||||
QVector <QPointF> handlerPoints() const;
|
||||
const QList<ConductorSegment *> segmentsList() const;
|
||||
bool moveSegment(int index, qreal dx, qreal dy);
|
||||
bool setPathPoints(const QList<QPointF> &scene_points);
|
||||
|
||||
void setPropertyToPotential(
|
||||
const ConductorProperties &property,
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
#include "../qetgraphicsitem/conductortextitem.h"
|
||||
#include "../conductorsegment.h"
|
||||
#include "../conductorrouter.h"
|
||||
#include "../qetgraphicsitem/diagramimageitem.h"
|
||||
|
||||
// See diagrameventaddpdf.h: a missing QtPdf module (or Qt < 6.4) is not
|
||||
@@ -1206,6 +1207,119 @@ bool QetScriptApi::moveConductorSegment(int folioIndex, const QString &elementUu
|
||||
return conductor->moveSegment(segmentIndex, dx, dy);
|
||||
}
|
||||
|
||||
namespace {
|
||||
ConductorRouter::Direction routerDirection(Qet::Orientation o)
|
||||
{
|
||||
switch (o) {
|
||||
case Qet::North: return ConductorRouter::Direction::North;
|
||||
case Qet::East: return ConductorRouter::Direction::East;
|
||||
case Qet::South: return ConductorRouter::Direction::South;
|
||||
case Qet::West: return ConductorRouter::Direction::West;
|
||||
}
|
||||
return ConductorRouter::Direction::North;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::applyRoute
|
||||
Redraw @p conductor around the symbols on its folio (ConductorRouter),
|
||||
as one undo step through Conductor::setPathPoints() -- the same
|
||||
ChangeConductorCommand a handle drag pushes, so the path is saved and
|
||||
survives a reload. Obstacles are every element's own rectangle, its
|
||||
texts left out; the other conductors are not obstacles but cost extra
|
||||
to run along or cross.
|
||||
@return "routed", or "no-route" with the reason logged when there is
|
||||
no such path -- the conductor then keeps the path it had. Not a
|
||||
failure: the wire exists and joins the right terminals either way.
|
||||
*/
|
||||
QString QetScriptApi::applyRoute(Conductor *conductor, const QString &caller)
|
||||
{
|
||||
Diagram *diagram = conductor->diagram();
|
||||
if (!diagram) return QString();
|
||||
|
||||
ConductorRouter::Request request;
|
||||
request.start = conductor->terminal1->dockConductor();
|
||||
request.start_direction = routerDirection(conductor->terminal1->orientation());
|
||||
request.end = conductor->terminal2->dockConductor();
|
||||
request.end_direction = routerDirection(conductor->terminal2->orientation());
|
||||
request.grid = Diagram::xGrid;
|
||||
request.bounds = diagram->border_and_titleblock.insideBorderRect();
|
||||
for (Element *e : diagram->elements())
|
||||
request.obstacles << e->mapRectToScene(e->boundingRect());
|
||||
for (Conductor *other : diagram->conductors()) {
|
||||
if (other == conductor) continue;
|
||||
QVector<QPointF> wire;
|
||||
const QList<ConductorSegment *> segs = other->segmentsList();
|
||||
for (ConductorSegment *seg : segs) {
|
||||
if (wire.isEmpty()) wire << other->mapToScene(seg->firstPoint());
|
||||
wire << other->mapToScene(seg->secondPoint());
|
||||
}
|
||||
request.wires << wire;
|
||||
}
|
||||
|
||||
const ConductorRouter::Result route = ConductorRouter::route(request);
|
||||
if (route.points.isEmpty()) {
|
||||
log(QStringLiteral("qet.%1: %2; the conductor keeps its path")
|
||||
.arg(caller, route.error));
|
||||
return QStringLiteral("no-route");
|
||||
}
|
||||
if (!conductor->setPathPoints(route.points)) {
|
||||
log(QStringLiteral("qet.%1: the route found does not join the two terminals; "
|
||||
"the conductor keeps its path").arg(caller));
|
||||
return QStringLiteral("no-route");
|
||||
}
|
||||
return QStringLiteral("routed");
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::routeConductor
|
||||
Redraw the conductor on a terminal so it goes around the symbols in
|
||||
its way instead of through them -- see applyRoute(). Addressed as the
|
||||
other conductor calls address one.
|
||||
@return "routed", "no-route" (path unchanged, reason logged), or an
|
||||
empty string if there is no such conductor or the project is read-only
|
||||
*/
|
||||
QString QetScriptApi::routeConductor(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex)
|
||||
{
|
||||
if (!m_project) return QString();
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.routeConductor: project is read-only"));
|
||||
return QString();
|
||||
}
|
||||
Conductor *conductor = findConductor(folioIndex, elementUuid, terminalIndex,
|
||||
QStringLiteral("routeConductor"));
|
||||
if (!conductor) return QString();
|
||||
return applyRoute(conductor, QStringLiteral("routeConductor"));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::routeConductorBetween
|
||||
routeConductor() for the conductor joining two given terminals, which
|
||||
names it even where either terminal carries other conductors too --
|
||||
the case just after addConductor() onto a terminal already wired.
|
||||
*/
|
||||
QString QetScriptApi::routeConductorBetween(int folioIndex,
|
||||
const QString &elementUuidA, int terminalIndexA,
|
||||
const QString &elementUuidB, int terminalIndexB)
|
||||
{
|
||||
if (!m_project) return QString();
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.routeConductorBetween: project is read-only"));
|
||||
return QString();
|
||||
}
|
||||
const QString caller = QStringLiteral("routeConductorBetween");
|
||||
Terminal *a = findTerminal(folioIndex, elementUuidA, terminalIndexA, caller);
|
||||
Terminal *b = findTerminal(folioIndex, elementUuidB, terminalIndexB, caller);
|
||||
if (!a || !b) return QString();
|
||||
for (Conductor *c : a->conductors()) {
|
||||
if (c->terminal1 == b || c->terminal2 == b)
|
||||
return applyRoute(c, caller);
|
||||
}
|
||||
log(QStringLiteral("qet.%1: no conductor joins those two terminals").arg(caller));
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString QetScriptApi::elementLinkType(int folioIndex, const QString &elementUuid) const
|
||||
{
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
|
||||
@@ -418,6 +418,11 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE bool moveConductorSegment(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, int segmentIndex,
|
||||
double dx, double dy);
|
||||
Q_INVOKABLE QString routeConductor(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex);
|
||||
Q_INVOKABLE QString routeConductorBetween(int folioIndex,
|
||||
const QString &elementUuidA, int terminalIndexA,
|
||||
const QString &elementUuidB, int terminalIndexB);
|
||||
|
||||
// -- cross-references: master/slave and report links --
|
||||
Q_INVOKABLE QString elementLinkType(int folioIndex, const QString &elementUuid) const;
|
||||
@@ -596,6 +601,7 @@ class QetScriptApi : public QObject
|
||||
Element *findElement(int folioIndex, const QString &elementUuid) const;
|
||||
Terminal *findTerminal(int folioIndex, const QString &elementUuid, int terminalIndex,
|
||||
const QString &caller);
|
||||
QString applyRoute(Conductor *conductor, const QString &caller);
|
||||
Conductor *findConductor(int folioIndex, const QString &elementUuid, int terminalIndex,
|
||||
const QString &caller);
|
||||
QList<IndependentTextItem *> sortedTexts(int folioIndex) const;
|
||||
|
||||
@@ -422,6 +422,16 @@ target_link_libraries(tst_wirehops PRIVATE Qt::Test Qt::Gui)
|
||||
target_compile_definitions(tst_wirehops PRIVATE
|
||||
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
|
||||
|
||||
# The routing behind qet.routeConductor(): conductorrouter.cpp needs only
|
||||
# Qt Core, so the geometry is tested without a scene.
|
||||
add_executable(
|
||||
tst_conductorrouter
|
||||
tst_conductorrouter.cpp
|
||||
${QET_DIR}/sources/conductorrouter.cpp)
|
||||
add_test(NAME tst_conductorrouter COMMAND tst_conductorrouter)
|
||||
target_include_directories(tst_conductorrouter PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_conductorrouter PRIVATE Qt::Test)
|
||||
|
||||
# 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,212 @@
|
||||
/*
|
||||
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 "conductorrouter.h"
|
||||
|
||||
#include <QtTest>
|
||||
|
||||
/**
|
||||
The geometry of ConductorRouter on its own: that a route is made of
|
||||
horizontal and vertical segments, leaves and enters each terminal in
|
||||
the terminal's direction, and goes around obstacles rather than
|
||||
through them.
|
||||
*/
|
||||
class TestConductorRouter : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
using Direction = ConductorRouter::Direction;
|
||||
|
||||
static bool crosses(const QList<QPointF> &points, const QRectF &r)
|
||||
{
|
||||
for (int i = 0; i + 1 < points.size(); ++i) {
|
||||
const QPointF a = points.at(i), b = points.at(i + 1);
|
||||
const QRectF seg = QRectF(a, b).normalized();
|
||||
const qreal left = qMax(seg.left(), r.left()), right = qMin(seg.right(), r.right());
|
||||
const qreal top = qMax(seg.top(), r.top()), bottom = qMin(seg.bottom(), r.bottom());
|
||||
// a segment is a degenerate rectangle: it crosses the
|
||||
// interior when it overlaps it on one axis and lies
|
||||
// strictly inside it on the other
|
||||
if (seg.width() == 0 && seg.left() > r.left() && seg.left() < r.right() && bottom > top)
|
||||
return true;
|
||||
if (seg.height() == 0 && seg.top() > r.top() && seg.top() < r.bottom() && right > left)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void checkShape(const QList<QPointF> &p, const ConductorRouter::Request &r)
|
||||
{
|
||||
QVERIFY(p.size() >= 3);
|
||||
QCOMPARE(p.first(), r.start);
|
||||
QCOMPARE(p.last(), r.end);
|
||||
for (int i = 0; i + 1 < p.size(); ++i)
|
||||
QVERIFY2(p.at(i).x() == p.at(i + 1).x() || p.at(i).y() == p.at(i + 1).y(),
|
||||
"a segment is neither horizontal nor vertical");
|
||||
}
|
||||
|
||||
static QPointF unit(Direction d)
|
||||
{
|
||||
switch (d) {
|
||||
case Direction::North: return {0, -1};
|
||||
case Direction::East: return {1, 0};
|
||||
case Direction::South: return {0, 1};
|
||||
case Direction::West: return {-1, 0};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
static QPointF direction(QPointF from, QPointF to)
|
||||
{
|
||||
const QPointF d = to - from;
|
||||
return {d.x() > 0 ? 1. : d.x() < 0 ? -1. : 0., d.y() > 0 ? 1. : d.y() < 0 ? -1. : 0.};
|
||||
}
|
||||
|
||||
private slots:
|
||||
void straightWhenNothingIsInTheWay()
|
||||
{
|
||||
ConductorRouter::Request r;
|
||||
r.start = {100, 100};
|
||||
r.start_direction = Direction::East;
|
||||
r.end = {300, 100};
|
||||
r.end_direction = Direction::West;
|
||||
const auto result = ConductorRouter::route(r);
|
||||
checkShape(result.points, r);
|
||||
// no bend at all: start, the two exit points, end
|
||||
for (const QPointF &p : result.points) QCOMPARE(p.y(), 100.);
|
||||
}
|
||||
|
||||
void goesAroundASymbol()
|
||||
{
|
||||
ConductorRouter::Request r;
|
||||
r.start = {100, 100};
|
||||
r.start_direction = Direction::East;
|
||||
r.end = {300, 100};
|
||||
r.end_direction = Direction::West;
|
||||
const QRectF symbol(170, 60, 60, 80);
|
||||
r.obstacles << symbol;
|
||||
const auto result = ConductorRouter::route(r);
|
||||
QVERIFY2(result.error.isEmpty(), qPrintable(result.error));
|
||||
checkShape(result.points, r);
|
||||
QVERIFY(!crosses(result.points, symbol.adjusted(-r.margin, -r.margin, r.margin, r.margin)));
|
||||
}
|
||||
|
||||
// The test that the check above can fail: a straight line through
|
||||
// the symbol is reported as crossing it.
|
||||
void crossingCheckCanFail()
|
||||
{
|
||||
QVERIFY(crosses({{100, 100}, {300, 100}}, QRectF(170, 60, 60, 80)));
|
||||
}
|
||||
|
||||
void leavesAndEntersInTheTerminalsDirections_data()
|
||||
{
|
||||
QTest::addColumn<int>("from");
|
||||
QTest::addColumn<int>("to");
|
||||
for (int a = 0; a < 4; ++a)
|
||||
for (int b = 0; b < 4; ++b)
|
||||
QTest::addRow("%d-%d", a, b) << a << b;
|
||||
}
|
||||
|
||||
void leavesAndEntersInTheTerminalsDirections()
|
||||
{
|
||||
QFETCH(int, from);
|
||||
QFETCH(int, to);
|
||||
ConductorRouter::Request r;
|
||||
r.start = {100, 100};
|
||||
r.start_direction = Direction(from);
|
||||
r.end = {250, 180};
|
||||
r.end_direction = Direction(to);
|
||||
// each terminal's own symbol, on the side opposite the way
|
||||
// the terminal points
|
||||
const QPointF s = unit(r.start_direction), e = unit(r.end_direction);
|
||||
const QRectF own_start(r.start - s * 40 - QPointF(20, 20), QSizeF(40, 40));
|
||||
const QRectF own_end(r.end - e * 40 - QPointF(20, 20), QSizeF(40, 40));
|
||||
r.obstacles << own_start << own_end;
|
||||
const auto result = ConductorRouter::route(r);
|
||||
QVERIFY2(result.error.isEmpty(), qPrintable(result.error));
|
||||
checkShape(result.points, r);
|
||||
const auto &p = result.points;
|
||||
QCOMPARE(direction(p.at(0), p.at(1)), s);
|
||||
QCOMPARE(direction(p.at(p.size() - 1), p.at(p.size() - 2)), e);
|
||||
QVERIFY(!crosses(p, own_start));
|
||||
QVERIFY(!crosses(p, own_end));
|
||||
}
|
||||
|
||||
void prefersNotToRunAlongAnotherWire()
|
||||
{
|
||||
ConductorRouter::Request r;
|
||||
r.start = {100, 100};
|
||||
r.start_direction = Direction::East;
|
||||
r.end = {300, 100};
|
||||
r.end_direction = Direction::West;
|
||||
// a wire lying exactly on the straight route
|
||||
r.wires << QVector<QPointF>{{110, 100}, {290, 100}};
|
||||
const auto result = ConductorRouter::route(r);
|
||||
checkShape(result.points, r);
|
||||
bool on_it = false;
|
||||
for (int i = 1; i + 2 < result.points.size(); ++i)
|
||||
if (result.points.at(i).y() == 100 && result.points.at(i + 1).y() == 100)
|
||||
on_it = true;
|
||||
QVERIFY(!on_it);
|
||||
}
|
||||
|
||||
void staysOnTheFolio()
|
||||
{
|
||||
ConductorRouter::Request r;
|
||||
r.start = {100, 30};
|
||||
r.start_direction = Direction::East;
|
||||
r.end = {300, 30};
|
||||
r.end_direction = Direction::West;
|
||||
r.obstacles << QRectF(170, 0, 60, 200);
|
||||
r.bounds = QRectF(0, 0, 500, 400);
|
||||
const auto result = ConductorRouter::route(r);
|
||||
QVERIFY2(result.error.isEmpty(), qPrintable(result.error));
|
||||
for (const QPointF &p : result.points) QVERIFY(r.bounds.contains(p));
|
||||
}
|
||||
|
||||
void reportsWhenThereIsNoWay()
|
||||
{
|
||||
ConductorRouter::Request r;
|
||||
r.start = {100, 100};
|
||||
r.start_direction = Direction::East;
|
||||
r.end = {300, 100};
|
||||
r.end_direction = Direction::West;
|
||||
// a wall from the top of the folio to the bottom
|
||||
r.obstacles << QRectF(170, -10, 60, 520);
|
||||
r.bounds = QRectF(0, 0, 500, 500);
|
||||
const auto result = ConductorRouter::route(r);
|
||||
QVERIFY(result.points.isEmpty());
|
||||
QVERIFY(!result.error.isEmpty());
|
||||
}
|
||||
|
||||
void terminalsOffTheGrid()
|
||||
{
|
||||
ConductorRouter::Request r;
|
||||
r.start = {103, 97};
|
||||
r.start_direction = Direction::South;
|
||||
r.end = {287, 213};
|
||||
r.end_direction = Direction::North;
|
||||
r.obstacles << QRectF(120, 140, 200, 30);
|
||||
const auto result = ConductorRouter::route(r);
|
||||
QVERIFY2(result.error.isEmpty(), qPrintable(result.error));
|
||||
checkShape(result.points, r);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestConductorRouter)
|
||||
#include "tst_conductorrouter.moc"
|
||||
Reference in New Issue
Block a user