mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-05 18:54:14 +02:00
Scripting API and MCP: route a conductor around the symbols in its way
A new conductor gets the default two or three straight segments, which run through whatever symbol or wire lies between its terminals; scripts then fix it segment by segment with moveConductorSegment(). ConductorRouter finds an orthogonal path on the folio grid that leaves and enters each terminal in its own direction, keeps clear of every element's rectangle, stays inside the border, and charges for bends and for running along or crossing other wires. qet.routeConductor() and qet.routeConductorBetween() apply it through Conductor::setPathPoints(), which pushes the same ChangeConductorCommand a handle drag does, so the path is saved, survives a reload, and one undo restores the default. Where no route exists the wire keeps its path and the call says so. qet-mcp: add_conductor takes "route": "avoid", and a route_conductor op reroutes an existing conductor (by terminal or by uuid). The router methods are required only by an edit that routes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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 "
|
||||
|
||||
@@ -281,6 +281,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
|
||||
@@ -343,6 +372,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"}],
|
||||
@@ -3806,6 +3837,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,304 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
|
||||
///The cost of the edge along one axis from a to b at fixed coordinate at,
|
||||
///or infinity if it crosses an obstacle. horizontal says which axis.
|
||||
qreal edgeCost(bool horizontal, qreal at, qreal a, qreal b,
|
||||
const QList<QRectF> &obstacles,
|
||||
const QVector<Span> &along, const QVector<Span> &across,
|
||||
qreal grid)
|
||||
{
|
||||
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();
|
||||
if (at > flo + eps && at < fhi - eps
|
||||
&& std::min(b, hi) - std::max(a, lo) > eps)
|
||||
return std::numeric_limits<qreal>::infinity();
|
||||
}
|
||||
qreal cost = b - a;
|
||||
for (const Span &s : along) {
|
||||
if (std::abs(s.at - at) > 0.5) continue;
|
||||
const qreal overlap = std::min(b, s.to) - std::max(a, s.from);
|
||||
if (overlap > eps) cost += 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.
|
||||
if (s.at > a + eps && s.at <= b + eps
|
||||
&& at > s.from + 0.5 && at < s.to - 0.5)
|
||||
cost += 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) and south (v_cost); infinity where blocked or none.
|
||||
const qreal inf = std::numeric_limits<qreal>::infinity();
|
||||
std::vector<qreal> h_cost(size_t(nx) * ny, inf), v_cost(size_t(nx) * ny, inf);
|
||||
for (int j = 0; j < ny; ++j)
|
||||
for (int i = 0; i + 1 < nx; ++i)
|
||||
h_cost[size_t(j) * nx + i] = edgeCost(true, ys[j], xs[i], xs[i + 1], obstacles,
|
||||
horizontal_wires, vertical_wires, r.grid);
|
||||
for (int i = 0; i < nx; ++i)
|
||||
for (int j = 0; j + 1 < ny; ++j)
|
||||
v_cost[size_t(j) * nx + i] = edgeCost(false, xs[i], ys[j], ys[j + 1], 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(j) * nx + i]; } break;
|
||||
case Direction::North: if (j > 0) { nj = j - 1; edge = v_cost[size_t(nj) * nx + i]; } 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
|
||||
@@ -1194,6 +1195,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;
|
||||
|
||||
@@ -402,6 +402,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