mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-07 20:34:14 +02:00
Merge pull request #1245 from ispyisail/feature/mcp-route-around
Add wire routing around symbols to the scripting API and MCP
This commit is contained in:
@@ -532,6 +532,25 @@ Python, plus the hang guard on `addConductor` and the database refresh in
|
||||
the file until it is saved once: since #1107 QElectroTech works one out
|
||||
from the wire's two ends on load and writes it on the next save, so it
|
||||
appears after a first `qet_edit`.
|
||||
- **Wires can be routed around symbols.** By default a new conductor gets
|
||||
QElectroTech's own two or three straight segments, which run through
|
||||
whatever symbol or wire lies between the two terminals. Give
|
||||
`add_conductor` `"route": "avoid"` to redraw it around the symbols
|
||||
instead, or use `route_conductor` (addressed like `move_conductor_segment`,
|
||||
by `element` + `terminal` or by `"conductor": "{uuid}"`) to redraw one
|
||||
already drawn. The route leaves and enters each terminal in its own
|
||||
direction, runs on the folio grid, stays inside the folio's border, and is
|
||||
the cheapest found by a search that charges for length, for each bend
|
||||
and, less heavily, for running along or crossing another wire. Obstacles
|
||||
are each symbol's own rectangle plus half a grid step; texts, images,
|
||||
shapes and tables are not obstacles. The path is saved as a hand-edited
|
||||
one, so it survives a reload and one undo puts the default back. Where
|
||||
no route exists, the wire keeps its path: `route_conductor` returns
|
||||
`"no-route"` and both ops say so in `note` -- it is not a failure, and
|
||||
the run goes on. Like a hand-edited path, it is stretched rather than
|
||||
rerouted when a symbol is moved afterwards; route again after moving
|
||||
things. Needs `qet.routeConductor()` / `qet.routeConductorBetween()` in
|
||||
the build, and only an edit that routes requires them.
|
||||
- **A terminal can be named by its uuid**: `terminal`, `from_terminal` and
|
||||
`to_terminal` take the terminal's uuid (as `qet_element_info` lists it)
|
||||
in place of its index, on the op's own element (for `add_conductor`, on
|
||||
|
||||
+44
-2
@@ -1361,6 +1361,10 @@ OPS = {
|
||||
"move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"),
|
||||
("terminal", "term"), ("segment", "num"),
|
||||
("dx", "num"), ("dy", "num")]),
|
||||
# Redraws one conductor around the symbols in its way; the result is
|
||||
# "routed", or "no-route" when there is none, the path then unchanged.
|
||||
"route_conductor": ("routeConductor", [("folio", "folio"), ("element", "elmt"),
|
||||
("terminal", "term")]),
|
||||
"link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"),
|
||||
("to_folio", "folio"), ("to", "elmt")]),
|
||||
"link_plc_io": ("linkElements", [("folio", "folio"), ("element", "elmt"),
|
||||
@@ -1495,7 +1499,16 @@ FOLIO_MAKING_OPS = ("add_folio", "insert_folio")
|
||||
|
||||
# The ops that address one conductor by element + terminal, and so also
|
||||
# take "conductor": "{uuid}" (qet_conductors reports each one's uuid).
|
||||
CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor")
|
||||
CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor",
|
||||
"route_conductor")
|
||||
|
||||
# add_conductor's "route": "default" is the application's own two or three
|
||||
# segments; "avoid" then redraws the new conductor around the symbols.
|
||||
ROUTE_MODES = ["default", "avoid"]
|
||||
|
||||
# Routing arrived after the other drawing verbs, so it is required only by
|
||||
# an edit that routes: every other edit still runs on a build without it.
|
||||
_ROUTE_METHODS = {"routeConductor", "routeConductorBetween"}
|
||||
|
||||
# Accepted by set_conductor. The names are the project file's own, so what
|
||||
# a script sets is what qet_conductors reports back.
|
||||
@@ -1514,7 +1527,7 @@ CONDUCTOR_DEFAULT_PROPERTIES = ["onetextperfolio"] + CONDUCTOR_PROPERTIES
|
||||
# verbs. Probed in the script rather than assumed, because the failure mode
|
||||
# otherwise is a TypeError on line N of a generated file the caller never
|
||||
# sees, reported as "the edit failed".
|
||||
_REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} |
|
||||
_REQUIRED_METHODS = sorted(({m for m, _ in OPS.values() if m} - _ROUTE_METHODS) |
|
||||
{"save", "folioCount", "conductorCount", "elementCount"})
|
||||
|
||||
_MARKER = "QETEDIT "
|
||||
@@ -1745,6 +1758,14 @@ def _build_script(operations: list, output: str) -> str:
|
||||
raise ValueError(f"operation {i}: unknown conductor property "
|
||||
f"{op.get('property')!r}; expected one of "
|
||||
f"{', '.join(CONDUCTOR_PROPERTIES)}")
|
||||
route = op.get("route", "default") if name == "add_conductor" else "default"
|
||||
if route not in ROUTE_MODES:
|
||||
raise ValueError(f"operation {i}: unknown route {route!r}; "
|
||||
f"expected one of {', '.join(ROUTE_MODES)}")
|
||||
if name == "route_conductor":
|
||||
uuid_methods.add("routeConductor")
|
||||
if route == "avoid":
|
||||
uuid_methods.add("routeConductorBetween")
|
||||
if name == "set_folio" and op.get("property") not in FOLIO_PROPERTIES:
|
||||
raise ValueError(f"operation {i}: unknown folio property "
|
||||
f"{op.get('property')!r}; expected one of "
|
||||
@@ -1830,6 +1851,18 @@ def _build_script(operations: list, output: str) -> str:
|
||||
lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});")
|
||||
call = f"(e{i} ? {call} : false)"
|
||||
lines.append(f" var v{i} = {call};")
|
||||
if route == "avoid":
|
||||
# The wire exists either way; a route not found leaves it on the
|
||||
# default path, which is a note on the op, not a failure of it.
|
||||
lines.append(f" if (v{i} === true) {{ var r{i} = qet.routeConductorBetween("
|
||||
f"{', '.join(args)}); if (r{i} !== 'routed') qet.log({_js(_MARKER)} + "
|
||||
f"JSON.stringify({{kind: 'op_note', index: {i}, note: 'no route "
|
||||
f"around the symbols was found; the conductor keeps the default "
|
||||
f"path'}})); }}")
|
||||
if name == "route_conductor":
|
||||
lines.append(f" if (v{i} === 'no-route') qet.log({_js(_MARKER)} + "
|
||||
f"JSON.stringify({{kind: 'op_note', index: {i}, note: 'no route "
|
||||
f"around the symbols was found; the conductor keeps its path'}}));")
|
||||
if ident is not None:
|
||||
lines.append(f" R[{_js(ident)}] = v{i};")
|
||||
refs.add(ident)
|
||||
@@ -3524,6 +3557,15 @@ TOOLS = [
|
||||
"movable ones, then a static segment, in that order from the "
|
||||
"first terminal; call with a guessed index and read \"succeeded\" "
|
||||
"to check it landed on a movable one. "
|
||||
"add_conductor takes an optional \"route\": \"avoid\" to "
|
||||
"redraw the new wire around the symbols in its way instead of "
|
||||
"QElectroTech's default two or three straight segments; "
|
||||
"route_conductor does the same to an existing conductor "
|
||||
"(addressed like move_conductor_segment, or by \"conductor\") "
|
||||
"and returns \"routed\" or \"no-route\". Where no route "
|
||||
"exists the wire keeps its path and the op's note says so -- "
|
||||
"not a failure. Route after placing everything: moving a "
|
||||
"symbol later stretches the routed path, it does not reroute it. "
|
||||
"link_elements takes a folio for each end, since a master "
|
||||
"and its slave are usually on different ones. "
|
||||
"delete_conductor removes only the conductor on the named "
|
||||
|
||||
@@ -282,6 +282,35 @@ class EditValidation(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"):
|
||||
self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}])
|
||||
|
||||
def test_route_ops(self):
|
||||
"""route_conductor and add_conductor's "route": "avoid" call the
|
||||
router; neither makes a missing route a failure of the run, and the
|
||||
router methods are required only by an edit that routes."""
|
||||
E, F = "{11111111-2222-4333-8444-555555555555}", "{21111111-2222-4333-8444-555555555555}"
|
||||
U = "{31111111-2222-4333-8444-555555555555}"
|
||||
plain = self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
|
||||
"to": F, "to_terminal": 0}])
|
||||
self.assertNotIn("routeConductor", plain)
|
||||
self.assertEqual(plain, self.build([{"op": "add_conductor", "folio": 0, "from": E,
|
||||
"from_terminal": 1, "to": F, "to_terminal": 0,
|
||||
"route": "default"}]))
|
||||
s = self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
|
||||
"to": F, "to_terminal": 0, "route": "avoid"}])
|
||||
self.assertIn(f'if (v0 === true) {{ var r0 = qet.routeConductorBetween(0, "{E}", 1, "{F}", 0);', s)
|
||||
self.assertIn('"routeConductorBetween"', s)
|
||||
self.assertNotIn('"routeConductor",', s)
|
||||
# the op's own result is still the conductor being added
|
||||
self.assertIn("result: v0", s)
|
||||
s = self.build([{"op": "route_conductor", "folio": 2, "conductor": U}])
|
||||
self.assertIn("(e0 ? qet.routeConductor(2, e0.element, e0.terminal) : false)", s)
|
||||
self.assertIn("if (v0 === 'no-route')", s)
|
||||
self.assertIn('"routeConductor"', s)
|
||||
s = self.build([{"op": "route_conductor", "folio": 0, "element": E, "terminal": 1}])
|
||||
self.assertIn(f'qet.routeConductor(0, "{E}", 1)', s)
|
||||
with self.assertRaisesRegex(ValueError, "unknown route"):
|
||||
self.build([{"op": "add_conductor", "folio": 0, "from": E, "from_terminal": 1,
|
||||
"to": F, "to_terminal": 0, "route": "around"}])
|
||||
|
||||
def test_terminal_by_uuid(self):
|
||||
"""A terminal named by uuid is turned into its index at run time, on
|
||||
the element it belongs to: the op's element, or each add_conductor
|
||||
@@ -344,6 +373,8 @@ class EditValidation(unittest.TestCase):
|
||||
"move_conductor_segment": el + [{"op": "move_conductor_segment", "folio": "$f",
|
||||
"element": "$e", "terminal": 0, "segment": 1,
|
||||
"dx": 10, "dy": 0}],
|
||||
"route_conductor": el + [{"op": "route_conductor", "folio": "$f", "element": "$e",
|
||||
"terminal": 0}],
|
||||
"delete_conductor": el + [{"op": "delete_conductor", "folio": "$f", "element": "$e", "terminal": 0}],
|
||||
"link_elements": two + [{"op": "link_elements", "folio": "$f", "element": "$e",
|
||||
"to_folio": "$f", "to": "$e2"}],
|
||||
@@ -3845,6 +3876,65 @@ class Integration(unittest.TestCase):
|
||||
"terminal": 0, "segment": 99, "dx": 1, "dy": 1}])
|
||||
self.assertFalse(r["ok"])
|
||||
|
||||
def _column_with_a_symbol_between(self):
|
||||
"""Three coils in a column; a wire from the bottom of the first to
|
||||
the top of the third runs straight through the middle one by
|
||||
default."""
|
||||
return [{"op": "add_folio", "id": "f"},
|
||||
{"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 200, "y": 100},
|
||||
{"op": "add_element", "id": "b", "folio": "$f", "path": COIL, "x": 200, "y": 250},
|
||||
{"op": "add_element", "id": "c", "folio": "$f", "path": COIL, "x": 200, "y": 400}]
|
||||
|
||||
@staticmethod
|
||||
def _crosses_the_middle_coil(xml):
|
||||
"""Whether the saved path enters the middle coil. The path starts
|
||||
at the first coil's A2 terminal, (200, 120); relative to that the
|
||||
middle coil (40 x 60, hotspot 17,32, placed at 200,250) covers
|
||||
x -17..23, y 98..158."""
|
||||
segs = re.findall(r'<segment length="([-0-9.]+)" orientation="(\w+)"', xml)
|
||||
x = y = 0.0
|
||||
for length, orientation in segs:
|
||||
nx, ny = (x + float(length), y) if orientation == "horizontal" else (x, y + float(length))
|
||||
lo_x, hi_x, lo_y, hi_y = min(x, nx), max(x, nx), min(y, ny), max(y, ny)
|
||||
if lo_x < 23 and hi_x > -17 and lo_y < 158 and hi_y > 98:
|
||||
return True
|
||||
x, y = nx, ny
|
||||
return False
|
||||
|
||||
def test_route_avoid_goes_around_the_symbol_between(self):
|
||||
base = self.sb.new()
|
||||
ops = self._column_with_a_symbol_between()
|
||||
wire = {"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 1,
|
||||
"to": "$c", "to_terminal": 0}
|
||||
# the arm that must differ: the default path is straight, saved
|
||||
# without segments, and so through the middle coil
|
||||
plain = self.ok(self.sb.edit(base, ops + [wire], out="plain.qet"))
|
||||
self.assertNotIn("<segment", Path(plain["output"]).read_text(encoding="utf-8"))
|
||||
r = self.ok(self.sb.edit(base, ops + [{**wire, "route": "avoid"}], out="routed.qet"))
|
||||
self.assertNotIn("note", r["operations"][-1])
|
||||
xml = Path(r["output"]).read_text(encoding="utf-8")
|
||||
self.assertIn("<segment", xml, "a routed path is saved as segments")
|
||||
self.assertFalse(self._crosses_the_middle_coil(xml))
|
||||
# the check can fail: the straight default path, as segments
|
||||
self.assertTrue(self._crosses_the_middle_coil(
|
||||
'<segment length="260" orientation="vertical"/>'))
|
||||
# one undo puts the default path back
|
||||
u = self.ok(self.sb.edit(base, ops + [{**wire, "route": "avoid"}, {"op": "undo"}],
|
||||
out="undone.qet"))
|
||||
self.assertNotIn("<segment", Path(u["output"]).read_text(encoding="utf-8"))
|
||||
|
||||
def test_route_conductor_reroutes_an_existing_wire(self):
|
||||
base = self.sb.new()
|
||||
ops = self._column_with_a_symbol_between() + [
|
||||
{"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 1,
|
||||
"to": "$c", "to_terminal": 0},
|
||||
{"op": "route_conductor", "folio": "$f", "element": "$a", "terminal": 1}]
|
||||
r = self.ok(self.sb.edit(base, ops))
|
||||
self.assertEqual(r["operations"][-1]["result"], "routed")
|
||||
xml = Path(r["output"]).read_text(encoding="utf-8")
|
||||
self.assertIn("<segment", xml)
|
||||
self.assertFalse(self._crosses_the_middle_coil(xml))
|
||||
|
||||
# ---- search and replace ----
|
||||
|
||||
def test_search_and_replace_element_info_across_folios(self):
|
||||
|
||||
Reference in New Issue
Block a user