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
+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):