Merge pull request #1266 from ispyisail/feature/qet-mcp-layout-ops
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m57s

Add qet_edit operations that place symbols so wires come out straight
This commit is contained in:
ispyisail
2026-10-03 18:35:52 +13:00
committed by GitHub
3 changed files with 379 additions and 1 deletions
+23
View File
@@ -410,6 +410,29 @@ drawing. The check is read-only. On a QElectroTech build without
be read; the answer names those in `unread_wires` and leaves them out of the
score.
**Draw with straight wires from the start**
A wire is straight only when its two terminals are exactly in line, and a
symbol is placed by its origin, not by its terminals. `place_element` does
the arithmetic: it adds a symbol with one of its terminals in line with
another symbol's, `gap` pixels away (40 by default), on the side that
terminal faces.
```json
{"op": "add_element", "folio": 0, "path": "common://…/borne_2.elmt", "x": 100, "y": 100, "id": "x1"},
{"op": "place_element", "folio": 0, "path": "common://…/contact.elmt",
"terminal": 0, "next_to": "$x1", "next_to_terminal": 2, "id": "k1"},
{"op": "add_conductor", "folio": 0, "from": "$x1", "from_terminal": 2, "to": "$k1", "to_terminal": 0}
```
For a column of current paths (IEC) the next symbol goes below; for a
ladder rung (NFPA) to the right, with the symbols rotated so their
terminals face along the rung. If the named terminal faces the wrong way,
the op says so in its `note`. `align_terminal` lines up a symbol that is
already placed; `align_elements` and `distribute_elements` line up and
space whole rows or columns. `place_element` and `align_terminal` need a
QElectroTech with `qet.terminalPosition()`.
**Draw something, and check it landed**
```json
+187 -1
View File
@@ -1524,6 +1524,41 @@ OPS = {
"set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "table"),
("x", "num"), ("y", "num")]),
"delete_table": ("deleteTable", [("folio", "folio"), ("table", "table")]),
# Lining symbols up. These run several calls each, in helpers the
# generated script defines (qetMcp*), not one scripting call.
"align_elements": ("qetMcpAlign", [("folio", "folio"), ("elements", "elmts"),
("edge", "str"), ("to", "elmt")]),
"distribute_elements": ("qetMcpDistribute", [("folio", "folio"), ("elements", "elmts"),
("axis", "str"), ("pitch", "num")]),
# Place a symbol so one of its terminals is exactly in line with
# another symbol's: the wire between the two is then straight. Returns
# the new element's uuid, as add_element does.
"place_element": ("qetMcpPlace", [("folio", "folio"), ("path", "str"),
("terminal", "anyterm"), ("next_to", "elmt"),
("next_to_terminal", "anyterm"),
("side", "str"), ("gap", "num"),
("angle", "num")]),
"align_terminal": ("qetMcpAlignTerminal", [("folio", "folio"), ("element", "elmt"),
("terminal", "anyterm"), ("to", "elmt"),
("to_terminal", "anyterm")]),
}
# Arguments those ops may leave out.
OP_DEFAULTS = {
"align_elements": {"to": ""},
"distribute_elements": {"pitch": 0},
"place_element": {"side": "", "gap": 40, "angle": 0},
}
ALIGN_EDGES = ["left", "center", "right", "top", "middle", "bottom"]
DISTRIBUTE_AXES = ["horizontal", "vertical"]
PLACE_SIDES = ["", "below", "above", "right", "left"]
# What the helpers call, beyond what every edit needs.
_HELPER_NEEDS = {
"qetMcpAlign": {"elementGeometry", "moveElement"},
"qetMcpDistribute": {"elementGeometry", "moveElement"},
"qetMcpPlace": {"elementGeometry", "addElement", "deleteElement", "moveElement",
"rotateElement", "terminalPosition", "terminalIndex"},
"qetMcpAlignTerminal": {"moveElement", "terminalPosition", "terminalIndex"},
}
SHAPES = ["line", "rectangle", "ellipse", "polygon"]
@@ -1579,7 +1614,8 @@ 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} - _ROUTE_METHODS) |
_REQUIRED_METHODS = sorted(({m for m, _ in OPS.values()
if m and not m.startswith("qetMcp")} - _ROUTE_METHODS) |
{"save", "folioCount", "conductorCount", "elementCount"})
_MARKER = "QETEDIT "
@@ -1661,6 +1697,97 @@ def _build_script(operations: list, output: str) -> str:
"' (or two of its terminals carry it)'}));",
" return t;",
"}",
# The layout ops' helpers. qetMcpOp is the index of the op running,
# for the notes they log.
"var qetMcpOp = -1;",
"function qetMcpNote(note) {",
f" qet.log({_js(_MARKER)} + JSON.stringify("
"{kind: 'op_note', index: qetMcpOp, note: note}));",
"}",
"function qetMcpTerm(folio, element, t) {",
" return typeof t === 'string' ? qet.terminalIndex(folio, element, t) : t;",
"}",
"function qetMcpAlign(folio, els, edge, to) {",
" var ref = qet.elementGeometry(folio, to || els[0]);",
" if (ref.left === undefined) { qetMcpNote('no element ' + (to || els[0])); return false; }",
" var across = edge === 'left' || edge === 'center' || edge === 'right';",
" function at(g) {",
" if (edge === 'center') return (g.left + g.right) / 2;",
" if (edge === 'middle') return (g.top + g.bottom) / 2;",
" return g[edge];",
" }",
" var moved = 0;",
" for (var i = 0; i < els.length; i++) {",
" if (els[i] === to) continue;",
" var g = qet.elementGeometry(folio, els[i]);",
" if (g.left === undefined) { qetMcpNote('no element ' + els[i]); return false; }",
" var d = at(ref) - at(g);",
" if (Math.abs(d) < 1e-9) continue;",
" if (!qet.moveElement(folio, els[i], across ? d : 0, across ? 0 : d)) return false;",
" moved++;",
" }",
" return moved;",
"}",
"function qetMcpDistribute(folio, els, axis, pitch) {",
" var k = axis === 'horizontal' ? 'x' : 'y', gs = [];",
" for (var i = 0; i < els.length; i++) {",
" var g = qet.elementGeometry(folio, els[i]);",
" if (g.left === undefined) { qetMcpNote('no element ' + els[i]); return false; }",
" gs.push({e: els[i], v: g[k]});",
" }",
" gs.sort(function (a, b) { return a.v - b.v; });",
" var n = gs.length, first = gs[0].v;",
" var step = pitch > 0 ? pitch : (gs[n - 1].v - first) / (n - 1);",
" var moved = 0;",
" for (var j = 1; j < n; j++) {",
" var t = first + j * step;",
# an even split of a gap that is not a whole number of grid steps
# rounds each place to the grid, so symbols on it stay on it
" if (pitch <= 0 && j < n - 1) t = first + Math.round((t - first) / 10) * 10;",
" var d = t - gs[j].v;",
" if (Math.abs(d) < 1e-9) continue;",
" if (!qet.moveElement(folio, gs[j].e, k === 'x' ? d : 0, k === 'y' ? d : 0)) return false;",
" moved++;",
" }",
" return moved;",
"}",
"var qetMcpStep = {below: [0, 1], above: [0, -1], right: [1, 0], left: [-1, 0]};",
"var qetMcpFacingSide = {s: 'below', n: 'above', e: 'right', w: 'left'};",
"var qetMcpBack = {below: 'n', above: 's', right: 'w', left: 'e'};",
"function qetMcpPlace(folio, path, term, near, nearTerm, side, gap, angle) {",
" var g = qet.elementGeometry(folio, near);",
" if (g.left === undefined) { qetMcpNote('no element ' + near); return ''; }",
" var to = qet.terminalPosition(folio, near, qetMcpTerm(folio, near, nearTerm));",
" if (to.x === undefined) { qetMcpNote('next_to_terminal ' + nearTerm + ' is not a terminal of ' + near); return ''; }",
" var el = qet.addElement(folio, path, g.x, g.y);",
" if (!el) return '';",
# turned first, so the terminal is measured where it ends up
" if (angle && !qet.rotateElement(folio, el, angle)) { qet.deleteElement(folio, el); return ''; }",
" var mine = qet.terminalPosition(folio, el, qetMcpTerm(folio, el, term));",
" if (mine.x === undefined) {",
" qet.deleteElement(folio, el);",
" qetMcpNote('terminal ' + term + ' is not a terminal of ' + path);",
" return '';",
" }",
" side = side || qetMcpFacingSide[to.facing];",
" var s = qetMcpStep[side];",
" if (!qet.moveElement(folio, el, to.x + s[0] * gap - mine.x, to.y + s[1] * gap - mine.y)) return '';",
" if (mine.facing !== qetMcpBack[side])",
" qetMcpNote('terminal ' + term + ' of the new symbol faces ' + mine.facing + ', not '",
" + qetMcpBack[side] + ': the wire to it will bend. Rotate the symbol (rotate_element) '",
" + 'or name another terminal');",
" return el;",
"}",
"function qetMcpAlignTerminal(folio, el, term, to, toTerm) {",
" var a = qet.terminalPosition(folio, to, qetMcpTerm(folio, to, toTerm));",
" var b = qet.terminalPosition(folio, el, qetMcpTerm(folio, el, term));",
" if (a.x === undefined || b.x === undefined) {",
" qetMcpNote('terminal not found: ' + (a.x === undefined ? to + ' ' + toTerm : el + ' ' + term));",
" return false;",
" }",
" var along = a.facing === 'n' || a.facing === 's';",
" return qet.moveElement(folio, el, along ? a.x - b.x : 0, along ? 0 : a.y - b.y);",
"}",
"if (missing.length === 0) {",
]
@@ -1732,6 +1859,16 @@ def _build_script(operations: list, output: str) -> str:
raise ValueError(f"operation {op_index}: {key!r} must be a terminal index "
f"or its uuid, got {value!r}")
return _js(value)
if kind == "anyterm":
# A terminal of an element the helper resolves itself (for
# place_element, one that does not exist yet): its index, or its
# uuid, which the helper turns into the index.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
return _js(value)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ValueError(f"operation {op_index}: {key!r} must be a terminal index "
f"or its uuid, got {value!r}")
return _js(value)
if kind == "element_text":
# A field's uuid is unique only within its element (copies keep
# them), so the lookup takes the op's element too.
@@ -1869,6 +2006,36 @@ def _build_script(operations: list, output: str) -> str:
# The conductor ops take "conductor": "{uuid}" in place of element +
# terminal: a uuid names one conductor for good, where a terminal
# can carry several.
if name in OP_DEFAULTS:
op = {**OP_DEFAULTS[name], **op}
if name == "align_elements":
if op.get("edge") not in ALIGN_EDGES:
raise ValueError(f"operation {i}: unknown edge {op.get('edge')!r}; "
f"expected one of {', '.join(ALIGN_EDGES)}")
if not isinstance(op.get("elements"), list) or len(op["elements"]) < 2:
raise ValueError(f"operation {i}: align_elements needs at least 2 elements")
if name == "distribute_elements":
if op.get("axis") not in DISTRIBUTE_AXES:
raise ValueError(f"operation {i}: unknown axis {op.get('axis')!r}; "
f"expected one of {', '.join(DISTRIBUTE_AXES)}")
pitch = op.get("pitch")
if isinstance(pitch, (int, float)) and not isinstance(pitch, bool) and pitch < 0:
raise ValueError(f"operation {i}: pitch must be >= 0 (0: spread evenly)")
least = 2 if isinstance(pitch, (int, float)) and pitch > 0 else 3
if not isinstance(op.get("elements"), list) or len(op["elements"]) < least:
raise ValueError(f"operation {i}: distribute_elements needs at least "
f"{least} elements" + ("" if least == 2 else
" (or 2 with a \"pitch\")"))
if name == "place_element":
if op.get("side") not in PLACE_SIDES:
raise ValueError(f"operation {i}: unknown side {op.get('side')!r}; expected "
f"one of {', '.join(s for s in PLACE_SIDES if s)}, or none "
"for the way next_to_terminal faces")
gap = op.get("gap")
if isinstance(gap, (int, float)) and not isinstance(gap, bool) and gap <= 0:
raise ValueError(f"operation {i}: gap must be > 0")
if method and method.startswith("qetMcp"):
uuid_methods.update(_HELPER_NEEDS[method])
conductor_js = None
if name in CONDUCTOR_UUID_OPS and "conductor" in op:
if "element" in op or "terminal" in op:
@@ -1905,7 +2072,10 @@ def _build_script(operations: list, output: str) -> str:
raise ValueError(f"operation {i}: \"id\" {ident!r} is already used")
call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})"
if method and method.startswith("qetMcp"):
call = f"{method}({', '.join(args)})"
lines.append(" if (!stop) {")
lines.append(f" qetMcpOp = {i};")
if conductor_js is not None:
lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});")
call = f"(e{i} ? {call} : false)"
@@ -4213,6 +4383,22 @@ TOOLS = [
"description":
"Operations applied in order. Each is an object with \"op\" "
"and that op's arguments. Ops: " + ", ".join(sorted(OPS)) + ". "
"Lining symbols up, so wires come out straight: a wire is "
"straight only when its two terminals are exactly in line. "
"place_element (folio, path, terminal, next_to, "
"next_to_terminal, optional side below|above|right|left -- "
"default the way next_to_terminal faces -- gap, 40 px "
"terminal to terminal, and angle, a rotation applied first, as "
"for a ladder rung) adds a symbol with its terminal in line "
"with next_to's, and notes when that terminal faces the wrong "
"way; align_terminal (folio, element, terminal, to, "
"to_terminal) moves a placed symbol across so the two are in "
"line; align_elements (folio, elements, edge left|center|right|"
"top|middle|bottom, optional to) lines up their boxes, as Edit > "
"Align does; distribute_elements (folio, elements, axis "
"horizontal|vertical, optional pitch) spaces their origins "
"evenly, or pitch apart. place_element and align_terminal need "
"a build with qet.terminalPosition(). "
"Give an op an \"id\" to name what it produced, then refer to "
"it later as \"$id\" -- that is how an element placed by "
"add_element gets wired by add_conductor, and how a folio made "
+169
View File
@@ -474,6 +474,16 @@ class EditValidation(unittest.TestCase):
"set_plc_io": el + [{"op": "set_plc_io", "folio": "$f", "element": "$e",
"index": 0, "property": "address", "value": "1.1"}],
"remove_plc_io": el + [{"op": "remove_plc_io", "folio": "$f", "element": "$e", "index": 0}],
"align_elements": two + [{"op": "align_elements", "folio": "$f",
"elements": ["$e", "$e2"], "edge": "middle"}],
"distribute_elements": two + [{"op": "distribute_elements", "folio": "$f",
"elements": ["$e", "$e2"], "axis": "vertical",
"pitch": 80}],
"place_element": el + [{"op": "place_element", "folio": "$f", "path": "common://x.elmt",
"terminal": 0, "next_to": "$e", "next_to_terminal": 1,
"side": "below", "gap": 60}],
"align_terminal": two + [{"op": "align_terminal", "folio": "$f", "element": "$e2",
"terminal": 0, "to": "$e", "to_terminal": 1}],
})
self.assertEqual(set(samples), set(m.OPS),
"an op has no sample here: add one so it is exercised")
@@ -1528,6 +1538,65 @@ class LayoutCheckTool(unittest.TestCase):
self.assertEqual(r["hint"], "boom")
class LayoutOpsValidation(unittest.TestCase):
"""align_elements, distribute_elements, place_element, align_terminal:
their arguments are checked before QElectroTech starts, and the script
asks for terminalPosition() only when an op needs it."""
A, B, C = ("{00000000-0000-0000-0000-00000000000a}", "{00000000-0000-0000-0000-00000000000b}",
"{00000000-0000-0000-0000-00000000000c}")
def bad(self, op, needle):
with self.assertRaises(ValueError) as cm:
m._build_script([op], "/tmp/x.qet")
self.assertIn(needle, str(cm.exception))
def test_align_arguments(self):
self.bad({"op": "align_elements", "folio": 0, "elements": [self.A, self.B],
"edge": "diagonal"}, "unknown edge")
self.bad({"op": "align_elements", "folio": 0, "elements": [self.A], "edge": "left"},
"at least 2")
def test_distribute_arguments(self):
self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B, self.C],
"axis": "diagonal"}, "unknown axis")
self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B],
"axis": "horizontal"}, "at least 3")
self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B, self.C],
"axis": "vertical", "pitch": -10}, "pitch must be >= 0")
m._build_script([{"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B],
"axis": "vertical", "pitch": 80}], "/tmp/x.qet")
def test_place_arguments(self):
base = {"op": "place_element", "folio": 0, "path": "common://x.elmt", "terminal": 0,
"next_to": self.A, "next_to_terminal": 1}
self.bad({**base, "side": "up"}, "unknown side")
self.bad({**base, "gap": 0}, "gap must be > 0")
self.bad({**base, "terminal": True}, "terminal index or its uuid")
self.bad({**base, "terminal": -1}, "terminal index or its uuid")
self.bad({**base, "terminal": "A1"}, "terminal index or its uuid")
s = m._build_script([{**base, "terminal": "{11111111-2222-3333-4444-555555555555}"}],
"/tmp/x.qet")
self.assertIn('qetMcpPlace(0, "common://x.elmt", "{11111111-2222-3333-4444-555555555555}", '
f'"{self.A}", 1, "", 40, 0)', s)
def test_helpers_are_called_bare_and_needs_follow_use(self):
place = m._build_script([{"op": "align_terminal", "folio": 0, "element": self.A,
"terminal": 0, "to": self.B, "to_terminal": 1}], "/tmp/x.qet")
self.assertIn("qetMcpAlignTerminal(0, ", place)
self.assertNotIn("qet.qetMcp", place)
self.assertIn('"terminalPosition"', place)
plain = m._build_script([{"op": "move_element", "folio": 0, "element": self.A,
"dx": 1, "dy": 0}], "/tmp/x.qet")
self.assertNotIn("terminalPosition\"", plain.split("var need = ")[1].split(";")[0])
self.assertNotIn("qetMcpAlign\"", plain)
def test_defaults_fill_in(self):
s = m._build_script([{"op": "align_elements", "folio": 0, "elements": [self.A, self.B],
"edge": "center"}], "/tmp/x.qet")
self.assertIn(f'qetMcpAlign(0, ["{self.A}", "{self.B}"], "center", "")', s)
class ElementSearch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@@ -5560,6 +5629,106 @@ class LayoutIntegration(unittest.TestCase):
self.assertFalse(r["ok"])
@needs_elements
class LayoutOpsIntegration(unittest.TestCase):
"""The layout ops on a real QElectroTech. place_element and
align_terminal need terminalPosition(); on a build without it they are
skipped here, and the refusal itself is checked instead."""
def setUp(self):
self.sb = Sandbox()
def tearDown(self):
self.sb.close()
def placed(self, ops, out="out.qet"):
r = self.sb.edit(self.sb.new(), ops, out=out)
return r
def xy(self, path):
return {e["uuid"]: (float(e["x"]), float(e["y"])) for e in m.tool_elements(path)["elements"]}
def add(self, i, x, y, path=COIL):
return {"op": "add_element", "folio": 0, "path": path, "x": x, "y": y, "id": f"e{i}"}
def test_align_center(self):
r = self.placed([self.add(0, 100, 100), self.add(1, 137, 200), self.add(2, 90, 300),
{"op": "align_elements", "folio": 0, "elements": ["$e0", "$e1", "$e2"],
"edge": "center"}])
self.assertTrue(r["ok"], r.get("hint"))
self.assertEqual({x for x, _ in self.xy(r["output"]).values()}, {100.0})
def test_distribute_evenly_and_by_pitch(self):
r = self.placed([self.add(0, 100, 100), self.add(1, 130, 100), self.add(2, 300, 100),
{"op": "distribute_elements", "folio": 0,
"elements": ["$e0", "$e1", "$e2"], "axis": "horizontal"}])
self.assertTrue(r["ok"], r.get("hint"))
self.assertEqual(sorted(x for x, _ in self.xy(r["output"]).values()), [100, 200, 300])
r = self.placed([self.add(0, 100, 100), self.add(1, 100, 130),
{"op": "distribute_elements", "folio": 0, "elements": ["$e0", "$e1"],
"axis": "vertical", "pitch": 80}], out="pitch.qet")
self.assertTrue(r["ok"], r.get("hint"))
self.assertEqual(sorted(y for _, y in self.xy(r["output"]).values()), [100, 180])
def need_positions(self, r):
if "terminalPosition" in (r.get("missing_methods") or []):
self.assertFalse(r["ok"])
self.assertIn("terminalPosition", r["hint"])
self.skipTest("this build has no terminalPosition()")
def test_place_element_lines_up_the_terminals(self):
# borne_2's bottom terminal (index 2, local 0,10) over the contact's
# top one (index 0, local 0,-20): 40 px apart, dock to dock
r = self.placed([self.add(0, 100, 100, TERMINAL),
{"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0,
"next_to": "$e0", "next_to_terminal": 2, "id": "k"},
{"op": "add_conductor", "folio": 0, "from": "$e0", "from_terminal": 2,
"to": "$k", "to_terminal": 0}])
self.need_positions(r)
self.assertTrue(r["ok"], r.get("hint"))
placed = r["operations"][1]["result"]
self.assertEqual(self.xy(r["output"])[placed], (100.0, 100 + 10 + 40 + 20))
self.assertNotIn("note", r["operations"][1])
def test_place_element_turned_for_a_rung(self):
# NFPA: turned 270, the block's terminal 2 (its bottom one) faces
# east; the contact placed to its right, turned the same way, then
# meets it with terminal 0 (its top one), facing west
r = self.placed([self.add(0, 100, 100, TERMINAL),
{"op": "rotate_element", "folio": 0, "element": "$e0", "angle": 270},
{"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0,
"next_to": "$e0", "next_to_terminal": 2, "angle": 270, "id": "k"}])
self.need_positions(r)
self.assertTrue(r["ok"], r.get("hint"))
self.assertNotIn("note", r["operations"][2], r["operations"][2])
x, y = self.xy(r["output"])[r["operations"][2]["result"]]
self.assertEqual(y, 100.0)
def test_place_element_warns_about_a_terminal_facing_away(self):
r = self.placed([self.add(0, 100, 100, TERMINAL),
{"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0,
"next_to": "$e0", "next_to_terminal": 1}]) # side terminal
self.need_positions(r)
self.assertTrue(r["ok"], r.get("hint"))
self.assertIn("faces n, not w", r["operations"][1]["note"])
def test_place_element_with_no_such_terminal_places_nothing(self):
r = self.placed([self.add(0, 100, 100, TERMINAL),
{"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 9,
"next_to": "$e0", "next_to_terminal": 2}])
self.need_positions(r)
self.assertFalse(r["ok"])
self.assertEqual(len(self.xy(r["output"])), 1)
def test_align_terminal_moves_across_only(self):
r = self.placed([self.add(0, 100, 100, TERMINAL), self.add(1, 127, 200, SLAVE),
{"op": "align_terminal", "folio": 0, "element": "$e1", "terminal": 0,
"to": "$e0", "to_terminal": 2}])
self.need_positions(r)
self.assertTrue(r["ok"], r.get("hint"))
self.assertIn((100.0, 200.0), self.xy(r["output"]).values())
@needs_binary
class PlcIntegration(unittest.TestCase):
"""PLC IO table and PLC-slave linking, against the fixtures in