diff --git a/lang/qet_en.ts b/lang/qet_en.ts index b59c89883..15f7578a4 100644 --- a/lang/qet_en.ts +++ b/lang/qet_en.ts @@ -7185,12 +7185,12 @@ Please use the advanced editor for this. LiveServer - + Mode direct Live mode - + Le mode direct est activé : un assistant IA connecté pourra exécuter des scripts sur le projet ouvert. Chaque action s'annule d'un Ctrl+Z, et le bouton « Arrêter » de la barre d'état coupe la connexion. @@ -7203,17 +7203,17 @@ Each action is undone with one Ctrl+Z, and the “Stop” button in the status b This setting is in Configure QElectroTech > General > Projects. - + &Continuer &Continue - + &Pas pour cette session &Not this session - + &Désactiver &Turn off @@ -7228,33 +7228,57 @@ This setting is in Configure QElectroTech > General > Projects.Live mode will open at every start. To be warned again, turn it off and back on in Configure QElectroTech > General > Projects. - - + + script script - + + Impression + Print + + + + L'assistant veut imprimer %n folio(s) de « %1 » sur « %2 ». + + The assistant wants to print %n folio of “%1” on “%2”. + The assistant wants to print %n folios of “%1” on “%2”. + + + + + &Imprimer + &Print + + + + &Annuler + &Cancel + + + + Assistant : %1 Assistant: %1 - + L'assistant veut exécuter un script The assistant wants to run a script - + « %1 » sur le projet ouvert. Une fois exécuté, Ctrl+Z l'annule. “%1” on the open project. Once it has run, Ctrl+Z undoes it. - + &Exécuter &Run - + &Refuser &Decline @@ -9826,7 +9850,7 @@ Available options: - + &Fermer &Close @@ -9888,8 +9912,8 @@ Available options: - - + + Supprimer le folio Delete this sheet @@ -10166,7 +10190,7 @@ Available options: Depth - + Groupe Group @@ -10378,7 +10402,7 @@ Available options: - + Échec de l'ouverture du projet message box title Unable to open project @@ -10391,8 +10415,8 @@ Available options: - - + + QElectroTech window title QElectroTech @@ -10662,8 +10686,8 @@ Hold Ctrl while moving to place freely. - - + + Recharger les dessins des éléments Reload element drawings @@ -11041,7 +11065,7 @@ Hold Ctrl while moving to place freely. - + Scripts Scripts @@ -11119,19 +11143,19 @@ If you save the project, these conductors will disappear from the file. Close it - + Suppression de borne impossible Unable to delete the terminal - + La suppression ne peut être effectué car la selection possède une ou plusieurs bornes ponté et/ou appartenant à une borne à niveau multiple. Déponter et/ou supprimer les niveaux des bornes concerné afin de pouvoir les supprimer The deletion cannot be performed because the selection contains one or more terminals that are bridged and/or belong to a multi-level terminal. Unbridge and/or remove the levels from the affected terminals so that they can be deleted - + %n objet(s) remis sur la grille %n item put back on the grid @@ -11139,7 +11163,7 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + %n objet(s) aligné(s) %n item aligned @@ -11147,22 +11171,22 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + La sélection est déjà sur la grille The selection is already on the grid - + Sélectionnez au moins deux éléments, images, textes, formes ou groupes non verrouillés Select at least two unlocked elements, images, texts, shapes or groups - + La sélection est déjà alignée, à la grille près The selection is already aligned, to the nearest grid step - + (%n objet(s) verrouillé(s) laissé(s) en place) (%n locked object left in place) @@ -11170,100 +11194,100 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + Éditer l'élement edit element Edit the element - + Éditer le champ de texte edit text field Edit the text field - + Éditer l'image edit image Edit the image - + Éditer le conducteur edit conductor Edit conductor - + Éditer l'objet sélectionné edit selected item Edit the selected object - + Une erreur est survenue lors de l'ouverture du fichier %1. message box content An error occurred while opening file %1. - + Active le projet « %1 » Activates the project « %1 » - + Êtes-vous sûr de vouloir supprimer ce folio ? Are you sure you want to delete this sheet? - + Supprimer les folios Delete the sheets - + Êtes-vous sûr de vouloir supprimer les %1 folios sélectionnés ? Are you sure you want to delete the selected %1 sheets ? - + Supprimer %1 folios Delete %1 sheets - - - - - - - + + + + + + + Déplacer les folios Move the sheets - + Erreur message box title Error - + Répéter : insérer « %1 » Repeat: insert "%1" - + Répéter : %1 Repeat: %1 - + folio %1 sheet %1 - + %n élément(s) redessiné(s). %n element redrawn. @@ -11271,7 +11295,7 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + %n élément(s) dont la définition est introuvable ou illisible : leur dessin actuel a été conservé. %n element for which no definition could be found or which were illegible: their current design has been retained. @@ -11279,7 +11303,7 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + %n élément(s) non redessiné(s) : leur taille, leur point de saisie ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée). %n element not redrawn: its size, grip point or terminals have changed (a terminal was added, removed or moved). @@ -11287,18 +11311,18 @@ Unbridge and/or remove the levels from the affected terminals so that they can b - + Pour les mettre à jour, il faut les supprimer puis les réinsérer. Attention : cette opération supprime les conducteurs déjà reliés à ces éléments, qu'il faudra retracer. To update them, they must be deleted then reinserted. Warning: this operation removes the conductors already connected to these elements, which will need to be redrawn. - - + + Exécuter un script Run a script - + Les scripts sont désactivés. Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. N'exécutez que des scripts dont vous connaissez l'origine. @@ -11316,28 +11340,28 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener Remembered after a restart - + Scripts JavaScript (*.js);;Tous les fichiers (*) JavaScript scripts (*.js);;All files (*) - + Ignoré : %1 Ignored: %1 - - + + Arrêter Stop - + Arrêter l'enregistrement de la macro Stop recording the macro - + ● Enregistrement : %n étape(s) ● Recording: %n step @@ -11345,12 +11369,12 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener - + Macro enregistrée Macro recorded - + « %1 » : %n étape(s). Pour en faire un script, demandez-le à votre assistant IA : le bouton ci-dessous copie la demande, il suffit de la coller dans sa fenêtre. @@ -11364,57 +11388,58 @@ To make a script of it, ask your AI assistant: the button below copies the reque - + &Copier la demande pour l'assistant &Copy the request for the assistant - + &Ouvrir le dossier &Open the folder - + Demande copiée : collez-la dans la fenêtre de l'assistant Request copied: paste it into the assistant's window - + Couper la connexion de l'assistant pour le reste de la session Close the assistant's connection for the rest of the session - + Mode direct : assistant connecté Live mode: assistant connected - + Mode direct : en attente d'un assistant Live mode: waiting for an assistant - - + + Assistant Assistant - + Demander avant d'exécuter un script écrit par l'assistant Ask before running a script the assistant wrote + Pour cette session seulement : chaque démarrage redemande For this session only: every start asks again - + Mode direct : %1 %2 à %3 Live mode: %1 %2 at %3 - + %n type(s) d'objets masqué(s) %n kind of item hidden @@ -11422,8 +11447,8 @@ To make a script of it, ask your AI assistant: the button below copies the reque - - + + Textes %1 Texts %1 @@ -14360,22 +14385,22 @@ Do you want to replace it ? Creation of conductors - + To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br>Requires python 3.5 or above.<br><B><U> First install on Windows</B></U><br>1. Install, if required, python 3.5 or above<br> Visit :<br><a href='https://www.python.org/downloads/'>python.org</a><br>2. pip install qet_tb_generator<br><B><U> Update on Windows</B></U><br>python -m pip install --upgrade qet_tb_generator<br>>>user could launch in a terminal this script in this directory<br> C:\users\XXXX\AppData\Local\Programs\Python\Python36-32\Scripts <br> To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br>Requires python 3.5 or above.<br><B><U> First install on Windows</B></U><br>1. Install, if required, python 3.5 or above<br> Visit :<br><a href='https://www.python.org/downloads/'>python.org</a><br>2. pip install qet_tb_generator<br><B><U> Update on Windows</B></U><br>python -m pip install --upgrade qet_tb_generator<br>>>user could launch in a terminal this script in this directory<br> C:\users\XXXX\AppData\Local\Programs\Python\Python36-32\Scripts <br> - + To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><B><U> First install on macOSX</B></U><br>1. Install, if required, python 3.11 bundle only, <a href='https://www.python.org/ftp/python/3.11.2/python-3.11.2-macos11.pkg'>python-3.11.2-macos11.pkg</a><br>2 Run Profile.command script<br>because program use hardcoded PATH for localise qet-tb-generator plugin <br> Visit :<br><a href='https://qelectrotech.org/forum/viewtopic.php?pid=5674#p5674'>howto</a><br>2. pip3 install qet_tb_generator<br><B><U> Update on macOSX</B></U><br> pip3 install --upgrade qet_tb_generator<br> To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><B><U> First install on macOSX</B></U><br>1. Install, if required, python 3.11 bundle only, <a href='https://www.python.org/ftp/python/3.11.2/python-3.11.2-macos11.pkg'>python-3.11.2-macos11.pkg</a><br>2 Run Profile.command script<br>because program use hardcoded PATH for localise qet-tb-generator plugin <br> Visit :<br><a href='https://qelectrotech.org/forum/viewtopic.php?pid=5674#p5674'>howto</a><br>2. pip3 install qet_tb_generator<br><B><U> Update on macOSX</B></U><br> pip3 install --upgrade qet_tb_generator<br> - + To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><br>Requires python 3.5 or above.<br><br><B><U> First install on Linux</B></U><br>1. check you have pip3 installed: pip3 --version<br>If not install with: sudo apt-get install python3-pip<br>2. Install the program: sudo pip3 install qet_tb_generator<br>3. Run the program: qet_tb_generator<br><br><B><U> Update on Linux</B></U><br>sudo pip3 install --upgrade qet_tb_generator<br> To install the plugin qet_tb_generator<br>Visit :<br><a href='https://pypi.python.org/pypi/qet-tb-generator'>qet-tb-generator</a><br><br>Requires python 3.5 or above.<br><br><B><U> First install on Linux</B></U><br>1. check you have pip3 installed: pip3 --version<br>If not install with: sudo apt-get install python3-pip<br>2. Install the program: sudo pip3 install qet_tb_generator<br>3. Run the program: qet_tb_generator<br><br><B><U> Update on Linux</B></U><br>sudo pip3 install --upgrade qet_tb_generator<br> - + Error launching qet_tb_generator plugin Error launching qet_tb_generator plugin @@ -14998,27 +15023,27 @@ Please download it by following the link and unzip it in the installation folder Rotate an image - + Modifier un texte d'élément Edit element text - + Numéroter automatiquement un élément Automatically number an element - + Renuméroter les éléments (%1) Renumber elements (%1) - + Rechercher et remplacer Search and replace - + Script diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index f32e37762..e9770cc63 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -40,9 +40,9 @@ here read the model. | `qet_project_new` | **start from nothing** — an empty project with a title and folios | | `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count | | `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios, terminals with more than four wires, folio reports with several wires | -| `qet_layout_check` | **does the drawing read well?** — a 0–100 score; wires that jog because two symbols are a few pixels out of line, symbols off the grid, wires through symbols, overlaps, crossings; and the moves that fix them, ready for `qet_edit` | +| `qet_layout_check` | **does the drawing read well?** — a 0–100 score; wires that jog because two symbols are a few pixels out of line, symbols off the grid, wires through symbols, overlaps, crossings, labels over wires, 4-way junction dots, side branches out of line; and the moves that fix them, ready for `qet_edit` | | `qet_query` | **ask the project database** — read-only SQL over the views and tables | -| `qet_about` | **start here** — where QElectroTech keeps things, what is switched on, the stored scripts, the calls a script can make (from `qet-assistant.json`) | +| `qet_about` | **start here** — where QElectroTech keeps things, what is switched on, the stored scripts, the calls a script can make (from `qet-assistant.json`), and the installation's standard symbols if it has any | | `qet_script_api` | **what a script can call** — every `qet.*` call of this build, and the header that makes a script a button | | `qet_script_test` | **try a script** on a copy of a project: what it would change, what it logged, its errors | | `qet_script_install` | **make a button** — store a script (and an SVG icon) where QElectroTech shows it in Project > Scripts and the Scripts toolbar | @@ -233,6 +233,8 @@ file or one listed by whoever configured the server: | `QET_MCP_BINARIES` | other executables a call may name, separated like `QET_MCP_WORKSPACE` (for comparing two builds) | | `QET_MCP_ALLOW_ANY_BINARY=1` | turns the check off: a call can then run any program | | `QET_MCP_ELEMENTS` | element collections a call may name as `elements_dir` besides the workspace and the installed one | +| `QET_MCP_CACHE_DIR` | where the parsed element index is kept between runs (default `~/.cache/qet-mcp`); a new server then loads a collection in well under a second instead of parsing every symbol | +| `QET_MCP_STANDARD_SYMBOLS` | the installation's standard symbols, one per device role (default `standard-symbols.json` in QElectroTech's data folder); `qet_about` lists them | Anything else is refused, even a file inside the workspace: being there makes it readable, not runnable. Before this rule any executable a call @@ -324,7 +326,7 @@ general (on the selected elements, say, not on these exact ones), ## Live mode: working in the QElectroTech you have open Every tool above works on files, with no QElectroTech window involved. The -three `qet_live_*` tools instead act on the project open in **your** +`qet_live_*` tools instead act on the project open in **your** QElectroTech, in front of you, so you can watch, stop or undo: | | | @@ -336,6 +338,20 @@ QElectroTech, in front of you, so you can watch, stop or undo: | `qet_live_show_folio` | show another folio | | `qet_live_undo_last` | undo the newest step, only if the assistant made it | | `qet_live_screenshot` | a picture of the folio on screen, as an MCP image, cropped to the folio | +| `qet_live_new_project` | a new project, as File > New makes it, made current; optional title, number of folios and a file to save it to (never over an existing one) | +| `qet_live_open_project` | open a saved project and make it current, with no dialog; one already open is only made current | +| `qet_live_switch_project` | make another open project current, by its index in `qet_live_status`'s `projects` or its file | +| `qet_live_save_project` | save the current project, or save it as a new file (never over an existing one) | +| `qet_live_close_project` | close a project, only when it has no unsaved changes | +| `qet_live_print` | print folios to the default or a named printer with no print dialog -- QElectroTech always asks you first -- or to a new PDF file | +| `qet_live_changes` | the undo history, each step marked assistant or you; `since` gives what changed after a point | +| `qet_live_layout_check` | `qet_layout_check` on the drawing as it is on screen, unsaved changes included, without saving it | + +Every `qet_live_*` call works on the current project. Pages and links +between pages need no tool of their own: in `qet_live_run_script`, +`qet.addFolio()` and `qet.setFolioTitle()` add and name folios, and +`qet.linkElements()` links a folio report arrow, or a coil and its +contacts, across folios. A script the assistant writes on the spot is shown to you first, with *Run*, *Decline* or *Always* (remembered after a restart; untick "Ask diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index df5c2a157..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 @@ -4085,6 +4297,139 @@ def tool_live_show_folio(folio: int) -> dict: return _live_call({"cmd": "show_folio", "folio": folio}) +def tool_live_new_project(title: str = "", folios: int = 1, path: str = "") -> dict: + _require_script_consent() + if not isinstance(folios, int) or isinstance(folios, bool) or not 1 <= folios <= 100: + raise ValueError("'folios' must be a whole number from 1 to 100") + if not isinstance(title, str) or not isinstance(path, str): + raise ValueError("'title' and 'path' must be text") + if path and not Path(path).expanduser().is_absolute(): + raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet") + request = {"cmd": "new_project", "folios": folios} + if title: + request["title"] = title + if path: + request["path"] = str(Path(path).expanduser()) + return _live_call(request) + + +def tool_live_open_project(path: str) -> dict: + if not isinstance(path, str) or not path.strip(): + raise ValueError("'path' must be the project's file, e.g. /home/me/projects/pump.qet") + p = Path(path).expanduser() + if not p.is_absolute(): + raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet") + return _live_call({"cmd": "open_project", "path": str(p)}) + + +def tool_live_switch_project(index: int | None = None, path: str = "") -> dict: + if (index is None) == (not path): + raise ValueError("give exactly one of 'index' (from qet_live_status's " + "\"projects\") or 'path'") + if index is not None: + if not isinstance(index, int) or isinstance(index, bool) or index < 0: + raise ValueError("'index' must be a whole number counted from 0") + return _live_call({"cmd": "switch_project", "index": index}) + p = Path(path).expanduser() + if not p.is_absolute(): + raise ValueError("'path' must be absolute") + 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"}) @@ -5091,7 +5436,9 @@ TOOLS = [ "name": "qet_live_status", "description": "LIVE MODE. Ask the QElectroTech the user has open what is on " "screen: the project, the folio shown (index and title), the " - "selected elements, the last undo step and the stored scripts. " + "selected elements, the last undo step, the stored scripts, and " + "\"projects\": every project open in the window (index, title, " + "file, folios, unsaved changes, which is current). " "Works only if the user switched live mode on in QElectroTech " "and accepted its warning at this start; the error says which " "step is missing. Changes nothing.", @@ -5164,6 +5511,136 @@ TOOLS = [ }, "handler": lambda a: tool_live_show_folio(a["folio"]), }, + { + "name": "qet_live_new_project", + "description": "LIVE MODE. Create a new project in the QElectroTech the " + "user has open, as File > New does (this installation's " + "new-folio defaults), and make it the current project: every " + "following qet_live_* call works on it. Optional title, number " + "of empty folios (pages, 1-100) and an absolute path to save it " + "to at once -- never over an existing file. Then, in " + "qet_live_run_script: qet.addFolio() / qet.insertFolio(i) add " + "pages, qet.setFolioTitle(i, text) names them, and " + "qet.linkElements(folioA, a, folioB, b) links across pages -- " + "a going folio report arrow to a coming one, or a coil to its " + "contacts on another page.", + "inputSchema": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "the project's title"}, + "folios": {"type": "integer", "default": 1, "minimum": 1, "maximum": 100}, + "path": {"type": "string", + "description": "absolute .qet path to save to now; must not exist"}, + }, + }, + "handler": lambda a: tool_live_new_project(a.get("title", ""), a.get("folios", 1), + a.get("path", "")), + }, + { + "name": "qet_live_open_project", + "description": "LIVE MODE. Open a saved project (.qet, absolute path) in the " + "QElectroTech the user has open and make it current, as File > " + "Open does but with no dialog; errors come back as text. A " + "project already open is only made current. Every following " + "qet_live_* call works on it.", + "inputSchema": { + "type": "object", + "properties": {"path": {"type": "string", "description": "absolute .qet path"}}, + "required": ["path"], + }, + "handler": lambda a: tool_live_open_project(a["path"]), + }, + { + "name": "qet_live_switch_project", + "description": "LIVE MODE. Make another project that is already open the " + "current one, by its index in qet_live_status's \"projects\" or " + "by its file path. Every following qet_live_* call works on it. " + "Changes no project.", + "inputSchema": { + "type": "object", + "properties": {"index": {"type": "integer", "minimum": 0}, + "path": {"type": "string"}}, + }, + "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 " @@ -5298,6 +5775,16 @@ _DATA_PATHS = { "qet_check": {"read": ("project",)}, "qet_layout_check": {"read": ("project",)}, "qet_project_new": {"write": ("output",)}, + # QElectroTech writes this file, but where is the client's choice: + # held to the same workspace as every other file a tool writes. + "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. @@ -5310,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"} @@ -5318,6 +5805,9 @@ _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} # Tools whose "overwrite" guards a file the server names itself (the # stored script, in scripts_dir()), not a client-chosen output path. _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", "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"} @@ -5517,6 +6007,9 @@ def enforce_path_policy(tool_name: str, arguments: dict) -> None: # Writing over something that is already there is the one step this # server cannot undo, so it is the one step it will not take on its # own. qet_project_new already had this flag; the others now match it. + if out.exists() and tool_name in _NEVER_OVERWRITE: + raise ValueError(f"{arg!r} already exists: {out}. This tool only " + "creates new files; choose another name.") if out.exists() and not arguments.get("overwrite"): raise ValueError( f"{arg!r} already exists: {out}. Pass \"overwrite\": true to " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index b4524d7ed..9cd59ab32 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -184,7 +184,10 @@ class ToolRegistry(unittest.TestCase): "qet_live_run_stored", "qet_live_command", "qet_live_show_folio", "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_recording_remove", "qet_layout_check", "qet_live_new_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): @@ -2829,7 +2832,20 @@ class PathPolicy(unittest.TestCase): guarded = {name for name, spec in m._DATA_PATHS.items() if spec.get("write")} advertised = {t["name"] for t in m.TOOLS if "overwrite" in t["inputSchema"].get("properties", {})} - self.assertEqual(guarded, advertised - m._OVERWRITE_OWN_FILE) + self.assertEqual(guarded - m._NEVER_OVERWRITE, advertised - m._OVERWRITE_OWN_FILE) + self.assertFalse(advertised & m._NEVER_OVERWRITE) + + def test_never_overwrite_tools_ignore_the_flag(self): + with tempfile.TemporaryDirectory() as d: + target = Path(d) / "exists.qet" + target.write_text("x") + with mock.patch.dict(os.environ, {"QET_MCP_WORKSPACE": d}): + for args in ({"path": str(target)}, {"path": str(target), "overwrite": True}): + with self.assertRaisesRegex(ValueError, "only creates new files"): + m.enforce_path_policy("qet_live_new_project", args) + m.enforce_path_policy("qet_live_new_project", {"path": str(Path(d) / "new.qet")}) + with self.assertRaises(ValueError): + m.enforce_path_policy("qet_live_new_project", {"path": "/etc/new.qet"}) def test_every_data_path_argument_is_guarded(self): """The other direction: a tool whose schema takes a data path must be @@ -3533,10 +3549,194 @@ class LiveClient(unittest.TestCase): with self.assertRaises(ValueError): m.tool_live_show_folio("2") + def test_new_project_sends_only_what_was_given(self): + self.session() + m.tool_live_new_project() + m.tool_live_new_project("Pump station", 3, "/tmp/x/pump.qet") + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], + [{"cmd": "new_project", "folios": 1}, + {"cmd": "new_project", "folios": 3, "title": "Pump station", + "path": "/tmp/x/pump.qet"}]) + for bad in (0, 101, "2", True): + with self.assertRaises(ValueError): + m.tool_live_new_project(folios=bad) + with self.assertRaisesRegex(ValueError, "absolute"): + m.tool_live_new_project(path="pump.qet") + self.assertEqual(len(self.seen), 2) + + def test_open_and_switch_send_only_what_was_given(self): + self.session() + m.tool_live_open_project("/tmp/x/pump.qet") + m.tool_live_switch_project(1) + m.tool_live_switch_project(path="/tmp/x/pump.qet") + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], + [{"cmd": "open_project", "path": "/tmp/x/pump.qet"}, + {"cmd": "switch_project", "index": 1}, + {"cmd": "switch_project", "path": "/tmp/x/pump.qet"}]) + for bad in ({}, {"index": 0, "path": "/a.qet"}, {"index": -1}, + {"index": True}, {"path": "rel.qet"}): + with self.assertRaises(ValueError): + m.tool_live_switch_project(**bad) + for bad in ("", "rel.qet"): + with self.assertRaises(ValueError): + 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": ""}): + with self.assertRaises(ValueError): + m.tool_live_new_project() + self.assertEqual(self.seen, []) + def test_stale_session_file(self): 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/properties/xrefproperties.cpp b/sources/properties/xrefproperties.cpp index 22fe44cea..e6ed7cd53 100644 --- a/sources/properties/xrefproperties.cpp +++ b/sources/properties/xrefproperties.cpp @@ -61,6 +61,7 @@ XRefProperties::XRefProperties() m_show_power_ctc = true; m_show_terminal_name = true; m_show_all_configured_slaves = false; + m_stack_overlapping = true; m_display = Cross; m_snap_to = Bottom; m_prefix_keys << "power" << "delay" << "switch"; @@ -83,6 +84,7 @@ void XRefProperties::toSettings(QSettings &settings, settings.setValue(prefix % "showpowerctc", m_show_power_ctc); settings.setValue(prefix % "showterminalname", m_show_terminal_name); settings.setValue(prefix % "showallconfiguredslaves", m_show_all_configured_slaves); + settings.setValue(prefix % "stackoverlapping", m_stack_overlapping); QString display = m_display == Cross? "cross" : "contacts"; settings.setValue(prefix % "displayhas", display); QString snap = m_snap_to == Bottom? "bottom" : "label"; @@ -117,6 +119,7 @@ void XRefProperties::fromSettings(const QSettings &settings, m_show_power_ctc = settings.value(prefix % "showpowerctc", true).toBool(); m_show_terminal_name = settings.value(prefix % "showterminalname", true).toBool(); m_show_all_configured_slaves = settings.value(prefix % "showallconfiguredslaves", false).toBool(); + m_stack_overlapping = settings.value(prefix % "stackoverlapping", true).toBool(); QString display = settings.value(prefix % "displayhas", "cross").toString(); display == "cross"? m_display = Cross : m_display = Contacts; QString snap = settings.value(prefix % "snapto", "label").toString(); @@ -148,6 +151,7 @@ QDomElement XRefProperties::toXml(QDomDocument &xml_document) const xml_element.setAttribute("showpowerctc", m_show_power_ctc? "true" : "false"); xml_element.setAttribute("showterminalname", m_show_terminal_name? "true" : "false"); xml_element.setAttribute("showallconfiguredslaves", m_show_all_configured_slaves? "true" : "false"); + xml_element.setAttribute("stackoverlapping", m_stack_overlapping? "true" : "false"); QString display = m_display == Cross? "cross" : "contacts"; xml_element.setAttribute("displayhas", display); QString snap = m_snap_to == Bottom? "bottom" : "label"; @@ -181,6 +185,7 @@ bool XRefProperties::fromXml(const QDomElement &xml_element) { m_show_power_ctc = xml_element.attribute("showpowerctc") == "true"; m_show_terminal_name = xml_element.attribute("showterminalname", "true") == "true"; m_show_all_configured_slaves = xml_element.attribute("showallconfiguredslaves", "false") == "true"; + m_stack_overlapping = xml_element.attribute("stackoverlapping", "true") == "true"; QString display = xml_element.attribute("displayhas", "cross"); display == "cross"? m_display = Cross : m_display = Contacts; QString snap = xml_element.attribute("snapto", "label"); @@ -228,6 +233,7 @@ bool XRefProperties::operator ==(const XRefProperties &xrp) const{ return (m_show_power_ctc == xrp.m_show_power_ctc && m_show_terminal_name == xrp.m_show_terminal_name && m_show_all_configured_slaves == xrp.m_show_all_configured_slaves + && m_stack_overlapping == xrp.m_stack_overlapping && m_display == xrp.m_display && m_snap_to == xrp.m_snap_to && m_prefix == xrp.m_prefix diff --git a/sources/properties/xrefproperties.h b/sources/properties/xrefproperties.h index e2faa015e..08dfeca02 100644 --- a/sources/properties/xrefproperties.h +++ b/sources/properties/xrefproperties.h @@ -63,6 +63,9 @@ class XRefProperties : public PropertiesInterface void setShowAllConfiguredSlaves (const bool a) {m_show_all_configured_slaves = a;} bool showAllConfiguredSlaves () const {return m_show_all_configured_slaves;} + void setStackOverlapping (const bool a) {m_stack_overlapping = a;} + bool stackOverlapping () const {return m_stack_overlapping;} + void setDisplayHas (const DisplayHas dh) {m_display = dh;} DisplayHas displayHas () const {return m_display;} @@ -92,6 +95,7 @@ class XRefProperties : public PropertiesInterface bool m_show_power_ctc; bool m_show_terminal_name; bool m_show_all_configured_slaves; + bool m_stack_overlapping; DisplayHas m_display; SnapTo m_snap_to; Qt::AlignmentFlag m_xref_pos; diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index fa6f73447..c7faddfa6 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1856,8 +1856,10 @@ bool QETDiagramEditor::openAndAddProject( box.exec(); } + //Not when nobody is there to answer: an assistant opening a + //project in live mode would wait on a window it cannot see BackupDialog backup_dialog(this); - if (backup_dialog.exec() == QDialog::Accepted) + if (interactive && backup_dialog.exec() == QDialog::Accepted) { QString backup_path = filepath_info.absolutePath() + QDir::separator() + QDateTime::currentDateTime().toString("yyyy-MM-dd-hh-mm") + "_" + diff --git a/sources/qetgraphicsitem/crossrefitem.cpp b/sources/qetgraphicsitem/crossrefitem.cpp index fb710a968..33b17c463 100644 --- a/sources/qetgraphicsitem/crossrefitem.cpp +++ b/sources/qetgraphicsitem/crossrefitem.cpp @@ -268,6 +268,7 @@ void CrossRefItem::updateLabel() m_shape_path = QPainterPath(); prepareGeometryChange(); m_bounding_rect = QRectF(); + m_text_rects.clear(); // Build geometry and m_hovered_contacts_map using a QImage-backed // painter so font metrics match the screen painter in paint(). @@ -343,9 +344,12 @@ void CrossRefItem::autoPos() @brief CrossRefItem::stackAtBottom Places every cross reference of this folio that snaps to the bottom. Each one is centred under its element at the bottom of the folio, and - when two would overlap, the one of the higher element goes above the - other: several coils in one column get their crosses stacked in the - same order as the coils, instead of all on the same spot. + when its texts would be drawn over the texts of another one, the one + of the higher element goes above the other: several coils in one + column get their crosses stacked in the same order as the coils, + instead of all on the same spot. Crosses that only touch, or overlap + without any text over text, stay where they are. + The types whose properties do not ask for stacking are only centred. */ void CrossRefItem::stackAtBottom() { @@ -374,41 +378,56 @@ void CrossRefItem::stackAtBottom() return a->m_element->uuid() < b->m_element->uuid(); }); + struct Placed { QRectF rect; QList texts; }; const qreal gap = 5; - QList placed; + QList placed; for (CrossRefItem *xref : std::as_const(xrefs)) { const qreal offset = xref->m_properties.offset(); QGIUtility::centerToBottomDiagram(xref, xref->m_element, offset <= 40 ? 5 : offset); - if (xref->boundingRect().isEmpty()) continue; + if (xref->boundingRect().isEmpty() + || !xref->m_properties.stackOverlapping()) + continue; - // Move up past every cross already placed that it would overlap. - // A list of contacts has an empty margin in its bounding rect: - // only its content is tested, so side by side lists do not - // count as overlapping. - const bool list = xref->m_properties.displayHas() - == XRefProperties::Contacts; - QRectF rect = xref->sceneBoundingRect(); - if (list) - rect.adjust(list_margin_left, 0, -list_margin_right, 0); - const qreal bottom = rect.bottom(); + Placed self; + self.rect = xref->sceneBoundingRect(); + const QTransform to_scene = xref->sceneTransform(); + for (const QRectF &text : std::as_const(xref->m_text_rects)) + self.texts << to_scene.mapRect(text); + const qreal bottom = self.rect.bottom(); + + // Move up past every cross already placed whose texts it would + // draw over. Each move goes strictly up, so this ends. for (bool moved = true; moved; ) { moved = false; - for (const QRectF &other : std::as_const(placed)) - if (rect.left() < other.right() && other.left() < rect.right() - && rect.top() < other.bottom() + gap - && other.top() - gap < rect.bottom()) { - rect.moveBottom(other.top() - gap); - moved = true; - } + for (const Placed &other : std::as_const(placed)) { + const qreal dy = other.rect.top() - gap - self.rect.bottom(); + if (dy >= 0 || !textsOverlap(self.texts, other.texts)) + continue; + self.rect.translate(0, dy); + for (QRectF &text : self.texts) text.translate(0, dy); + moved = true; + } } - if (rect.bottom() != bottom) + if (self.rect.bottom() != bottom) xref->setPos(xref->parentItem()->mapFromScene( - xref->scenePos() + QPointF(0, rect.bottom() - bottom))); - placed << rect; + xref->scenePos() + QPointF(0, self.rect.bottom() - bottom))); + placed << self; } } +/** + @brief CrossRefItem::textsOverlap + @return true if one of the rects of a intersects one of the rects of b +*/ +bool CrossRefItem::textsOverlap(const QList &a, const QList &b) +{ + for (const QRectF &ra : a) + for (const QRectF &rb : b) + if (ra.intersects(rb)) return true; + return false; +} + /** @brief CrossRefItem::sceneEvent @param event @@ -1156,9 +1175,9 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in painter.setFont(QETApp::diagramTextsFont(4)); QRectF bt(0, offset, 24, 10); if (terminal_names.size() >= 1) - painter.drawText(bt, Qt::AlignLeft|Qt::AlignTop, terminal_names[0]); + drawText(painter, bt, Qt::AlignLeft|Qt::AlignTop, terminal_names[0]); if (terminal_names.size() >= 2) - painter.drawText(bt, Qt::AlignRight|Qt::AlignTop, terminal_names[1]); + drawText(painter, bt, Qt::AlignRight|Qt::AlignTop, terminal_names[1]); painter.setFont(QETApp::diagramTextsFont(5)); } @@ -1241,7 +1260,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in QRectF text_rect = painter.boundingRect(QRectF(30, offset, 5, 10), Qt::AlignLeft | Qt::AlignVCenter, str); if (!str.isEmpty()) { - painter.drawText(text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); + drawText(painter, text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); bounding_rect = bounding_rect.united(text_rect); } @@ -1286,13 +1305,13 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in painter.setFont(QETApp::diagramTextsFont(4)); // Storage order set above: [0]=NC, [1]=NO, [2]=Common if (terminal_names.size() >= 2) - painter.drawText(QRectF(0, offset, 8, 8), + drawText(painter, QRectF(0, offset, 8, 8), Qt::AlignLeft|Qt::AlignTop, terminal_names[1]); // NO top-left if (terminal_names.size() >= 3) - painter.drawText(QRectF(16, offset+4, 8, 6), + drawText(painter, QRectF(16, offset+4, 8, 6), Qt::AlignRight|Qt::AlignTop, terminal_names[2]); // Common right if (terminal_names.size() >= 1) - painter.drawText(QRectF(0, offset+9, 8, 6), + drawText(painter, QRectF(0, offset+9, 8, 6), Qt::AlignLeft|Qt::AlignTop, terminal_names[0]); // NC bottom-left painter.setFont(QETApp::diagramTextsFont(5)); } @@ -1325,7 +1344,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in str); if (!str.isEmpty()) { - painter.drawText(text_rect, + drawText(painter, text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); bounding_rect = bounding_rect.united(text_rect); @@ -1361,7 +1380,7 @@ QRectF CrossRefItem::drawContact(QPainter &painter, int flags, Element *elmt, in str); if (!str.isEmpty()) { - painter.drawText(text_rect, + drawText(painter, text_rect, Qt::AlignLeft | Qt::AlignVCenter, str); bounding_rect = bounding_rect.united(text_rect); @@ -1454,7 +1473,7 @@ void CrossRefItem::fillCrossRef(QPainter &painter) QSize(middle_cross, 1)), Qt::AlignLeft, str); - painter.drawText(bounding, Qt::AlignLeft, str); + drawText(painter, bounding, Qt::AlignLeft, str); if (m_update_map) { QString pos_str = elementPositionText(elmt, true); @@ -1534,7 +1553,7 @@ void CrossRefItem::fillCrossRef(QPainter &painter) QSize(middle_cross, 1)), Qt::AlignRight, str); - painter.drawText(bounding, Qt::AlignRight, str); + drawText(painter, bounding, Qt::AlignRight, str); if (m_update_map) { QString pos_str = elementPositionText(elmt, true); @@ -1582,7 +1601,7 @@ void CrossRefItem::AddExtraInfo(QPainter &painter, const QString& type) r, Qt::TextWordWrap | Qt::AlignHCenter, text); - painter.drawText(text_bounding, + drawText(painter, text_bounding, Qt::TextWordWrap | Qt::AlignHCenter, text); @@ -1598,6 +1617,21 @@ void CrossRefItem::AddExtraInfo(QPainter &painter, const QString& type) } } +/** + @brief CrossRefItem::drawText + Draws text in rect with painter, like QPainter::drawText. While the + geometry is built (m_update_map), also stores the rect really covered + by the text, so stackAtBottom() can tell texts drawn over each other. +*/ +void CrossRefItem::drawText(QPainter &painter, const QRectF &rect, + int flags, const QString &text) +{ + QRectF drawn; + painter.drawText(rect, flags, text, &drawn); + if (m_update_map && !text.isEmpty()) + m_text_rects << painter.transform().mapRect(drawn); +} + /** @brief CrossRefItem::NOElements @return The linked elements of m_element which are open or switch contact. diff --git a/sources/qetgraphicsitem/crossrefitem.h b/sources/qetgraphicsitem/crossrefitem.h index bb7068981..a16167d83 100644 --- a/sources/qetgraphicsitem/crossrefitem.h +++ b/sources/qetgraphicsitem/crossrefitem.h @@ -59,6 +59,8 @@ class CrossRefItem : public QGraphicsObject void init(); void setUpConnection(); void stackAtBottom(); + static bool textsOverlap(const QList &a, + const QList &b); public: enum { Type = UserType + 1009 }; @@ -138,6 +140,8 @@ class CrossRefItem : public QGraphicsObject const QStringList &master_labels = QStringList()); void fillCrossRef(QPainter &painter); void AddExtraInfo(QPainter &painter, const QString&); + void drawText(QPainter &painter, const QRectF &rect, int flags, + const QString &text); QList NOElements() const; QList NCElements() const; @@ -150,6 +154,7 @@ class CrossRefItem : public QGraphicsObject int m_drawed_contacts; bool m_update_map = false; QMultiMap m_hovered_contacts_map; + QList m_text_rects; //rects of the drawn texts (local coords) Element *m_hovered_contact = nullptr; DynamicElementTextItem *m_text = nullptr; ElementTextItemGroup *m_group = nullptr; diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index a892a9f5f..5327c0d95 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" @@ -49,6 +50,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include "../projectview.h" @@ -263,6 +271,22 @@ void LiveServer::handle(const QJsonObject &request) answer = runStored(request.value(QStringLiteral("script")).toString()); } else if (cmd == QLatin1String("command")) { answer = command(request.value(QStringLiteral("action")).toString()); + } else if (cmd == QLatin1String("new_project")) { + answer = newProject(request); + } else if (cmd == QLatin1String("open_project")) { + 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")) { @@ -325,6 +349,7 @@ QJsonObject LiveServer::status() {QStringLiteral("name"), s.header.name}}); answer.insert(QStringLiteral("stored_scripts"), scripts); //So an assistant can notice a recording the user just made + answer.insert(QStringLiteral("projects"), openProjects()); answer.insert(QStringLiteral("macro_recorder"), QJsonObject{ {QStringLiteral("recording"), MacroRecorder::instance().isRecording()}, {QStringLiteral("steps"), MacroRecorder::instance().stepCount()}, @@ -454,6 +479,376 @@ QJsonObject LiveServer::showFolio(int folio) return {{QStringLiteral("ok"), true}, {QStringLiteral("folio"), folio}}; } +/** + @brief LiveServer::newProject + A new project, as File > New makes it: the new-folio defaults of this + QElectroTech, opened and made the current project, so the next script + works on it. Optional: "title", "folios" (how many empty folios, 1 to + 100, default 1), and "path" to save it to at once -- never over an + existing file, so an assistant cannot replace the user's work. +*/ +QJsonObject LiveServer::newProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + + const int folios = request.value(QStringLiteral("folios")).toInt(1); + if (folios < 1 || folios > 100) + return failure(QStringLiteral("folios must be between 1 and 100, not %1").arg(folios)); + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (!path.isEmpty()) { + 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; a new project is never " + "saved over a file").arg(path)); + if (!info.dir().exists()) + return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath())); + } + + auto project = new QETProject(e); + for (int i = 0; i < folios; ++i) project->addNewDiagram(); + const QString title = request.value(QStringLiteral("title")).toString().trimmed(); + if (!title.isEmpty()) project->setTitle(title); + if (!e->addProject(project)) return failure(QStringLiteral("QElectroTech refused the new project")); + + //Current at once, whether or not QElectroTech is the active + //application: the next request runs on it (see editor()) + for (ProjectView *pv : e->openedProjects()) { + if (pv->project() == project) { + for (QMdiSubWindow *w : e->m_workspace.subWindowList()) + if (w->widget() == pv) e->m_workspace.setActiveSubWindow(w); + } + } + + if (!path.isEmpty()) { + project->setFilePath(path); + const QETResult result = project->write(); + if (!result.isOk()) + return failure(QStringLiteral("the project was created but not saved to %1: %2") + .arg(path, result.errorMessage())); + } + + QJsonObject answer = status(); + answer.insert(QStringLiteral("created"), true); + return answer; +} + +/** + @brief LiveServer::openProjects + The projects open in the editor window, in tab order: the index + switch_project takes, title, file, folios, unsaved changes, and which + one is current. +*/ +QJsonArray LiveServer::openProjects() const +{ + QJsonArray list; + QETDiagramEditor *e = editor(); + if (!e) return list; + const QETProject *current = e->currentProject(); + const QList views = e->openedProjects(); + for (int i = 0; i < views.count(); ++i) { + QETProject *p = views.at(i)->project(); + list.append(QJsonObject{ + {QStringLiteral("index"), i}, + {QStringLiteral("title"), p->title()}, + {QStringLiteral("path"), p->filePath()}, + {QStringLiteral("folios"), int(p->diagrams().count())}, + {QStringLiteral("modified"), p->projectWasModified()}, + {QStringLiteral("read_only"), p->isReadOnly()}, + {QStringLiteral("current"), p == current}}); + } + return list; +} + +/** + @brief LiveServer::openProject + Open a saved project and make it current, as File > Open does but with + no file dialog and no error windows: what is wrong comes back in the + answer. A project already open is only made current. +*/ +QJsonObject LiveServer::openProject(const QString &raw_path) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QString path = raw_path.trimmed(); + if (path.isEmpty()) return failure(QStringLiteral("no path given")); + const QFileInfo info(path); + if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path)); + if (!info.isFile()) return failure(QStringLiteral("%1 does not exist").arg(path)); + if (!info.isReadable()) return failure(QStringLiteral("%1 cannot be read (permissions)").arg(path)); + + QJsonObject extra{{QStringLiteral("opened"), true}}; + if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) { + e->activateProject(pv); + extra = {{QStringLiteral("opened"), false}, {QStringLiteral("already_open"), true}}; + } else if (QETApp::diagramEditorForFile(info.absoluteFilePath())) { + return failure(QStringLiteral("%1 is open in another QElectroTech window; " + "switch to it there").arg(path)); + } else { + if (!e->openAndAddProject(info.absoluteFilePath(), false)) + return failure(QStringLiteral("QElectroTech could not open %1 (not a " + "QElectroTech project, or a newer format)").arg(path)); + if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) e->activateProject(pv); + } + QJsonObject answer = status(); + for (auto it = extra.begin(); it != extra.end(); ++it) answer.insert(it.key(), it.value()); + return answer; +} + +/** + @brief LiveServer::switchProject + Make another open project current, by its index in "projects" (status) + or by its file path. +*/ +QJsonObject LiveServer::switchProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QList views = e->openedProjects(); + ProjectView *target = 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())); + target = views.at(index); + } else { + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (path.isEmpty()) return failure(QStringLiteral("give \"index\" (from \"projects\") or \"path\"")); + target = e->viewForFile(QFileInfo(path).absoluteFilePath()); + if (!target) return failure(QStringLiteral("%1 is not open; open_project opens it").arg(path)); + } + e->activateProject(target); + 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(); + //96 dpi, as the PDF export draws: symbols are replayed at the + //device's resolution, so at a printer's 600-1200 dpi they came out + //many times too big next to the wires (seen on a test print) + printer.setResolution(96); + 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 6cb06cd9a..5a1211feb 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -18,6 +18,7 @@ #ifndef LIVESERVER_H #define LIVESERVER_H +#include #include #include #include @@ -85,6 +86,15 @@ class LiveServer : public QObject QJsonObject runStored(const QString &id); QJsonObject command(const QString &id); QJsonObject showFolio(int folio); + QJsonObject newProject(const QJsonObject &request); + 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, diff --git a/sources/ui/xrefpropertieswidget.cpp b/sources/ui/xrefpropertieswidget.cpp index d08c8d5c7..911804491 100644 --- a/sources/ui/xrefpropertieswidget.cpp +++ b/sources/ui/xrefpropertieswidget.cpp @@ -146,6 +146,7 @@ void XRefPropertiesWidget::saveProperties(int index) { xrp.setShowPowerContac(ui->m_show_power_cb->isChecked()); xrp.setShowTerminalName(ui->m_show_terminal_name_cb->isChecked()); xrp.setShowAllConfiguredSlaves(ui->m_show_all_slaves_cb->isChecked()); + xrp.setStackOverlapping(ui->m_stack_overlapping_cb->isChecked()); xrp.setPrefix("power", ui->m_power_prefix_le->text()); xrp.setPrefix("delay", ui->m_delay_prefix_le->text()); xrp.setPrefix("switch", ui->m_switch_prefix_le->text()); @@ -201,6 +202,8 @@ void XRefPropertiesWidget::updateDisplay() ui->m_snap_to_cb->setCurrentIndex(ui->m_snap_to_cb->findData("label")); ui->m_offset_sb->setEnabled(false); } + ui->m_stack_overlapping_cb->setChecked(xrp.stackOverlapping()); + ui->m_stack_overlapping_cb->setEnabled(ui->m_offset_sb->isEnabled()); if(xrp.getXrefPos() == Qt::AlignTop) ui->m_xrefpos_cb->setCurrentIndex(ui->m_xrefpos_cb->findData("top")); else if(xrp.getXrefPos() == Qt::AlignLeft) ui->m_xrefpos_cb->setCurrentIndex(ui->m_xrefpos_cb->findData("left")); @@ -233,6 +236,7 @@ void XRefPropertiesWidget::updateDisplay() ui->m_display_has_cross_rb->setVisible(!is_plc); ui->m_show_terminal_name_cb->setVisible(!is_plc); ui->m_show_all_slaves_cb->setVisible(!is_plc); + ui->m_stack_overlapping_cb->setVisible(!is_plc); ui->m_cross_properties_gb->setVisible(!is_plc); } @@ -261,4 +265,5 @@ void XRefPropertiesWidget::enableOffsetSB(int i){ ui->m_offset_sb->setEnabled(false); else ui->m_offset_sb->setEnabled(true); + ui->m_stack_overlapping_cb->setEnabled(ui->m_offset_sb->isEnabled()); } diff --git a/sources/ui/xrefpropertieswidget.ui b/sources/ui/xrefpropertieswidget.ui index e79070539..0d33c7a27 100644 --- a/sources/ui/xrefpropertieswidget.ui +++ b/sources/ui/xrefpropertieswidget.ui @@ -89,6 +89,16 @@ + + + + + Quand les textes de deux références croisées se chevauchent, celle de l'élément le plus haut est placée au-dessus de l'autre. + + + Empiler les références dont les textes se chevauchent + + @@ -330,6 +340,7 @@ m_type_cb m_snap_to_cb m_offset_sb + m_stack_overlapping_cb m_xrefpos_cb m_display_has_contacts_rb m_display_has_cross_rb