diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 66a810712..efe2f1992 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -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 diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index cd53ec880..16928640c 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -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 diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 257b1c294..ca9b597ba 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -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 " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 0f7871168..95bf7b918 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -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' -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("')) + # 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(". +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "conductorrouter.h" + +#include +#include +#include +#include +#include + +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((static_cast(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 &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 &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 axis(qreal low, qreal high, qreal grid, std::initializer_list extra) +{ + QVector 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 &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 &obstacles, + const QVector &along, const QVector &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::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 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 xs = axis(box.left(), box.right(), r.grid, {s1.x(), s2.x()}); + const QVector 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 horizontal_wires, vertical_wires; + for (const QVector &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::infinity(); + std::vector 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 dist(states, inf); + std::vector previous(states, -1); + using Entry = std::pair; + std::priority_queue, std::greater> 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(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(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 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 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; +} diff --git a/sources/conductorrouter.h b/sources/conductorrouter.h new file mode 100644 index 000000000..1fa6b65fe --- /dev/null +++ b/sources/conductorrouter.h @@ -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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef CONDUCTORROUTER_H +#define CONDUCTORROUTER_H + +#include +#include +#include +#include +#include + +/** + @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 obstacles; + ///The other wires on the folio, each as its list of points. + ///Running along one or crossing one costs extra. + QList> 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 points; + ///Why there is no route, when points is empty + QString error; + }; + + Result route(const Request &request); +} + +#endif // CONDUCTORROUTER_H diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index 4d658129f..80a8c7124 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -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 &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 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 diff --git a/sources/qetgraphicsitem/conductor.h b/sources/qetgraphicsitem/conductor.h index 46976129a..2cbe76f27 100644 --- a/sources/qetgraphicsitem/conductor.h +++ b/sources/qetgraphicsitem/conductor.h @@ -116,6 +116,7 @@ class Conductor : public QGraphicsObject QVector handlerPoints() const; const QList segmentsList() const; bool moveSegment(int index, qreal dx, qreal dy); + bool setPathPoints(const QList &scene_points); void setPropertyToPotential( const ConductorProperties &property, diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 54b0d9054..63fdd0098 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -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 wire; + const QList 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); diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index a458149da..a4da697de 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -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 sortedTexts(int folioIndex) const; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 3b1085990..e92e86f53 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -402,6 +402,16 @@ target_link_libraries(tst_wirehops PRIVATE Qt::Test Qt::Gui) target_compile_definitions(tst_wirehops PRIVATE "QET_TEST_BINARY_PATH=\"$\"") +# 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 diff --git a/tests/qttest/tst_conductorrouter.cpp b/tests/qttest/tst_conductorrouter.cpp new file mode 100644 index 000000000..6807d4149 --- /dev/null +++ b/tests/qttest/tst_conductorrouter.cpp @@ -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 . +*/ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "conductorrouter.h" + +#include + +/** + 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 &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 &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("from"); + QTest::addColumn("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{{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"