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:
Laurent Trinques
2026-10-02 14:05:13 +02:00
committed by GitHub
12 changed files with 957 additions and 2 deletions
+2
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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 "
+90
View File
@@ -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):
+342
View File
@@ -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;
}
+77
View File
@@ -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
+40
View File
@@ -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
+1
View File
@@ -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,
+114
View File
@@ -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);
+6
View File
@@ -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;
+10
View File
@@ -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
+212
View File
@@ -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"