From 2e782e481c67222440906b89e3f7b52909155fc2 Mon Sep 17 00:00:00 2001 From: Sven Date: Fri, 12 Jun 2026 15:49:48 +0200 Subject: [PATCH] SOme iteration with CLaude to improve the program --- beamline_editor/editor_widget.py | 216 +++++++++++++++++++++++++++++++ elements.py | 24 ++-- main.py | 24 ++++ ui/BeamlineGUI.py | 38 ++++++ ui/BeamlineGUI.ui | 78 ++++++++++- 5 files changed, 367 insertions(+), 13 deletions(-) diff --git a/beamline_editor/editor_widget.py b/beamline_editor/editor_widget.py index 9cde6c7..d49a2db 100644 --- a/beamline_editor/editor_widget.py +++ b/beamline_editor/editor_widget.py @@ -311,3 +311,219 @@ class BeamlineEditorWidget(QWidget): def connections(self): from .connections import Connection return [i for i in self.scene.items() if isinstance(i, Connection)] + + # ── layout persistence ──────────────────────────────────────────────────── + + def saveLayout(self, path: str) -> None: + """ + Save the current canvas to a JSON file. + + Saved data + ---------- + version : int — file-format version (1) + nodes : list — one dict per node: + id, element_type, display_name, x, y + connections : list — one dict per wire: + src_id, src_port_index, + dst_id, dst_port_index + + Parameters + ---------- + path : str + File path to write (created or overwritten). + """ + import json + from .nodes import BeamlineNode + from .connections import Connection + + nodes_data = [] + for item in self.scene.items(): + if isinstance(item, BeamlineNode): + nodes_data.append({ + "id": item.node_id, + "element_type": item.element_type, + "display_name": item.display_name(), + "x": item.pos().x(), + "y": item.pos().y(), + }) + + conns_data = [] + for item in self.scene.items(): + if isinstance(item, Connection): + conns_data.append({ + "src_id": item.src_port.node.node_id, + "src_port_index": item.src_port.index, + "dst_id": item.dst_port.node.node_id, + "dst_port_index": item.dst_port.index, + }) + + payload = { + "version": 1, + "nodes": nodes_data, + "connections": conns_data, + } + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + self.scene.status_changed.emit(f"Layout saved → {path}") + + def loadLayout(self, path: str) -> None: + """ + Clear the canvas and restore a layout previously saved with + saveLayout(). + + on_element_added is fired for every restored node so the host + program can rebuild its own data structures. + + Parameters + ---------- + path : str + Path to a JSON file written by saveLayout(). + """ + self.clear() + self._load_layout_file(path, id_map=None, offset_y=0.0) + self.scene.status_changed.emit(f"Layout loaded ← {path}") + + def importLayout(self, path: str) -> dict[str, str]: + """ + Import a saved layout onto the canvas *without* clearing existing + elements. The imported nodes are placed below all current nodes + so they do not overlap. + + ID conflicts + ------------ + If any node_id in the file already exists on the canvas, a new + UUID is assigned automatically. The mapping is returned so the + host program can update its own records. + + Parameters + ---------- + path : str + Path to a JSON file written by saveLayout(). + + Returns + ------- + dict[str, str] + { old_id: new_id } for every node whose ID was remapped. + Empty dict if there were no conflicts. + """ + existing_ids = { + item.node_id + for item in self.scene.items() + if hasattr(item, "node_id") + } + + # Compute vertical offset: place imported layout below current content + from .nodes import BeamlineNode + current_nodes = [i for i in self.scene.items() if isinstance(i, BeamlineNode)] + if current_nodes: + max_y = max(n.pos().y() + n.H for n in current_nodes) + offset_y = max_y + 80 # 80 px gap between layouts + else: + offset_y = 0.0 + + id_map = self._load_layout_file( + path, + id_map=existing_ids, + offset_y=offset_y, + ) + conflicts = {old: new for old, new in id_map.items() if old != new} + msg = ( + f"Imported layout ← {path}" + + (f" ({len(conflicts)} ID(s) remapped)" if conflicts else "") + ) + self.scene.status_changed.emit(msg) + return conflicts + + # ── internal loader ─────────────────────────────────────────────────────── + + def _load_layout_file( + self, + path: str, + id_map, # set[str] of existing IDs to check, or None + offset_y: float, + ) -> dict[str, str]: + """ + Parse the JSON file and instantiate nodes + connections. + + Returns a dict mapping every node_id in the file to the id actually + used on the canvas (identical unless a conflict forced a remap). + """ + import json, uuid as _uuid + from PyQt5.QtCore import QPointF + from .nodes import create_node, BeamlineNode + from .connections import Connection + + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + + if payload.get("version", 1) != 1: + raise ValueError( + f"Unsupported layout file version: {payload.get('version')}" + ) + + # ── Pass 1: create nodes, remap conflicting IDs ─────────────────────── + existing_ids: set[str] = set(id_map) if id_map is not None else set() + remap: dict[str, str] = {} # file_id → canvas_id + + for nd in payload.get("nodes", []): + file_id = nd["id"] + element_type = nd["element_type"] + display_name = nd.get("display_name", element_type) + x = float(nd["x"]) + y = float(nd["y"]) + offset_y + + node = create_node(element_type, QPointF(x, y)) + + if file_id in existing_ids: + # Conflict: keep the freshly generated UUID the node already has + canvas_id = node.node_id + else: + # No conflict: reuse the saved ID so external records still match + node.node_id = file_id + canvas_id = file_id + + remap[file_id] = canvas_id + existing_ids.add(canvas_id) # prevent later nodes conflicting too + + if display_name != element_type: + node.set_display_name(display_name) + + self.scene.addItem(node) + + # Notify host so it can register the element + if self.scene.on_element_added: + self.scene.on_element_added(element_type, canvas_id) + + # ── Pass 2: rebuild connections using remapped IDs ──────────────────── + # Build a fast lookup: canvas_id → node + node_lookup: dict[str, BeamlineNode] = {} + for item in self.scene.items(): + if isinstance(item, BeamlineNode): + node_lookup[item.node_id] = item + + def _get_port(node: BeamlineNode, port_type: str, index: int): + """Return the Port matching port_type + index, or None.""" + candidates = ( + node._all_out_ports() if port_type == "out" + else node._all_in_ports() + ) + for p in candidates: + if p.index == index: + return p + return None + + for cd in payload.get("connections", []): + src_id = remap.get(cd["src_id"], cd["src_id"]) + dst_id = remap.get(cd["dst_id"], cd["dst_id"]) + src_node = node_lookup.get(src_id) + dst_node = node_lookup.get(dst_id) + if src_node is None or dst_node is None: + continue + src_port = _get_port(src_node, "out", cd.get("src_port_index", 0)) + dst_port = _get_port(dst_node, "in", cd.get("dst_port_index", 0)) + if src_port is None or dst_port is None: + continue + conn = Connection(src_port, dst_port) + self.scene.addItem(conn) + + return remap diff --git a/elements.py b/elements.py index be2cad3..dccfb41 100644 --- a/elements.py +++ b/elements.py @@ -131,7 +131,7 @@ class Quadrupole(Magnet): def __init__(self, prop): Magnet.__init__(self, prop) if not ('k1' in prop): # prop.has_key('k1')): - self.__dict__.update({'k1': 0}) + self.__dict__.update({'k1': 0.}) if not ('Tag' in prop): # prop.has_key('Tag')): self.__dict__.update({'Tag': 'MQUA'}) @@ -146,7 +146,7 @@ class Sextupole(Magnet): def __init__(self, prop): Magnet.__init__(self, prop) if not ('k2' in prop): # prop.has_key('k2')): - self.__dict__.update({'k2': 0}) + self.__dict__.update({'k2': 0.}) if not ('Tag' in prop): # prop.has_key('Tag')): self.__dict__.update({'Tag': 'MSEX'}) @@ -162,7 +162,7 @@ class Solenoid(Magnet): def __init__(self, prop): Magnet.__init__(self, prop) if not ('ks' in prop): # prop.has_key('ks')): - self.__dict__.update({'ks': 0}) + self.__dict__.update({'ks': 0.}) if not ('Tag' in prop): # prop.has_key('Tag')): self.__dict__.update({'Tag': 'MSOL'}) @@ -205,19 +205,19 @@ class Undulator(SimpleContainer): if not ('Group' in prop): # prop.has_key('Group')): self.__dict__.update({'Group': 'ID'}) if not ('K' in prop): # prop.has_key('K')): - self.__dict__.update({'K': 0}) + self.__dict__.update({'K': 0.}) if not ('ku' in prop): # prop.has_key('ku')): - self.__dict__.update({'ku': 0}) + self.__dict__.update({'ku': 0.}) if not ('kx' in prop): # prop.has_key('kx')): - self.__dict__.update({'kx': 0}) + self.__dict__.update({'kx': 0.}) if not ('ky' in prop): # prop.has_key('ky')): - self.__dict__.update({'ky': 1}) + self.__dict__.update({'ky': 1.}) if not ('Tag' in prop): # prop.has_key('Tag')): self.__dict__.update({'Tag': 'UIND'}) if not ('Power' in prop): # prop.has_key('Power')): - self.__dict__.update({'Power': 0}) + self.__dict__.update({'Power': 0.}) if not ('Waist' in prop): # prop.has_key('Waist')): - self.__dict__.update({'Waist': 1}) + self.__dict__.update({'Waist': 1.}) @@ -232,9 +232,9 @@ class RF(SimpleContainer): def __init__(self, prop): SimpleContainer.__init__(self, prop) if not ('Gradient' in prop): # prop.has_key('Gradient')): - self.__dict__.update({'Gradient': 0}) + self.__dict__.update({'Gradient': 0.}) if not ('Phase' in prop): # prop.has_key('Phase')): - self.__dict__.update({'Phase': 0}) + self.__dict__.update({'Phase': 0.}) if not ('Band' in prop): # prop.has_key('Band')): self.__dict__.update({'Band': 'S'}) if not ('Frequency' in prop): # prop.has_key('Frequency')): @@ -251,7 +251,7 @@ class RF(SimpleContainer): # Enable flag for TDS as well as in the diagnostics if 'TDS' in prop['Baugruppe']: - self.__dict__.update({'enable': 0}) + self.__dict__.update({'enable': 0.}) def writeElement(self, app): app.writeRF(self) diff --git a/main.py b/main.py index e81164d..3a4ef99 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import sys +import json from PyQt5 import QtWidgets, QtCore, QtGui from ui.BeamlineGUI import Ui_BeamlineGUI @@ -19,9 +20,32 @@ class BeamlineEditor(QtWidgets.QMainWindow, Ui_BeamlineGUI): QtWidgets.QVBoxLayout(self.UIEditorBox).addWidget(self.editor) + self.actionSave_Templates.triggered.connect(self.saveTemplates) + self.actionLoad_Templates.triggered.connect(self.loadTemplates) + + self.actionSave.triggered.connect(self.save) + self.actionLoad.triggered.connect(self.load) + # elements, lines and branch points self.elementDB={} + def save(self): + self.editor.saveLayout("Layouts/lattice.json") + + def load(self): + self.editor.loadLayout("Layouts/lattice.json") + + def saveTemplates(self): + template=self.editor.get_templates() + with (open('Settings/templates.json','w', encoding = "utf-8") as file): + json.dump(template, file, indent=2) + + def loadTemplates(self): + with (open('Settings/templates.json','r', encoding = "utf-8") as file): + template=json.load(file) + self.editor.set_templates(template) + + #editor = BeamlineEditorWidget( # on_element_added = fn(element_type: str, node_id: str), # on_elements_deleted = fn(node_ids: list[str]), diff --git a/ui/BeamlineGUI.py b/ui/BeamlineGUI.py index 5909c1c..fd2e5d2 100644 --- a/ui/BeamlineGUI.py +++ b/ui/BeamlineGUI.py @@ -26,14 +26,52 @@ class Ui_BeamlineGUI(object): self.menubar = QtWidgets.QMenuBar(BeamlineGUI) self.menubar.setGeometry(QtCore.QRect(0, 0, 1491, 22)) self.menubar.setObjectName("menubar") + self.menuFile = QtWidgets.QMenu(self.menubar) + self.menuFile.setObjectName("menuFile") BeamlineGUI.setMenuBar(self.menubar) self.statusbar = QtWidgets.QStatusBar(BeamlineGUI) self.statusbar.setObjectName("statusbar") BeamlineGUI.setStatusBar(self.statusbar) + self.actionNew_Layout = QtWidgets.QAction(BeamlineGUI) + self.actionNew_Layout.setObjectName("actionNew_Layout") + self.actionSave = QtWidgets.QAction(BeamlineGUI) + self.actionSave.setObjectName("actionSave") + self.actionSave_as = QtWidgets.QAction(BeamlineGUI) + self.actionSave_as.setObjectName("actionSave_as") + self.actionSave_Templates = QtWidgets.QAction(BeamlineGUI) + self.actionSave_Templates.setObjectName("actionSave_Templates") + self.actionLoad_Templates = QtWidgets.QAction(BeamlineGUI) + self.actionLoad_Templates.setObjectName("actionLoad_Templates") + self.actionLoad = QtWidgets.QAction(BeamlineGUI) + self.actionLoad.setObjectName("actionLoad") + self.actionQuit = QtWidgets.QAction(BeamlineGUI) + self.actionQuit.setObjectName("actionQuit") + self.menuFile.addAction(self.actionNew_Layout) + self.menuFile.addAction(self.actionLoad) + self.menuFile.addAction(self.actionSave) + self.menuFile.addAction(self.actionSave_as) + self.menuFile.addSeparator() + self.menuFile.addAction(self.actionLoad_Templates) + self.menuFile.addAction(self.actionSave_Templates) + self.menuFile.addSeparator() + self.menuFile.addAction(self.actionQuit) + self.menubar.addAction(self.menuFile.menuAction()) self.retranslateUi(BeamlineGUI) + self.actionQuit.triggered.connect(self.centralwidget.close) # type: ignore QtCore.QMetaObject.connectSlotsByName(BeamlineGUI) def retranslateUi(self, BeamlineGUI): _translate = QtCore.QCoreApplication.translate BeamlineGUI.setWindowTitle(_translate("BeamlineGUI", "MainWindow")) + self.menuFile.setTitle(_translate("BeamlineGUI", "File")) + self.actionNew_Layout.setText(_translate("BeamlineGUI", "New Layout")) + self.actionSave.setText(_translate("BeamlineGUI", "Save")) + self.actionSave.setShortcut(_translate("BeamlineGUI", "Ctrl+S")) + self.actionSave_as.setText(_translate("BeamlineGUI", "Save as..")) + self.actionSave_Templates.setText(_translate("BeamlineGUI", "Save Templates")) + self.actionLoad_Templates.setText(_translate("BeamlineGUI", "Load Templates")) + self.actionLoad.setText(_translate("BeamlineGUI", "Load")) + self.actionLoad.setShortcut(_translate("BeamlineGUI", "Ctrl+L")) + self.actionQuit.setText(_translate("BeamlineGUI", "Quit")) + self.actionQuit.setShortcut(_translate("BeamlineGUI", "Ctrl+Q")) diff --git a/ui/BeamlineGUI.ui b/ui/BeamlineGUI.ui index 918f4e8..1a62b3e 100644 --- a/ui/BeamlineGUI.ui +++ b/ui/BeamlineGUI.ui @@ -29,9 +29,85 @@ 22 + + + File + + + + + + + + + + + + + + + New Layout + + + + + Save + + + Ctrl+S + + + + + Save as.. + + + + + Save Templates + + + + + Load Templates + + + + + Load + + + Ctrl+L + + + + + Quit + + + Ctrl+Q + + - + + + actionQuit + triggered() + BeamlineGUI + close() + + + -1 + -1 + + + 745 + 320 + + + +