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
+
+
+
+
+ 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
+
+
+
+