diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py
index 3a5b80075..29c3ca812 100755
--- a/misc/qet-mcp/qet_mcp.py
+++ b/misc/qet-mcp/qet_mcp.py
@@ -2402,17 +2402,71 @@ _SECONDARY_FOLDER = re.compile(r"(?:^|/)\d+_(?:graphics|manufacturers_articles|m
def _collection_signature(root: Path):
- """Cheap change detector: file count and newest mtime, no parsing."""
+ """Cheap change detector: file count and newest mtime, no parsing.
+ os.scandir() rather than Path.rglob(): the same answer in about a third
+ of the time, which every search pays."""
count, newest = 0, 0.0
- for f in root.rglob("*.elmt"):
- count += 1
+ stack = [str(root)]
+ while stack:
try:
- newest = max(newest, f.stat().st_mtime)
+ it = os.scandir(stack.pop())
except OSError:
- pass
+ continue
+ with it:
+ for entry in it:
+ try:
+ if entry.is_dir(follow_symlinks=False):
+ stack.append(entry.path)
+ elif entry.name.endswith(".elmt"):
+ count += 1
+ newest = max(newest, entry.stat().st_mtime)
+ except OSError:
+ pass
return count, newest
+def _index_cache_file(root: Path) -> Path:
+ """Where the parsed index of one collection is kept between runs."""
+ base = os.environ.get("QET_MCP_CACHE_DIR") or os.path.join(
+ os.environ.get("XDG_CACHE_HOME") or os.path.join(str(Path.home()), ".cache"), "qet-mcp")
+ import hashlib
+ digest = hashlib.sha1(str(root).encode("utf-8")).hexdigest()[:16]
+ return Path(base) / f"element-index-{digest}.json"
+
+
+_INDEX_CACHE_FORMAT = 1
+
+
+def _load_index_cache(root: Path, sig) -> list | None:
+ try:
+ data = json.loads(_index_cache_file(root).read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return None
+ if (data.get("format") != _INDEX_CACHE_FORMAT or data.get("root") != str(root)
+ or tuple(data.get("sig") or ()) != tuple(sig)):
+ return None
+ items = data.get("items") or []
+ for it in items:
+ it["haystack"] = frozenset(it.get("haystack") or ())
+ return items
+
+
+def _save_index_cache(root: Path, sig, items: list) -> None:
+ """Best effort: a cache that cannot be written only costs the next
+ start its 6 s; it never fails a search."""
+ path = _index_cache_file(root)
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ tmp.write_text(json.dumps({
+ "format": _INDEX_CACHE_FORMAT, "root": str(root), "sig": list(sig),
+ "items": [dict(it, haystack=sorted(it["haystack"])) for it in items]}),
+ encoding="utf-8")
+ os.replace(tmp, path)
+ except OSError:
+ pass
+
+
def _index_collection(root: Path) -> list:
"""Parse every .elmt under root once and keep what a search needs.
@@ -2426,6 +2480,10 @@ def _index_collection(root: Path) -> list:
cached = _ELEMENT_INDEX.get(key)
if cached and cached["sig"] == sig:
return cached["items"]
+ items = _load_index_cache(root.resolve(), sig)
+ if items is not None:
+ _ELEMENT_INDEX[key] = {"sig": sig, "items": items}
+ return items
items = []
for f in sorted(root.rglob("*.elmt")):
@@ -2457,6 +2515,7 @@ def _index_collection(root: Path) -> list:
"haystack": frozenset(_search_words(" ".join([*names.values(), rel, kind]))),
})
_ELEMENT_INDEX[key] = {"sig": sig, "items": items}
+ _save_index_cache(root.resolve(), sig, items)
return items
@@ -2768,6 +2827,27 @@ LAYOUT_RULES = {
"are on. An axis a straight wire lines it up on is left alone. "
"\"fix\" moves it onto the grid.",
},
+ "label_on_wire": {
+ "severity": "warning",
+ "note": "A symbol's label is drawn over a wire. House style: a label sits at "
+ "least 20 px past the outermost connected terminal, on a side no "
+ "wire arrives from. Move the label (setElementTextProperty x/y), not "
+ "the symbol. Needs a QElectroTech with elementTextGeometry(); older "
+ "builds skip this rule.",
+ },
+ "four_way_junction": {
+ "severity": "warning",
+ "note": "Wires leave one point in all four directions: a 4-way dot, which "
+ "reads as a crossing when printed. Stagger it into two 3-way "
+ "T-junctions a grid step or more apart.",
+ },
+ "misaligned_branch": {
+ "severity": "info",
+ "note": "Two symbols stacked in the same side branch (each fed by a wire with "
+ "a horizontal run) are a little out of line, so the branch is not one "
+ "straight column. House style: parallel branches share one vertical "
+ "axis. \"dx\" is how far apart their wire docks are.",
+ },
"crossing": {
"severity": "info",
"note": "Two wires cross. Counted so two drafts can be compared; some "
@@ -2784,13 +2864,25 @@ _LAYOUT_SEG = re.compile(r"^\s*\d+:\s*\(([^,]+),([^)]+)\)-\(([^,]+),([^)]+)\)")
_LAYOUT_JS = r"""
var only = @FOLIO@;
var byUuid = typeof qet.conductorPath === 'function';
+function labelBox(f, u) {
+ if (typeof qet.elementTextGeometry !== 'function') return null;
+ var t = qet.elementTexts(f, u);
+ for (var i = 0; i < t.length; i++)
+ if (qet.elementTextProperty(f, u, i, 'source') === 'info'
+ && qet.elementTextProperty(f, u, i, 'info') === 'label') {
+ var g = qet.elementTextGeometry(f, u, i);
+ return (g && g.right > g.left && qet.elementTextProperty(f, u, i, 'shows')) ? g : null;
+ }
+ return null;
+}
for (var f = 0; f < qet.folioCount(); f++) {
if (only >= 0 && f !== only) continue;
var els = qet.elementUuids(f), E = [];
for (var i = 0; i < els.length; i++) {
E.push({uuid: els[i], name: qet.elementName(f, els[i]),
label: qet.elementLabel(f, els[i]), g: qet.elementGeometry(f, els[i]),
- terminals: qet.elementTerminals(f, els[i]).length});
+ terminals: qet.elementTerminals(f, els[i]).length,
+ labelbox: labelBox(f, els[i])});
}
var cu = qet.conductorUuids(f), lines = qet.conductors(f), C = [];
for (var j = 0; j < cu.length; j++) {
@@ -2951,7 +3043,9 @@ def _layout_folio(data: dict, max_shift: float) -> dict:
"annotation": int(el.get("terminals", 1) or 0) == 0,
"x": float(g.get("x", 0)), "y": float(g.get("y", 0)),
"xy": (float(g.get("x", 0)), float(g.get("y", 0))),
- "box": box, "docks": []}
+ "box": box, "docks": [],
+ "labelbox": _box(el.get("labelbox") or {})
+ if el.get("labelbox") else None}
wire_count = {}
wires, unread = [], []
for w in data.get("conductors") or []:
@@ -3214,6 +3308,8 @@ def _layout_folio(data: dict, max_shift: float) -> dict:
add("crossing", conductors=[w["uuid"], v["uuid"]], count=n,
note=LAYOUT_RULES["crossing"]["note"])
+ _house_style_rules(symbols, wires, add, dirty_symbols)
+
return {"folio": folio, "symbols": len(symbols), "wires": len(wires),
"unread": unread, "findings": findings, "dirty_wires": dirty_wires,
"dirty_symbols": dirty_symbols, "length": length, "crossings": crossings,
@@ -3221,6 +3317,86 @@ def _layout_folio(data: dict, max_shift: float) -> dict:
"fixes": [move_op(u) for u in move]}
+def _house_style_rules(symbols: dict, wires: list, add, dirty_symbols: set) -> None:
+ """The house-style rules: labels over wires, 4-way junctions, side
+ branches out of line. Pure geometry, like the rest of the scorer."""
+ def seg_hits_box(a, b, box, inset=1.0):
+ l, t, r, btm = box[0] + inset, box[1] + inset, box[2] - inset, box[3] - inset
+ if l >= r or t >= btm:
+ return False
+ (x1, y1), (x2, y2) = a, b
+ if abs(y1 - y2) < 1e-6: # horizontal
+ return t <= y1 <= btm and min(x1, x2) <= r and max(x1, x2) >= l
+ if abs(x1 - x2) < 1e-6: # vertical
+ return l <= x1 <= r and min(y1, y2) <= btm and max(y1, y2) >= t
+ return False
+
+ for s in symbols.values():
+ lb = s.get("labelbox")
+ if not lb:
+ continue
+ hit = [w["uuid"] for w in wires
+ if any(seg_hits_box(a, b, lb) for a, b in zip(w["pts"], w["pts"][1:]))]
+ if hit:
+ add("label_on_wire", element=s["uuid"], label=s["label"], name=s["name"],
+ conductors=hit, note=LAYOUT_RULES["label_on_wire"]["note"])
+ dirty_symbols.add(s["uuid"])
+
+ def key(p):
+ return (round(p[0]), round(p[1]))
+
+ def direction(a, b):
+ dx, dy = b[0] - a[0], b[1] - a[1]
+ if abs(dx) < 1e-6 and abs(dy) < 1e-6:
+ return None
+ if abs(dx) >= abs(dy):
+ return "e" if dx > 0 else "w"
+ return "s" if dy > 0 else "n"
+
+ ways, owners = {}, {}
+ for w in wires:
+ pts = w["pts"]
+ for i, p in enumerate(pts):
+ for q in ((pts[i - 1],) if i else ()) + ((pts[i + 1],) if i + 1 < len(pts) else ()):
+ d = direction(p, q)
+ if d:
+ ways.setdefault(key(p), set()).add(d)
+ owners.setdefault(key(p), set()).add(w["uuid"])
+ for pt, d in sorted(ways.items()):
+ if len(d) == 4 and len(owners[pt]) >= 2:
+ add("four_way_junction", at=list(pt), conductors=sorted(owners[pt]),
+ note=LAYOUT_RULES["four_way_junction"]["note"])
+
+ # A side branch symbol is fed only by wires that jog sideways; one
+ # joined to a neighbour by a straight vertical wire is in a column.
+ side, column = set(), set()
+ for w in wires:
+ xs = {round(p[0], 3) for p in w["pts"]}
+ ends = [e for e in w["ends"] if e]
+ if len(xs) == 1:
+ column.update(ends)
+ elif any(abs(a[1] - b[1]) < 1e-6 and abs(a[0] - b[0]) > 1e-6
+ for a, b in zip(w["pts"], w["pts"][1:])):
+ side.update(ends)
+ side -= column
+ cand = [s for s in symbols.values() if s["uuid"] in side and s["docks"]
+ and not s["annotation"]]
+ # Two symbols joined by a wire of their own are avoidable_bend's case.
+ joined = {frozenset(w["ends"]) for w in wires if all(w["ends"])}
+ for i, s in enumerate(cand):
+ for t in cand[i + 1:]:
+ sb, tb = s["box"], t["box"]
+ if sb[1] < tb[3] and tb[1] < sb[3]:
+ continue # side by side, not stacked
+ if frozenset((s["uuid"], t["uuid"])) in joined:
+ continue
+ dx = min(abs(p[0] - q[0]) for p in s["docks"] for q in t["docks"])
+ if 0.5 < dx <= 80:
+ add("misaligned_branch", elements=[s["uuid"], t["uuid"]],
+ labels=[s["label"], t["label"]], names=[s["name"], t["name"]],
+ dx=round(dx, 1), note=LAYOUT_RULES["misaligned_branch"]["note"])
+
+
def _layout_answer(folios: list, style: str, limit: int) -> dict:
"""Combine per-folio results into the tool's answer."""
symbols = sum(f["symbols"] for f in folios)
@@ -3253,6 +3429,9 @@ def _layout_answer(folios: list, style: str, limit: int) -> dict:
"overlaps": count["overlapping_symbols"],
"off_grid": count["off_grid"],
"crossings": sum(f["crossings"] for f in folios),
+ "labels_on_wires": count["label_on_wire"],
+ "four_way_junctions": count["four_way_junction"],
+ "misaligned_branches": count["misaligned_branch"],
"flow": {"vertical": round(vertical / total, 3) if total else 0.0,
"horizontal": round(horizontal / total, 3) if total else 0.0},
},
@@ -3669,6 +3848,36 @@ SERVER_INSTRUCTIONS = (
"a wire is straight only when its two terminals are exactly in line.")
+def standard_symbols_file(info: dict | None = None) -> Path:
+ """The installation's standard symbols: one symbol per device role,
+ chosen by the user. QET_MCP_STANDARD_SYMBOLS if set, else
+ standard-symbols.json in QElectroTech's data folder."""
+ explicit = os.environ.get("QET_MCP_STANDARD_SYMBOLS", "").strip()
+ if explicit:
+ return Path(explicit).expanduser()
+ data = ((info or {}).get("folders") or {}).get("data")
+ return (Path(data) if data else assistant_info_file().parent) / "standard-symbols.json"
+
+
+def standard_symbols(info: dict | None = None):
+ """The roles from standard-symbols.json, or None when there is none.
+ A file that cannot be read is reported, not hidden."""
+ path = standard_symbols_file(info)
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return None
+ except (OSError, ValueError) as exc:
+ return {"error": f"{path} could not be read: {exc}"}
+ roles = data.get("roles") if isinstance(data, dict) else None
+ if not isinstance(roles, list):
+ return {"error": f"{path} has no \"roles\" list"}
+ return {"file": str(path), "updated": data.get("updated"),
+ "roles": [{k: r.get(k) for k in ("id", "label", "letter", "path",
+ "terminals", "note") if r.get(k) is not None}
+ for r in roles if isinstance(r, dict) and r.get("path")]}
+
+
def tool_about() -> dict:
"""What this server and the QElectroTech it works with look like now."""
info = assistant_info()
@@ -3701,6 +3910,9 @@ def tool_about() -> dict:
"script_api": info.get("script_api"),
# Never the token: it is for the live tools, not the conversation.
"live": {"open": bool(live), "pid": (live or {}).get("pid")},
+ # One symbol per device role, the user's own choice: use these
+ # paths with add_element / qet.addElement before searching.
+ "standard_symbols": standard_symbols(info),
})
return out
@@ -4124,6 +4336,100 @@ def tool_live_switch_project(index: int | None = None, path: str = "") -> dict:
return _live_call({"cmd": "switch_project", "path": str(p)})
+def _abs_path(value, arg: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"'{arg}' must be a file path")
+ p = Path(value).expanduser()
+ if not p.is_absolute():
+ raise ValueError(f"'{arg}' must be absolute, e.g. /home/me/projects/pump.qet")
+ return str(p)
+
+
+def _index_arg(index):
+ if index is not None and (not isinstance(index, int) or isinstance(index, bool) or index < 0):
+ raise ValueError("'index' must be a whole number counted from 0")
+ return index
+
+
+def tool_live_save_project(path: str = "") -> dict:
+ _require_script_consent()
+ request = {"cmd": "save_project"}
+ if path:
+ request["path"] = _abs_path(path, "path")
+ return _live_call(request)
+
+
+def tool_live_close_project(index: int | None = None) -> dict:
+ _require_script_consent()
+ request = {"cmd": "close_project"}
+ if _index_arg(index) is not None:
+ request["index"] = index
+ return _live_call(request)
+
+
+def tool_live_print(folios="all", printer: str = "", output_file: str = "") -> dict:
+ _require_script_consent()
+ if isinstance(folios, list):
+ if not folios or not all(isinstance(i, int) and not isinstance(i, bool) and i >= 0
+ for i in folios):
+ raise ValueError("'folios' as a list holds folio indexes counted from 0")
+ elif folios not in ("all", "current"):
+ raise ValueError("'folios' must be \"all\", \"current\" or a list of indexes")
+ if printer and output_file:
+ raise ValueError("give 'printer' or 'output_file', not both")
+ request = {"cmd": "print", "folios": folios}
+ if printer:
+ request["printer"] = printer
+ if output_file:
+ request["output_file"] = _abs_path(output_file, "output_file")
+ # The user answers QElectroTech's question before anything prints.
+ return _live_call(request, timeout=300)
+
+
+def tool_live_changes(since: int | None = None) -> dict:
+ request = {"cmd": "changes"}
+ if since is not None:
+ if not isinstance(since, int) or isinstance(since, bool) or since < -1:
+ raise ValueError("'since' must be a step index from an earlier answer (or -1)")
+ request["since"] = since
+ return _live_call(request)
+
+
+def tool_live_layout_check(folio: str | int = "current", style: str = "auto",
+ max_shift: float = 40, limit: int = 50,
+ elements_dir: str | None = None, timeout: int = 180,
+ binary: str | None = None) -> dict:
+ """qet_layout_check on what the user sees: QElectroTech writes a copy of
+ the open project (unsaved changes included) to a private folder, the
+ check reads the copy, the copy is deleted. The user's project and file
+ are not touched, and no "run this script?" window is shown."""
+ binary = binary or resolve_binary()
+ if not binary:
+ raise ValueError("no QElectroTech binary to run the check with: set QET_BINARY")
+ work = Path(tempfile.mkdtemp(prefix="qet-live-check-"))
+ try:
+ snap = _live_call({"cmd": "snapshot", "path": str(work / "live.qet")})
+ if not snap.get("ok"):
+ return snap
+ if folio == "current":
+ folio_no = snap.get("folio", -1) + 1 or None
+ elif folio == "all":
+ folio_no = None
+ elif isinstance(folio, int) and not isinstance(folio, bool) and folio >= 1:
+ folio_no = folio
+ else:
+ raise ValueError("'folio' is \"current\", \"all\" or a folio number counted from 1")
+ answer = tool_layout_check(str(binary), snap["path"], folio=folio_no, style=style,
+ max_shift=max_shift, limit=limit,
+ elements_dir=elements_dir, timeout=timeout)
+ answer["checked"] = "the open project, as on screen (unsaved changes included)"
+ # The moves in "fixes" are for qet_edit on a file; live, apply them
+ # with qet_live_run_script (qet.moveElement), one undo step.
+ return answer
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+
+
def tool_live_undo_last() -> dict:
_require_script_consent()
return _live_call({"cmd": "undo_last"})
@@ -5257,6 +5563,84 @@ TOOLS = [
},
"handler": lambda a: tool_live_switch_project(a.get("index"), a.get("path", "")),
},
+ {
+ "name": "qet_live_save_project",
+ "description": "LIVE MODE. Save the current project to its own file, or with "
+ "'path' save it as a new file (absolute; never over an existing "
+ "file), which then becomes its file.",
+ "inputSchema": {"type": "object",
+ "properties": {"path": {"type": "string"}}},
+ "handler": lambda a: tool_live_save_project(a.get("path", "")),
+ },
+ {
+ "name": "qet_live_close_project",
+ "description": "LIVE MODE. Close an open project (the current one, or 'index' "
+ "from qet_live_status's \"projects\") -- only when it has no "
+ "unsaved changes: closing never discards work, so save it first.",
+ "inputSchema": {"type": "object",
+ "properties": {"index": {"type": "integer", "minimum": 0}}},
+ "handler": lambda a: tool_live_close_project(a.get("index")),
+ },
+ {
+ "name": "qet_live_print",
+ "description": "LIVE MODE. Print folios of the current project without the "
+ "print dialog: 'folios' is \"all\" (default), \"current\" or a "
+ "list of indexes from 0; 'printer' names a printer, else the "
+ "computer's default printer. QElectroTech always asks the user "
+ "before printing (paper cannot be taken back); \"refused by the "
+ "user\" means they said no. 'output_file' (absolute .pdf, must "
+ "not exist) prints to a PDF file instead, with no question.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "folios": {"oneOf": [{"type": "string", "enum": ["all", "current"]},
+ {"type": "array", "items": {"type": "integer", "minimum": 0}}]},
+ "printer": {"type": "string"},
+ "output_file": {"type": "string"},
+ },
+ },
+ "handler": lambda a: tool_live_print(a.get("folios", "all"), a.get("printer", ""),
+ a.get("output_file", "")),
+ },
+ {
+ "name": "qet_live_changes",
+ "description": "LIVE MODE. The current project's undo history: each step's name, "
+ "whether the assistant or the user made it, and whether it is "
+ "undone; 'since' (a step index from an earlier answer, e.g. its "
+ "\"now\") keeps only the later steps -- what changed since then. "
+ "Changes nothing.",
+ "inputSchema": {"type": "object",
+ "properties": {"since": {"type": "integer", "minimum": -1}}},
+ "handler": lambda a: tool_live_changes(a.get("since")),
+ },
+ {
+ "name": "qet_live_layout_check",
+ "description": "LIVE MODE. qet_layout_check on the drawing the user has open, "
+ "as on screen (unsaved changes included), without saving it: "
+ "straight wires, symbols in line and on the grid, overlaps, "
+ "and the house-style rules -- labels over wires, 4-way junction "
+ "dots, side branches out of line. Run it before saying a "
+ "drawing is finished. 'folio': \"current\" (default), \"all\" or "
+ "a number from 1. Apply \"fixes\" with qet_live_run_script "
+ "(qet.moveElement), not qet_edit. Changes nothing.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "folio": {"oneOf": [{"type": "string", "enum": ["current", "all"]},
+ {"type": "integer", "minimum": 1}]},
+ "style": {"type": "string", "enum": list(LAYOUT_STYLES)},
+ "max_shift": {"type": "number", "default": 40},
+ "limit": {"type": "integer", "default": 50},
+ "elements_dir": {"type": "string"},
+ "binary": {"type": "string", "description": "the qelectrotech executable "
+ "that runs the check; leave it out to use this server's"},
+ },
+ },
+ "handler": lambda a: tool_live_layout_check(a.get("folio", "current"),
+ a.get("style", "auto"),
+ a.get("max_shift", 40), a.get("limit", 50),
+ a.get("elements_dir"), binary=a.get("binary")),
+ },
{
"name": "qet_live_undo_last",
"description": "LIVE MODE. Undo the newest step in the open project, only if "
@@ -5396,6 +5780,11 @@ _DATA_PATHS = {
"qet_live_new_project": {"write": ("path",)},
"qet_live_open_project": {"read": ("path",)},
"qet_live_switch_project": {"read": ("path",)},
+ "qet_live_save_project": {"write": ("path",)},
+ "qet_live_print": {"write": ("output_file",)},
+ # Runs QElectroTech on a copy QElectroTech writes to a private folder:
+ # no client path, but the binary and elements_dir rules apply.
+ "qet_live_layout_check": {},
"qet_element_build": {"write": ("output",)},
# The scripts folder is chosen by scripts_dir(), never by the client,
# so only the project a script is tried on is a data path here.
@@ -5408,7 +5797,7 @@ _DATA_PATHS = {
# Tools that launch QElectroTech, and so take "binary" and "elements_dir".
_LAUNCHES_QET = {"qet_export", "qet_edit", "qet_query", "qet_continuity",
"qet_check", "qet_layout_check", "qet_project_new", "qet_script_api",
- "qet_script_test", "qet_recording_check"}
+ "qet_script_test", "qet_recording_check", "qet_live_layout_check"}
# Tools that launch QElectroTech only when given this argument.
_LAUNCHES_QET_WITH = {"qet_script_install": "test_project"}
@@ -5418,7 +5807,7 @@ _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"}
_OVERWRITE_OWN_FILE = {"qet_script_install"}
# Tools that create a file and never replace one, whatever the client asks:
# no "overwrite" in their schema, and the flag is ignored if sent anyway.
-_NEVER_OVERWRITE = {"qet_live_new_project"}
+_NEVER_OVERWRITE = {"qet_live_new_project", "qet_live_save_project", "qet_live_print"}
# qet_edit operations that name a file of their own.
_DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"}
diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py
index e0ba86bdc..9cd59ab32 100644
--- a/misc/qet-mcp/test_qet_mcp.py
+++ b/misc/qet-mcp/test_qet_mcp.py
@@ -185,7 +185,9 @@ class ToolRegistry(unittest.TestCase):
"qet_live_undo_last", "qet_live_screenshot", "qet_about",
"qet_recording_list", "qet_recording_read", "qet_recording_check",
"qet_recording_remove", "qet_layout_check", "qet_live_new_project",
- "qet_live_open_project", "qet_live_switch_project"})
+ "qet_live_open_project", "qet_live_switch_project", "qet_live_save_project",
+ "qet_live_close_project", "qet_live_print", "qet_live_changes",
+ "qet_live_layout_check"})
class EditValidation(unittest.TestCase):
@@ -3582,6 +3584,48 @@ class LiveClient(unittest.TestCase):
m.tool_live_open_project(bad)
self.assertEqual(len(self.seen), 3)
+ def test_save_close_print_changes_send_only_what_was_given(self):
+ self.session()
+ m.tool_live_save_project()
+ m.tool_live_save_project("/tmp/x/as.qet")
+ m.tool_live_close_project()
+ m.tool_live_close_project(2)
+ m.tool_live_print()
+ m.tool_live_print([0, 2], printer="HP")
+ m.tool_live_print("current", output_file="/tmp/x/out.pdf")
+ m.tool_live_changes()
+ m.tool_live_changes(4)
+ self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")}
+ for r in self.seen], [
+ {"cmd": "save_project"}, {"cmd": "save_project", "path": "/tmp/x/as.qet"},
+ {"cmd": "close_project"}, {"cmd": "close_project", "index": 2},
+ {"cmd": "print", "folios": "all"},
+ {"cmd": "print", "folios": [0, 2], "printer": "HP"},
+ {"cmd": "print", "folios": "current", "output_file": "/tmp/x/out.pdf"},
+ {"cmd": "changes"}, {"cmd": "changes", "since": 4}])
+ bad = [lambda: m.tool_live_save_project("rel.qet"),
+ lambda: m.tool_live_close_project(-1),
+ lambda: m.tool_live_close_project(True),
+ lambda: m.tool_live_print("some"),
+ lambda: m.tool_live_print([]),
+ lambda: m.tool_live_print([0, -1]),
+ lambda: m.tool_live_print(printer="HP", output_file="/tmp/a.pdf"),
+ lambda: m.tool_live_print(output_file="out.pdf"),
+ lambda: m.tool_live_changes(-2)]
+ for call in bad:
+ with self.assertRaises(ValueError):
+ call()
+ self.assertEqual(len(self.seen), 9)
+
+ def test_writing_live_tools_need_script_consent(self):
+ self.session()
+ with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}):
+ for call in (m.tool_live_save_project, m.tool_live_close_project,
+ m.tool_live_print):
+ with self.assertRaises(ValueError):
+ call()
+ self.assertEqual(self.seen, [])
+
def test_new_project_needs_script_consent(self):
self.session()
with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}):
@@ -3593,6 +3637,106 @@ class LiveClient(unittest.TestCase):
self.info({"socket": self.sock_path + "-gone", "token": "T0K"})
with self.assertRaisesRegex(ValueError, "could not reach"):
m.tool_live_status()
+class HouseStyleLayoutRules(unittest.TestCase):
+ """The house-style rules of the layout check, on made-up geometry."""
+
+ def el(self, uuid, x, y, w=20, h=40, label="", labelbox=None):
+ return {"uuid": uuid, "name": uuid, "label": label, "terminals": 2,
+ "g": {"x": x, "y": y, "left": x - w / 2, "top": y - h / 2,
+ "right": x + w / 2, "bottom": y + h / 2}, "labelbox": labelbox}
+
+ def wire(self, uuid, a, b, path):
+ return {"uuid": uuid, "ends": [a + " terminal 0", b + " terminal 1"],
+ "path": [{"x": x, "y": y} for x, y in path]}
+
+ def rules(self, data):
+ return sorted(f["rule"] for f in m._layout_folio(dict(folio=0, **data), 40)["findings"]
+ if f["rule"] in ("label_on_wire", "four_way_junction", "misaligned_branch"))
+
+ def test_label_over_a_wire_and_clear_of_it(self):
+ els = [self.el("a", 100, 100, label="-K1",
+ labelbox={"left": 95, "top": 150, "right": 120, "bottom": 160}),
+ self.el("b", 100, 300)]
+ over = [self.wire("w", "a", "b", [(100, 120), (100, 280)])]
+ self.assertEqual(self.rules({"elements": els, "conductors": over}), ["label_on_wire"])
+ els[0]["labelbox"] = {"left": 120, "top": 150, "right": 145, "bottom": 160}
+ self.assertEqual(self.rules({"elements": els, "conductors": over}), [])
+
+ def test_four_way_dot_but_not_a_t(self):
+ els = [self.el(k, x, y) for k, x, y in
+ (("n", 200, 100), ("s", 200, 300), ("e", 300, 200), ("w", 100, 200))]
+ four = [self.wire("1", "n", "s", [(200, 120), (200, 200), (200, 280)]),
+ self.wire("2", "w", "e", [(110, 200), (200, 200), (290, 200)])]
+ # Two straight wires through one point have no vertex there...
+ self.assertEqual(self.rules({"elements": els, "conductors": four}), [])
+ star = [self.wire("1", "n", "s", [(200, 120), (200, 200)]),
+ self.wire("2", "s", "n", [(200, 280), (200, 200)]),
+ self.wire("3", "w", "e", [(110, 200), (200, 200)]),
+ self.wire("4", "e", "w", [(290, 200), (200, 200)])]
+ self.assertEqual(self.rules({"elements": els, "conductors": star}), ["four_way_junction"])
+ self.assertEqual(self.rules({"elements": els, "conductors": star[:3]}), [])
+
+ def test_side_branch_out_of_line_like_the_motor_starter(self):
+ # main column at x 500, branch symbols at 580 (hold-in) and 660 (lamp)
+ els = [self.el("s2", 500, 470), self.el("hold", 580, 470), self.el("coil", 500, 580),
+ self.el("lamp", 660, 580)]
+ wires = [self.wire("1", "s2", "hold", [(500, 450), (580, 450)]),
+ self.wire("2", "coil", "lamp", [(500, 560), (660, 560)]),
+ self.wire("3", "s2", "coil", [(500, 490), (500, 560)])]
+ self.assertEqual(self.rules({"elements": els, "conductors": wires}), ["misaligned_branch"])
+ els[3] = self.el("lamp", 580, 580)
+ wires[1] = self.wire("2", "coil", "lamp", [(500, 560), (580, 560)])
+ self.assertEqual(len(wires), 3)
+ self.assertEqual(self.rules({"elements": els, "conductors": wires}), [])
+
+
+class StandardSymbols(unittest.TestCase):
+ def test_absent_listed_and_broken(self):
+ with tempfile.TemporaryDirectory() as d:
+ f = Path(d) / "standard-symbols.json"
+ with mock.patch.dict(os.environ, {"QET_MCP_STANDARD_SYMBOLS": str(f)}):
+ self.assertIsNone(m.standard_symbols())
+ f.write_text(json.dumps({"updated": "2026-10-05", "roles": [
+ {"id": "coil", "label": "Coil", "letter": "K", "terminals": ["A1", "A2"],
+ "path": "common://10_electric/x/bobine3.elmt", "score": 99},
+ {"id": "none_fit", "label": "Nothing", "path": None}]}))
+ got = m.standard_symbols()
+ self.assertEqual(got["roles"], [{"id": "coil", "label": "Coil", "letter": "K",
+ "path": "common://10_electric/x/bobine3.elmt",
+ "terminals": ["A1", "A2"]}])
+ f.write_text("{not json")
+ self.assertIn("could not be read", m.standard_symbols()["error"])
+
+ def test_default_location_is_qet_data_folder(self):
+ with mock.patch.dict(os.environ, {"QET_MCP_STANDARD_SYMBOLS": ""}):
+ self.assertEqual(m.standard_symbols_file({"folders": {"data": "/x/data"}}),
+ Path("/x/data/standard-symbols.json"))
+
+
+class ElementIndexCache(unittest.TestCase):
+ def test_second_process_reads_the_cache_and_a_change_rebuilds(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = Path(d) / "elements"
+ root.mkdir()
+ elmt = (''
+ '{}'
+ '')
+ (root / "a.elmt").write_text(elmt.format("Alpha coil"))
+ with mock.patch.dict(os.environ, {"QET_MCP_CACHE_DIR": str(Path(d) / "cache")}):
+ m._ELEMENT_INDEX.clear()
+ first = m._index_collection(root)
+ self.assertTrue(m._index_cache_file(root.resolve()).is_file())
+ m._ELEMENT_INDEX.clear() # a new server process
+ with mock.patch.object(m.ET, "parse", side_effect=AssertionError("parsed")):
+ again = m._index_collection(root)
+ self.assertEqual([i["path"] for i in again], [i["path"] for i in first])
+ self.assertEqual(again[0]["haystack"], first[0]["haystack"])
+ (root / "b.elmt").write_text(elmt.format("Beta lamp"))
+ m._ELEMENT_INDEX.clear()
+ self.assertEqual(len(m._index_collection(root)), 2)
+ m._ELEMENT_INDEX.clear()
+
+
class AssistantInfoFile(unittest.TestCase):
"""qet-assistant.json: QElectroTech says where things are; the server
believes it over its own per-platform guess."""
diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp
index f1f87a659..1939b61a0 100644
--- a/sources/scripting/liveserver.cpp
+++ b/sources/scripting/liveserver.cpp
@@ -23,6 +23,7 @@
#include "scriptlibrary.h"
#include "../diagram.h"
#include "../diagramview.h"
+#include "../qet.h"
#include "../qetapp.h"
#include "../qetdiagrameditor.h"
#include "../qetproject.h"
@@ -51,6 +52,10 @@
#include
#include
#include
+#include
+#include
+#include
+#include
#include
#include
#include "../projectview.h"
@@ -260,6 +265,16 @@ void LiveServer::handle(const QJsonObject &request)
answer = openProject(request.value(QStringLiteral("path")).toString());
} else if (cmd == QLatin1String("switch_project")) {
answer = switchProject(request);
+ } else if (cmd == QLatin1String("save_project")) {
+ answer = saveProject(request);
+ } else if (cmd == QLatin1String("close_project")) {
+ answer = closeProject(request);
+ } else if (cmd == QLatin1String("print")) {
+ answer = print(request);
+ } else if (cmd == QLatin1String("snapshot")) {
+ answer = snapshot(request.value(QStringLiteral("path")).toString());
+ } else if (cmd == QLatin1String("changes")) {
+ answer = changes(request);
} else if (cmd == QLatin1String("show_folio")) {
answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1));
} else if (cmd == QLatin1String("undo_last")) {
@@ -596,6 +611,228 @@ QJsonObject LiveServer::switchProject(const QJsonObject &request)
return status();
}
+/**
+ @brief LiveServer::saveProject
+ Save the current project to its own file, or, with "path", to a new
+ file that becomes its file (Save As) -- never over an existing one.
+*/
+QJsonObject LiveServer::saveProject(const QJsonObject &request)
+{
+ QETDiagramEditor *e = editor();
+ QETProject *project = e ? e->currentProject() : nullptr;
+ if (!project) return failure(QStringLiteral("no project is open in QElectroTech"));
+ if (project->isReadOnly()) return failure(QStringLiteral("the project is read-only"));
+
+ const QString path = request.value(QStringLiteral("path")).toString().trimmed();
+ if (path.isEmpty()) {
+ if (project->filePath().isEmpty())
+ return failure(QStringLiteral("the project has no file yet: give \"path\" to save it as one"));
+ } else {
+ const QFileInfo info(path);
+ if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path));
+ if (info.exists())
+ return failure(QStringLiteral("%1 already exists; save as never replaces a file").arg(path));
+ if (!info.dir().exists())
+ return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath()));
+ project->setFilePath(info.absoluteFilePath());
+ }
+ const QETResult result = project->write();
+ if (!result.isOk()) return failure(QStringLiteral("not saved: %1").arg(result.errorMessage()));
+ QJsonObject answer = status();
+ answer.insert(QStringLiteral("saved"), project->filePath());
+ return answer;
+}
+
+/**
+ @brief LiveServer::closeProject
+ Close an open project (the current one, or "index"), only when it has
+ no unsaved changes: closing never discards the user's work, and never
+ asks them a question on the assistant's behalf.
+*/
+QJsonObject LiveServer::closeProject(const QJsonObject &request)
+{
+ QETDiagramEditor *e = editor();
+ if (!e) return failure(QStringLiteral("no QElectroTech editor window is open"));
+ const QList views = e->openedProjects();
+ ProjectView *pv = nullptr;
+ if (request.contains(QStringLiteral("index"))) {
+ const int index = request.value(QStringLiteral("index")).toInt(-1);
+ if (index < 0 || index >= views.count())
+ return failure(QStringLiteral("no open project %1: %2 are open, counted from 0")
+ .arg(index).arg(views.count()));
+ pv = views.at(index);
+ } else {
+ pv = e->currentProjectView();
+ if (!pv) return failure(QStringLiteral("no project is open in QElectroTech"));
+ }
+ QETProject *project = pv->project();
+ if (project->projectWasModified())
+ return failure(QStringLiteral("\"%1\" has unsaved changes; save it first "
+ "(save_project) -- closing never discards them")
+ .arg(project->title()));
+ const QString title = project->title();
+ if (!e->closeProject(pv)) return failure(QStringLiteral("QElectroTech did not close \"%1\"").arg(title));
+ QJsonObject answer = status();
+ answer.insert(QStringLiteral("closed"), title);
+ return answer;
+}
+
+/**
+ @brief LiveServer::print
+ Print folios of the current project with no print dialog: "folios" is
+ "all" (default), "current" or a list of indexes; "printer" names one,
+ else the system's default printer. Paper cannot be taken back, so the
+ user is always asked first, whatever "always" says for scripts.
+ "output_file" prints to a new PDF file instead (no paper, so no
+ question; never over an existing file).
+*/
+QJsonObject LiveServer::print(const QJsonObject &request)
+{
+ QETDiagramEditor *e = editor();
+ ProjectView *pv = e ? e->currentProjectView() : nullptr;
+ if (!pv) return failure(QStringLiteral("no project is open in QElectroTech"));
+ QETProject *project = pv->project();
+ const QList all = project->diagrams();
+
+ QList folios;
+ const QJsonValue which = request.value(QStringLiteral("folios"));
+ if (which.isUndefined() || which.toString() == QLatin1String("all")) {
+ folios = all;
+ } else if (which.toString() == QLatin1String("current")) {
+ if (DiagramView *dv = e->currentDiagramView()) folios << dv->diagram();
+ } else if (which.isArray()) {
+ for (const QJsonValue &v : which.toArray()) {
+ const int i = v.toInt(-1);
+ if (i < 0 || i >= all.count())
+ return failure(QStringLiteral("no folio %1: the project has %2, counted from 0")
+ .arg(v.toVariant().toString()).arg(all.count()));
+ folios << all.at(i);
+ }
+ } else {
+ return failure(QStringLiteral("folios must be \"all\", \"current\" or a list of indexes"));
+ }
+ if (folios.isEmpty()) return failure(QStringLiteral("no folio to print"));
+
+ const QString file = request.value(QStringLiteral("output_file")).toString().trimmed();
+ QPrinterInfo info;
+ if (file.isEmpty()) {
+ const QString name = request.value(QStringLiteral("printer")).toString().trimmed();
+ info = name.isEmpty() ? QPrinterInfo::defaultPrinter() : QPrinterInfo::printerInfo(name);
+ if (info.isNull())
+ return failure(name.isEmpty()
+ ? QStringLiteral("this computer has no default printer")
+ : QStringLiteral("no printer called \"%1\" (printers: %2)")
+ .arg(name, QPrinterInfo::availablePrinterNames().join(QStringLiteral(", "))));
+ QMessageBox box(QMessageBox::Question, tr("Impression"),
+ tr("L'assistant veut imprimer %n folio(s) de « %1 » sur « %2 ».", "", int(folios.count()))
+ .arg(project->title(), info.printerName()),
+ QMessageBox::NoButton, e);
+ QPushButton *go = box.addButton(tr("&Imprimer"), QMessageBox::AcceptRole);
+ box.addButton(tr("&Annuler"), QMessageBox::RejectRole);
+ box.setDefaultButton(go);
+ box.exec();
+ if (box.clickedButton() != go) return failure(QStringLiteral("refused by the user"));
+ } else {
+ const QFileInfo fi(file);
+ if (fi.isRelative()) return failure(QStringLiteral("output_file must be absolute: %1").arg(file));
+ if (fi.exists()) return failure(QStringLiteral("%1 already exists; printing never replaces a file").arg(file));
+ }
+
+ QPrinter printer = file.isEmpty() ? QPrinter(info, QPrinter::HighResolution)
+ : QPrinter(QPrinter::HighResolution);
+ if (!file.isEmpty()) {
+ printer.setOutputFormat(QPrinter::PdfFormat);
+ printer.setOutputFileName(file);
+ }
+ printer.setDocName(project->title().isEmpty() ? QStringLiteral("QElectroTech") : project->title());
+ const auto folioRect = [](Diagram *d) {
+ QRectF r = d->border_and_titleblock.borderAndTitleBlockRect();
+ r.adjust(0, 0, 1, 1);
+ return r.toAlignedRect();
+ };
+ //One orientation for the job: some printer drivers ignore a change
+ //between pages. The first folio decides.
+ const QRect first = folioRect(folios.first());
+ printer.setPageOrientation(first.width() > first.height() ? QPageLayout::Landscape
+ : QPageLayout::Portrait);
+ QPainter painter;
+ if (!painter.begin(&printer)) return failure(QStringLiteral("the printer could not be opened"));
+ for (int i = 0; i < folios.count(); ++i) {
+ Diagram *d = folios.at(i);
+ if (i) printer.newPage();
+ //As the PDF export draws a folio: no grid, guides or terminals
+ const bool grid = d->displayGrid(), guides = d->displayGuides();
+ const bool terms = d->drawTerminals(), names = d->drawTerminalNames();
+ d->setDisplayGrid(false); d->setDisplayGuides(false);
+ d->setDrawTerminals(false); d->setDrawTerminalNames(false);
+ d->render(&painter, printer.pageLayout().paintRectPixels(printer.resolution()),
+ folioRect(d), Qt::KeepAspectRatio);
+ d->setDisplayGrid(grid); d->setDisplayGuides(guides);
+ d->setDrawTerminals(terms); d->setDrawTerminalNames(names);
+ }
+ painter.end();
+ return {{QStringLiteral("ok"), true}, {QStringLiteral("printed"), int(folios.count())},
+ {QStringLiteral("to"), file.isEmpty() ? info.printerName() : file}};
+}
+
+/**
+ @brief LiveServer::snapshot
+ Write a copy of the current project, as it is on screen (unsaved
+ changes included), to a new file -- for a check to read -- without
+ saving the project or changing its file. Never over an existing file.
+*/
+QJsonObject LiveServer::snapshot(const QString &raw_path)
+{
+ QETDiagramEditor *e = editor();
+ QETProject *project = e ? e->currentProject() : nullptr;
+ if (!project) return failure(QStringLiteral("no project is open in QElectroTech"));
+ const QFileInfo info(raw_path.trimmed());
+ if (raw_path.trimmed().isEmpty() || info.isRelative())
+ return failure(QStringLiteral("path must be absolute"));
+ if (info.exists()) return failure(QStringLiteral("%1 already exists").arg(raw_path));
+ QDomDocument xml(project->toXml());
+ QString error;
+ if (!QET::writeXmlFile(xml, info.absoluteFilePath(), &error))
+ return failure(QStringLiteral("snapshot not written: %1").arg(error));
+ return {{QStringLiteral("ok"), true}, {QStringLiteral("path"), info.absoluteFilePath()},
+ {QStringLiteral("folio"), e->currentDiagramView()
+ ? int(project->diagrams().indexOf(e->currentDiagramView()->diagram())) : -1}};
+}
+
+/**
+ @brief LiveServer::changes
+ The current project's undo history: each step's name, whether the
+ assistant made it, and whether it is undone. "since" (an index from an
+ earlier answer) keeps only the steps after it, so an assistant can say
+ what it changed since a point. Changes nothing.
+*/
+QJsonObject LiveServer::changes(const QJsonObject &request)
+{
+ QETDiagramEditor *e = editor();
+ QETProject *project = e ? e->currentProject() : nullptr;
+ if (!project) return failure(QStringLiteral("no project is open in QElectroTech"));
+ QUndoStack *stack = project->undoStack();
+ const QString prefix = tr("Assistant : %1").arg(QString());
+ const int since = request.value(QStringLiteral("since")).toInt(-1);
+ QJsonArray steps;
+ int assistant = 0, user = 0;
+ for (int i = qMax(0, since + 1); i < stack->count(); ++i) {
+ const QString text = stack->text(i);
+ const bool mine = text.startsWith(prefix);
+ const bool undone = i >= stack->index();
+ if (!undone) (mine ? assistant : user)++;
+ steps.append(QJsonObject{{QStringLiteral("index"), i}, {QStringLiteral("step"), text},
+ {QStringLiteral("by"), mine ? QStringLiteral("assistant")
+ : QStringLiteral("user")},
+ {QStringLiteral("undone"), undone}});
+ }
+ return {{QStringLiteral("ok"), true}, {QStringLiteral("project"), project->title()},
+ {QStringLiteral("steps"), steps},
+ {QStringLiteral("now"), stack->index() - 1},
+ {QStringLiteral("saved_at"), stack->cleanIndex() - 1},
+ {QStringLiteral("done_by_assistant"), assistant}, {QStringLiteral("done_by_user"), user}};
+}
+
/**
@brief LiveServer::undoLast
Undo the newest step, only if the assistant made it: what the user did
diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h
index 93ba325fa..882d86bbc 100644
--- a/sources/scripting/liveserver.h
+++ b/sources/scripting/liveserver.h
@@ -89,6 +89,11 @@ class LiveServer : public QObject
QJsonObject openProject(const QString &path);
QJsonObject switchProject(const QJsonObject &request);
QJsonArray openProjects() const;
+ QJsonObject saveProject(const QJsonObject &request);
+ QJsonObject closeProject(const QJsonObject &request);
+ QJsonObject print(const QJsonObject &request);
+ QJsonObject changes(const QJsonObject &request);
+ QJsonObject snapshot(const QString &path);
QJsonObject undoLast();
QJsonObject screenshot();
bool confirm(const QString &name, const QString &source);
diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp
index 0de2b7497..78c0833d1 100644
--- a/sources/scripting/qetscriptapi.cpp
+++ b/sources/scripting/qetscriptapi.cpp
@@ -3489,6 +3489,25 @@ bool QetScriptApi::deleteImage(int folioIndex, int imageIndex)
return true;
}
+/**
+ @brief QetScriptApi::elementTextGeometry
+ Where one of a symbol's text fields is drawn on the folio, in folio
+ coordinates: left, top, right, bottom of the text as shown. A label
+ that sits on a wire is visible here, not from x/y alone (x/y are
+ relative to the symbol and say nothing about the text's size).
+ @return an empty map when the text does not exist
+*/
+QVariantMap QetScriptApi::elementTextGeometry(int folioIndex, const QString &elementUuid,
+ int textIndex) const
+{
+ DynamicElementTextItem *t = findElementText(folioIndex, elementUuid, textIndex,
+ QStringLiteral("elementTextGeometry"));
+ if (!t) return {};
+ const QRectF r = t->sceneBoundingRect();
+ return {{QStringLiteral("left"), r.left()}, {QStringLiteral("top"), r.top()},
+ {QStringLiteral("right"), r.right()}, {QStringLiteral("bottom"), r.bottom()}};
+}
+
/**
@brief QetScriptApi::addPdfPage
Render one page of a PDF file to an image and place it, as the "add
diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h
index 572168a0f..6b9e6bb9b 100644
--- a/sources/scripting/qetscriptapi.h
+++ b/sources/scripting/qetscriptapi.h
@@ -556,6 +556,8 @@ class QetScriptApi : public QObject
Q_INVOKABLE QString elementTextProperty(int folioIndex, const QString &elementUuid,
int textIndex, const QString &property) const;
Q_INVOKABLE bool deleteElementText(int folioIndex, const QString &elementUuid, int textIndex);
+ Q_INVOKABLE QVariantMap elementTextGeometry(int folioIndex, const QString &elementUuid,
+ int textIndex) const;
// -- copy elements (with the conductors between them) to a position --
Q_INVOKABLE QStringList duplicateElements(int fromFolioIndex, const QStringList &elementUuids,