qet-mcp: qet_diff reports rotated symbols; equal angles compare equal

Rotating a symbol changes only its orientation attribute (quarter turns),
which qet_diff did not read, so a pure rotation reported no change in any
section. The elements section now has "rotated": each symbol whose
orientation changed, with the value before and after.

Rotating and undoing leaves QElectroTech writing text-field rotations as
"-270" where they were "90" (or "-90" for "270"); compared as strings that
read as a change. Rotations of texts, shapes, pictures and element text
fields are now compared reduced to [0, 360).

Found by a seeded-edit invariant run over the shipped examples: 51
rotations unreported across 20 projects, and 7 undo sequences reported as
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-28 12:55:33 +13:00
parent c1b93a8892
commit e64ce0a6ba
2 changed files with 64 additions and 9 deletions
+31 -6
View File
@@ -312,6 +312,21 @@ def _plain_text(html: str) -> str:
return " ".join(inner.split())
def _angle(value: str) -> str:
"""A rotation in degrees, reduced to [0, 360) so equal angles compare equal.
QElectroTech writes the same angle in more than one way: rotating a symbol
and undoing it leaves its text fields at "-270" where they were "90", or
"-90" where they were "270". Compared as written, that read as a change.
Anything that is not a number is returned unchanged.
"""
try:
deg = float(value) % 360
except (TypeError, ValueError):
return value
return f"{deg:g}"
def _extras(root: ET.Element) -> dict:
"""Everything a folio holds besides elements and conductors.
@@ -333,7 +348,7 @@ def _extras(root: ET.Element) -> dict:
texts.append(record(
t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""),
"text": _plain_text(t.get("text", ""))},
{"rotation": t.get("rotation", "0"),
{"rotation": _angle(t.get("rotation", "0")),
"font": t.get("font", ""), "color": t.get("color", "")}))
for sh in d.iter("shape"):
pen, brush = sh.find("pen"), sh.find("brush")
@@ -346,11 +361,11 @@ def _extras(root: ET.Element) -> dict:
"line_width": pen.get("widthF", "") if pen is not None else "",
"fill": (brush.get("color", "") if brush is not None and
brush.get("style", "") != "NoBrush" else "none"),
"rotation": sh.get("rotation", "0")}))
"rotation": _angle(sh.get("rotation", "0"))}))
for im in d.iter("image"):
images.append(record(
im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")},
{"scale": im.get("size", ""), "rotation": im.get("rotation", "")}))
{"scale": im.get("size", ""), "rotation": _angle(im.get("rotation", ""))}))
element_texts = {}
for n, d in _folios(root):
@@ -367,7 +382,7 @@ def _extras(root: ET.Element) -> dict:
fs = (t.get("font", "").split(",") + ["", ""])[1]
element_texts[base + (seen[base],)] = {
"x": t.get("x", ""), "y": t.get("y", ""), "size": fs,
"frame": t.get("frame", ""), "rotation": t.get("rotation", ""),
"frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")),
"width": t.get("text_width", ""),
"shows": t.findtext("text") or ""}
@@ -472,16 +487,24 @@ def tool_diff(before: str, after: str) -> dict:
a_el, b_el = {}, {}
for i, e in _elements(_root(before)):
r = _element_row(i, e)
# Rotation is saved as "orientation", in quarter turns (0-3); it is
# the only thing a rotation changes, so without it a rotated symbol
# reads as untouched.
r["orientation"] = e.get("orientation", "0")
a_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
for i, e in _elements(_root(after)):
r = _element_row(i, e)
r["orientation"] = e.get("orientation", "0")
b_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
moved, relabelled, changed_info = [], [], []
moved, rotated, relabelled, changed_info = [], [], [], []
for k, a in a_el.items():
b = b_el.get(k)
if b is None:
continue
if a["orientation"] != b["orientation"]:
rotated.append({"uuid": k, "name": a["name"], "folio": a["folio"],
"orientation": [a["orientation"], b["orientation"]]})
if (a["x"], a["y"]) != (b["x"], b["y"]):
moved.append({
"uuid": k, "name": a["name"], "folio": a["folio"],
@@ -540,6 +563,7 @@ def tool_diff(before: str, after: str) -> dict:
"distinct_move_deltas": [list(d) for d in deltas],
"relabelled": relabelled[:50],
"info_changed": changed_info[:50],
"rotated": rotated[:50],
},
"conductors": {
"before": len(a_co), "after": len(b_co),
@@ -2178,7 +2202,8 @@ TOOLS = [
{
"name": "qet_diff",
"description": "Structurally diff two .qet files: which elements moved and "
"by what delta, which were added, removed or relabelled, and "
"by what delta, which were rotated (orientation in quarter "
"turns, 0-3), which were added, removed or relabelled, and "
"which conductor fields changed. Use this to verify what an "
"edit actually did, rather than reading a screenshot.",
"inputSchema": {
+33 -3
View File
@@ -899,13 +899,39 @@ class DiffContracts(unittest.TestCase):
"moved_count": 1, "distinct_move_deltas": [[5.0, 0.0]],
"relabelled": [{"uuid": A, "name": "coil", "from": "K1", "to": "K2"}],
"info_changed": [{"uuid": A, "name": "coil", "from": {"label": "K1", "comment": "c"},
"to": {"label": "K2", "comment": "d"}}]})
"to": {"label": "K2", "comment": "d"}}],
"rotated": []})
def test_an_unchanged_element_reports_nothing(self):
p = self.qet(self.folio(self.el(self.A, 1, 2, "K1", "c")))
e = m.tool_diff(p, p)["elements"]
self.assertEqual((e["moved"], e["relabelled"], e["info_changed"], e["added"], e["removed"]),
([], [], [], [], []))
self.assertEqual((e["moved"], e["relabelled"], e["info_changed"], e["added"], e["removed"],
e["rotated"]), ([], [], [], [], [], []))
def test_equal_angles_written_differently_are_the_same(self):
self.assertEqual([m._angle(v) for v in ("-270", "90", "-90", "270", "360", "0", "450", "12.5", "", "x")],
["90", "90", "270", "270", "0", "0", "90", "12.5", "", "x"])
def field(rot):
return self.el(self.A, 0, 0, texts=(
f'<dynamic_elmt_text x="0" y="0" rotation="{rot}" text_from="UserText"><text>t</text>'
'</dynamic_elmt_text>'))
d = m.tool_diff(self.qet(self.folio(field("90"))), self.qet(self.folio(field("-270"))))
self.assertEqual(d["element_texts"]["changed"], [])
d = m.tool_diff(self.qet(self.folio(field("90"))), self.qet(self.folio(field("180"))))
self.assertEqual(d["element_texts"]["changed"][0]["changed"], {"rotation": ["90", "180"]})
def test_a_rotation_is_reported(self):
"""Rotating a symbol changes only its orientation (quarter turns); a diff
that ignored it reported a rotation as no change at all."""
rot = lambda o: self.qet(self.folio(
self.el(self.A, 1, 2).replace('x="1"', f'orientation="{o}" x="1"')))
d = m.tool_diff(rot(0), rot(1))
self.assertEqual(d["elements"]["rotated"],
[{"uuid": self.A, "name": "coil", "folio": 1, "orientation": ["0", "1"]}])
self.assertEqual((d["elements"]["moved"], d["elements"]["relabelled"]), ([], []))
# no attribute is orientation 0, as QElectroTech reads it
plain = self.qet(self.folio(self.el(self.A, 1, 2)))
self.assertEqual(m.tool_diff(plain, rot(0))["elements"]["rotated"], [])
def test_conductors_section_exact(self):
A, B, C = self.A, self.B, self.C
@@ -1064,6 +1090,10 @@ class DiffContracts(unittest.TestCase):
e = m.tool_diff(self.qet(self.folio(many(51, "a"))),
self.qet(self.folio(many(51, "b"))))["elements"]
self.assertEqual((len(e["relabelled"]), len(e["info_changed"])), (50, 50))
turned = lambda o: "".join(self.el(U(i), 0, 0).replace('x="0"', f'orientation="{o}" x="0"')
for i in range(51))
e = m.tool_diff(self.qet(self.folio(turned(0))), self.qet(self.folio(turned(2))))["elements"]
self.assertEqual(len(e["rotated"]), 50)
els = many(102, None)
wires = lambda n, num: "".join(self.wire(U(i), U(i + 1), num=num) for i in range(n))
c = m.tool_diff(self.qet(self.folio(els, wires(101, "a"))),