Merge remote-tracking branch 'upstream/master' into feature/mcp-folio-presets

# Conflicts:
#	misc/qet-mcp/README.md
#	misc/qet-mcp/qet_mcp.py
This commit is contained in:
ispyisail
2026-10-03 07:44:54 +13:00
460 changed files with 116374 additions and 59539 deletions
+12 -1
View File
@@ -29,6 +29,9 @@ and 32 pixels, unchanged:
ico/breeze/22/<name>.svg
ico/breeze/32/<name>.svg
The settings page icons in BREEZE_PAGES are copied from the one size
listed for each, to ico/breeze/<size>/<name>.svg.
Many Breeze names are symbolic links to another file. The copy holds the
linked file's content under the name BREEZE gives, so list the link's
target in BREEZE and several QET icons share one copy. Every name needs
@@ -39,7 +42,7 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from make_icon_themes import BREEZE, BREEZE_SIZES, ICO # noqa: E402
from make_icon_themes import BREEZE, BREEZE_PAGES, BREEZE_SIZES, ICO # noqa: E402
CATEGORIES = ["actions", "places", "mimetypes", "status", "devices", "apps", "preferences"]
OUT = ICO / "breeze"
@@ -69,6 +72,14 @@ def main():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(source.read_bytes())
wanted.add(target)
for name, size in BREEZE_PAGES.values():
source = find(checkout, name, size)
if not source:
sys.exit(f"{name}: no {size} pixel icon in {checkout}")
target = OUT / str(size) / f"{name}.svg"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(source.read_bytes())
wanted.add(target)
# Drop icons no longer listed, so ico/breeze/ holds only what the theme uses.
for old in sorted(OUT.glob("*/*.svg")):
if old not in wanted:
+24 -8
View File
@@ -94,9 +94,13 @@ Breeze art:
another multiple of 16 (32, 64, 128...) the smooth 16 pixel design
44 pixels and scalable the smooth 22 pixel design
The Scale=2 folders follow the table at twice their size. Four icons
The Scale=2 folders follow the table at twice their size. Five icons
have a 32 pixel design, used in place of both smooth vectors.
A settings page icon (PAGES) is colored art shown at 64 and 128 pixels:
one SVG in the scalable folder serves it. The printer and settings pages
use the Breeze art QET's PNGs were exported from (BREEZE_PAGES).
An SVG is recolored for the dark theme through its text color (see
dark_svg). A colored SVG, one without a text color, gets no dark copy
and is inherited from the light theme, as a colored PNG is.
@@ -110,7 +114,7 @@ touched; the dark theme inherits them from the light one.
Qt inherits by name, not by size: once a name has any file in the dark
theme, the parent theme is never consulted for that name, and a size the
dark theme lacks is served by scaling the nearest dark file. An icon that
is line art at 22 pixels and colored at 128 (the printer) would then come
is line art at 22 pixels and colored at 128 would then come
out as the 22 pixel copy scaled up on a dark palette. So for every name
the dark theme holds, the .qrc also aliases the light files of the sizes
the dark theme does not have, when they read on the dark window (3:1,
@@ -173,6 +177,7 @@ SVGS = [
"scalable/align-vertical-center.svg",
"scalable/align-vertical-top.svg",
"scalable/diagram.svg",
"scalable/draw-fillet.svg",
"scalable/folio-delete.svg",
"scalable/folio-new.svg",
"scalable/folio-properties.svg",
@@ -197,7 +202,6 @@ BREEZE = {
"applications-development-translation": "amarok_change_language",
"arrow-left": "go-previous",
"arrow-right": "go-next",
"circle": "draw-circle",
"configure": "configure",
"dialog-cancel": "dialog-cancel",
"dialog-ok": "dialog-ok-apply",
@@ -233,7 +237,6 @@ BREEZE = {
"edit-table-insert-row-above": "edit-table-insert-row-above",
"edit-table-insert-row-under": "edit-table-insert-row-under",
"edit-undo": "edit-undo",
"ellipse": "draw-ellipse",
"flip": "object-flip-vertical",
"folder": "folder",
"folder-new": "folder-new",
@@ -283,7 +286,6 @@ BREEZE = {
"two_pages": "view-pages-facing",
"user-busy": "im-user-busy",
"user-online": "im-user-online",
"view-fit-window": "zoom-fit-best",
"view-fullscreen": "view-fullscreen",
"view-pim-journal": "view-calendar-journal",
"view-refresh": "view-refresh",
@@ -299,6 +301,19 @@ BREEZE = {
# Sizes Breeze draws its action icons at, the folders of ico/breeze/.
BREEZE_SIZES = [16, 22, 32]
# Settings page icons: colored art shown at 64 and 128 pixels only, so one
# scalable SVG serves every size. QET name to Breeze name and the Breeze
# size it is copied from (ico/breeze/<size>/<name>.svg). QET's PNGs of
# these were exports of the same Breeze art.
BREEZE_PAGES = {
"printer": ("printer", 64),
"settings": ("systemsettings", 48),
}
# Page icons, QET name to SVG, relative to ico/.
PAGES = {name: f"breeze/{size}/{breeze}.svg" for name, (breeze, size) in BREEZE_PAGES.items()}
PAGES["configure-shortcuts"] = "128x128/configure-shortcuts.svg"
# Where the generator puts the 22 pixel Breeze art moved onto a 24 pixel
# canvas (see on_24_canvas).
BREEZE_24 = ICO / "generated" / "breeze-24"
@@ -599,7 +614,7 @@ def main():
for size in SIZES:
folder = ICO / size
for png in sorted(folder.glob("*.png")):
if png.stem in BREEZE or png.stem in TRACED or png.stem in grid:
if png.stem in BREEZE or png.stem in TRACED or png.stem in grid or png.stem in PAGES:
continue
rel = f"{size}/{png.name}"
names = [png.stem]
@@ -624,8 +639,9 @@ def main():
changed += write_if_changed(target, dark_image)
dark.append(f"themes/qet-dark/{size}/{name}.png")
for rel in SVGS:
name = (ICO / rel).name.replace("-symbolic", "")
svgs = [((ICO / rel).name.replace("-symbolic", ""), rel) for rel in SVGS]
svgs += [(f"{name}.svg", rel) for name, rel in PAGES.items()]
for name, rel in svgs:
light.append((f"themes/qet/scalable/{name}", rel))
text = dark_svg((ICO / rel).read_text(encoding="utf-8"))
if text is None:
+28
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
@@ -557,6 +576,15 @@ Python, plus the hang guard on `addConductor` and the database refresh in
791.25 pt` for tabloid landscape from a new folio. (The PDF export makes
the page the frame and title block plus its one-pixel line, at 96 pixels
an inch: 0.75 pt a pixel.) Needs `qet.folioPresets()` in the build.
- **The `wiring` export names unnamed terminals.** Most shipped symbols
leave their terminals unnamed, so `from_terminal`/`to_terminal` are often
empty. Each row also ends with `from_terminal_index`,
`from_terminal_uuid`, `to_terminal_index` and `to_terminal_uuid`: the
index `add_conductor` takes, and the uuid the `.qet` names the terminal
by (as `qet_edit` accepts in place of the index). The index is empty for
a terminal sharing its point with another, where the order is undefined;
the uuid tells those apart. `wiring_list_view` carries the same four
columns for `qet_query`.
- **`qet_export` isolates its launch.** SingleApplication keys its socket
on `applicationFilePath()`, so a second launch of the same binary path
forwards its request to an already-running instance and returns *that*
+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"),
@@ -1502,7 +1506,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.
@@ -1521,7 +1534,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 "
@@ -1752,6 +1765,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 "
@@ -1857,6 +1878,18 @@ def _build_script(operations: list, output: str) -> str:
f"' rows of ' + qet.folioBorder({f}, 'row-height') + '; frame ' + "
f"Math.ceil(Number(qet.folioBorder({f}, 'width')) + 1) * 0.75 + ' x ' + "
f"Math.ceil(Number(qet.folioBorder({f}, 'height')) + 1) * 0.75 + ' pt'}}));")
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)
@@ -3551,6 +3584,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 "
+129
View File
@@ -22,6 +22,7 @@ away: a check that has never been seen to fail is not evidence.
from __future__ import annotations
import csv
import json
import os
import re
@@ -290,6 +291,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
@@ -352,6 +382,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"}],
@@ -3698,6 +3730,44 @@ class Integration(unittest.TestCase):
note = r["operations"][1].get("note", "")
self.assertIn("from_terminal: no terminal", note)
self.assertIn("to_terminal: no terminal", note)
def test_wiring_export_names_unnamed_terminals(self):
"""--export-wiring gives each end the index add_conductor took and
the uuid the file names the terminal by, even where the terminal
has no name. This strip's file lists its terminals in another order
than the index (top to bottom, then left to right), so a column
holding the file's order would fail here."""
strip = ("common://10_electric/10_allpole/130_terminals_terminal_strips/"
"90_terminal_strips_diagram/90-10-0211.elmt")
base = self.sb.new()
r = self.ok(self.sb.edit(base, [
{"op": "add_element", "id": "a", "folio": 0, "path": strip, "x": 100, "y": 200},
{"op": "add_element", "id": "b", "folio": 0, "path": strip, "x": 400, "y": 200},
{"op": "add_conductor", "folio": 0, "from": "$a", "from_terminal": 2,
"to": "$b", "to_terminal": 3},
{"op": "add_conductor", "folio": 0, "from": "$a", "from_terminal": 1,
"to": "$b", "to_terminal": 0}]))
a_uuid, b_uuid = (o["result"] for o in r["operations"][:2])
out = self.sb.p("wiring.csv")
e = m.tool_export(BINARY, r["output"], "wiring", out)
self.assertTrue(e["ok"], e)
with open(out, encoding="utf-8-sig", newline="") as fh:
rows = list(csv.DictReader(fh, delimiter=";"))
self.assertEqual(len(rows), 2)
# the conductor in the file: which uuid it names each end by
in_file = {c.get("uuid"): {(c.get("element1"), c.get("terminal1")),
(c.get("element2"), c.get("terminal2"))}
for c in ET.parse(r["output"]).getroot().iter("conductor")
if c.get("element1")}
got = set()
for row in rows:
self.assertEqual((row["from_terminal"], row["to_terminal"]), ("", ""))
ends = {(int(row["from_terminal_index"]), row["from_terminal_uuid"]),
(int(row["to_terminal_index"]), row["to_terminal_uuid"])}
self.assertEqual({u for _, u in ends},
{t for _, t in in_file[row["conductor_uuid"]]})
got.add(frozenset(i for i, _ in ends))
self.assertEqual(got, {frozenset({2, 3}), frozenset({1, 0})})
def test_folio_ref_survives_an_insert_before_it(self):
"""add_folio names a folio "$f"; inserting another at position 0
moves it from index 1 to 2. "$f" must still name it."""
@@ -3829,6 +3899,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):