diff --git a/eco/bernina/beamline_interact.svg b/eco/bernina/beamline_interact.svg new file mode 100644 index 0000000..d31bd6e --- /dev/null +++ b/eco/bernina/beamline_interact.svg @@ -0,0 +1,10623 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + 142 + + + + 50 + + + + 46 + + + + 44 + + + + 39.3 + + + + 29.3 + + + + 9.3 + + + + 7.4 + + + + 4.8 + + + + + 2.60 + + + + + + + 3.35 + + + + + + 7.2 + + + + + -- + + + + + + + 1.52 + + + + + + 1.42 + + + + + 39.6 + + + + 9.8 + + + + 4.1 + + + + 50 + + + + 98 + + + + 89 + + + + 78 + + + + 83 + + + + + 3.725 + + + + + + + chi@180° + + + + + mu @ 0° + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + yhl / rxhl + xhl + eta @0° + phi @0° + zkap + ykap + xkap + rxkap + rzkap + delta@0° + gamma@0° + + + + + tdet + + + + mid-Ir + UV, vis. <25 fs + las.delay + tt_kb.delay + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Offset mirrorsoffset + + + + + + + + + + + + + + + + + + + + + + Timing diagnosticstt_opt + UV, vis., near IR + + Undulatorfel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + THz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Double crystal monochromatorsmono + Slitsslit_mono + + Profileprof_opt + + Focussing mirrorskb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Intensity/positionmon_dsd + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Robot detector armrobot + + + + + + + + + + + + + Profileprof_und + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + X-ray + + + Generalpurposestationgps. ... + + + + + + + + + + + + + + + X-raydiffractometerxrd. ... + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Profileprof_kb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Timing diagnosticstt_kb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Attenuatoratt_usd + + Attenuatoratt + + + + + Intensity/positionmon_mono + + Profileprof_mono + + Slitsslit_att + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Slitsslit_kb + + Slitsslit_cleanup + + + + + + + + + + Profileprof_dsd + + Intensity/positionmon_opt + + Intensity/positionmon_kb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Intensity/positionmon_und + Slitsslit_und + Slitsslit_switch + + + + + + + + + + + + + + + + + + + + + + + + + X-ray pulse pickerxp + + + + + + + + + + + + + + + + + + + + + + Attenuatoratt_fe + + + + + + + + + + + + + Profileprof_fe + + X-ray spectrometerxspect + + + + + + + + + + Profileprof_att + + + + + + + + + + + + + + + las.delay_eos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TOPAS twins + 100 fs + + + + + + + + + 35 fs + TOPAS He + TOPAS white + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Reference laserreflaser_beamline + Reference laserreflaser + Laser + + + + + + + + + + + + + diff --git a/eco/bernina/bernina.py b/eco/bernina/bernina.py index 960c796..383ff7e 100644 --- a/eco/bernina/bernina.py +++ b/eco/bernina/bernina.py @@ -45,6 +45,7 @@ namespace = Namespace( required_names_directory="/sf/bernina/code/gac-bernina/eco_cnf_bernina/required_bernina_names.json", ) namespace.alias_namespace.data = [] +namespace._show_svg = str(Path(__file__).parent / "beamline_interact.svg") # Adding stuff that might be relevant for stuff configured below (e.g. config) _config_bernina_dict = AdjustableFS( diff --git a/eco/elements/assembly.py b/eco/elements/assembly.py index 0d09c30..417fc2a 100644 --- a/eco/elements/assembly.py +++ b/eco/elements/assembly.py @@ -601,6 +601,28 @@ class Assembly: return make_assembly_tk_window(self) + def show(self, in_window=False, exclude_group_ids=None): + """Opens the interactive SVG viewer for `_show_svg`, if defined on this instance. + + Commands clicked in the SVG run against this assembly's own full + alias name as namespace prefix, e.g. clicking a device labelled + "slit_att" inside an assembly aliased "bernina.optics" runs + "bernina.optics.slit_att" in the IPython session. + """ + svg_path = getattr(self, "_show_svg", None) + if not svg_path: + print(f"No _show_svg defined for {self.alias.get_full_name()}.") + return + + from eco.utilities.svg_interactor import launch_svg_viewer + + launch_svg_viewer( + svg_path, + in_window=in_window, + namespace_prefix=self.alias.get_full_name(), + exclude_group_ids=exclude_group_ids, + ) + import epics.pv import time diff --git a/eco/microscopes/microscopes.py b/eco/microscopes/microscopes.py index f65dbdc..3390e06 100644 --- a/eco/microscopes/microscopes.py +++ b/eco/microscopes/microscopes.py @@ -144,7 +144,8 @@ class OptoSigmaZoom(Assembly): [self.zoom_raw], # lambda x: abs(round(x / 4260 * 100) - 100), # lambda x: round(abs(x - 100) / 100 * 4260), - lambda x: 4260 - (x * 5260 / 100), + lambda x: abs(round((x+1006) / 5260 * 100) - 100), + lambda x: round(abs(x - 100) / 100 * 5260 - 1006), name="zoom", is_setting=False, ) diff --git a/eco/utilities/svg_interactor.py b/eco/utilities/svg_interactor.py new file mode 100644 index 0000000..d067dc2 --- /dev/null +++ b/eco/utilities/svg_interactor.py @@ -0,0 +1,430 @@ +import os +import json +import threading +from dash import Dash, html, dcc +from dash.dependencies import Input, Output +from flask import Response +from IPython import get_ipython + +# Shared between the browser (Dash) and native-window (WebKitGTK) backends. +# Extracts the Python command attached to an SVG element, in priority order: +# 1. onclick="// eco: " - set via Inkscape's Object Properties +# (Ctrl+Shift+O) "Interactivity" section, "onclick" field. Deliberately +# wrapped as a JS comment rather than raw code: onclick is a real DOM +# event-handler attribute, so if this SVG is ever opened standalone or +# embedded on a real page, a genuine browser will try to execute its +# content as JavaScript on click. A JS line comment makes that a +# guaranteed no-op (verified empirically) instead of a broken/misleading +# attempt to run Python as JS. "eco" is this project's marker so it reads +# clearly in the raw XML and won't collide with unrelated onclick use. +# 2. child element - set via Inkscape's Object Properties (Ctrl+Shift+O) +# "Description" field. This is a CHILD ELEMENT, not a desc="..." attribute - +# so what you see in Inkscape's Properties dialog is exactly what runs. +# Deliberately NOT wiring up "Title" too: Title is commonly used as a +# plain accessible label, and treating it as code would make plain labels +# crash as syntax errors. +# 3. Beamline-diagram convention (only when NAMESPACE_PREFIX is set): a +# label whose first monospace-font tspan names a device, e.g. "Attenuator" +# / "att". Any trailing "@123°" reference-angle annotation on that line is +# stripped first. +# +# When NAMESPACE_PREFIX is set, it is prepended to whichever raw string was +# found above (from onclick, , OR the monospace convention) - e.g. +# prefix "bernina" + raw "slit_att" -> "bernina.slit_att", executed against +# that namespace object in the IPython session rather than as a standalone +# expression. With no prefix, onclick/ content is used verbatim (e.g. a +# full print(...) call). +_EXTRACT_COMMAND_JS = r""" +const ONCLICK_MARKER_RE = /^\s*\/\/\s*eco:\s*(.*)$/; + +function isInsideExcludedGroup(el) { + for (let node = el; node; node = node.parentNode) { + if (node.getAttribute && EXCLUDE_GROUP_IDS.includes(node.getAttribute("id"))) { + return true; + } + } + return false; +} + +function extractCommand(el) { + let raw = null; + + let onclickAttr = el.getAttribute("onclick"); + if (onclickAttr) { + let m = onclickAttr.match(ONCLICK_MARKER_RE); + if (m && m[1].trim()) { + raw = m[1].trim(); + } + } + if (!raw) { + let descEl = el.querySelector(":scope > desc"); + if (descEl && descEl.textContent.trim()) { + raw = descEl.textContent.trim(); + } + } + if (!raw && NAMESPACE_PREFIX) { + let textEl = el.closest("text"); + if (textEl && isInsideExcludedGroup(textEl)) { + textEl = null; + } + if (textEl) { + let tspans = textEl.querySelectorAll("tspan"); + for (let i = 0; i < tspans.length; i++) { + let fam = tspans[i].style.fontFamily || ""; + if (fam.indexOf("mono") !== -1) { + let cleaned = (tspans[i].textContent || "") + .replace(ANGLE_SUFFIX_RE, "").trim(); + if (cleaned) { + raw = cleaned; + } + break; + } + } + } + } + if (!raw) return null; + return NAMESPACE_PREFIX ? (NAMESPACE_PREFIX + "." + raw) : raw; +} +""" + + +def _build_extract_command_js(namespace_prefix, exclude_group_ids): + header = ( + "const NAMESPACE_PREFIX = " + json.dumps(namespace_prefix) + ";\n" + "const EXCLUDE_GROUP_IDS = " + json.dumps(list(exclude_group_ids or [])) + ";\n" + r"const ANGLE_SUFFIX_RE = /\s*@\s*[\d.]+\s*°?\s*$/;" + "\n" + ) + return header + _EXTRACT_COMMAND_JS + + +def _run_dash_server(svg_path, ip_instance, namespace_prefix=None, exclude_group_ids=None): + """Launches a local webserver hosting the interactive Inkscape SVG.""" + app = Dash(__name__) + + # 1. Read the SVG file content directly in Python + try: + with open(svg_path, "r", encoding="utf-8") as f: + svg_content = f.read() + except Exception as e: + print(f"Error reading SVG file: {e}") + return + + # Serve the SVG from a same-origin Flask route instead of a data: URL. + # data: URLs get an opaque origin, so the browser blocks the parent page + # from accessing the 's contentDocument (silently, no error) - + # the click listener below would never see the SVG's DOM at all. + @app.server.route("/svg-content") + def serve_svg(): + return Response(svg_content, mimetype="image/svg+xml") + + # Dash's html.Script component is mounted through React's client-side + # renderer, and browsers only auto-execute + + + """ + # Plain string.replace, not str.format/f-string: the template above already + # uses literal {%...%} placeholders that Dash itself substitutes, which + # would collide with Python's own {}-based formatting syntax. + app.index_string = app.index_string.replace( + "__EXTRACT_COMMAND_JS__", _build_extract_command_js(namespace_prefix, exclude_group_ids) + ) + + app.layout = html.Div([ + # 2. Changed from html.Embed to html.ObjectEl, served from a same-origin route + html.ObjectEl( + id="inkscape-svg-object", + data="/svg-content", + type="image/svg+xml", + style={"width": "100vw", "height": "95vh", "border": "none"} + ), + + # Standard input field used as a bridge channel between JS events and Python callbacks + dcc.Input(id="click-relay", type="text", style={"display": "none"}) + ], style={"margin": "0", "padding": "0", "backgroundColor": "#f0f0f0"}) + + @app.callback( + Output("click-relay", "style"), + Input("click-relay", "value"), + prevent_initial_call=True + ) + def handle_web_click(relay_data): + if not relay_data: + return {"display": "none"} + + try: + command_str, ctrl_pressed_str, _ = relay_data.split("|||") + ctrl_pressed = ctrl_pressed_str == "true" + + if ctrl_pressed: + print(f"\n[SVG Paste] -> {command_str}", end="", flush=True) + ip_instance.set_next_input(command_str, replace=False) + else: + print(f"\nExecuting from SVG: {command_str}") + ip_instance.run_cell(command_str) + except Exception as err: + print(f"\nError processing event layout channel: {err}") + + return {"display": "none"} + + # Run the server quietly on background loops + import logging + log = logging.getLogger('werkzeug') + log.setLevel(logging.ERROR) + app.run(port=8050, debug=False, use_reloader=False) + + +def _run_qt_window(svg_path, ip_instance, namespace_prefix=None, exclude_group_ids=None): + """Opens a native, resizable window rendering the SVG via Qt's QWebEngineView. + + QWebEngineView embeds Chromium (the same class of engine as + WebKitGTK/Safari) - it is NOT Qt's QtSvg module, which only implements a + static subset of SVG (no filters, very limited gradients) and was why an + earlier Qt-based attempt at this feature rendered complex diagrams + incorrectly. Since QWebEngineView paints SVG the same way a real browser + tab does, that failure mode doesn't apply here. + + Uses qtpy (same as eco.widgets.display_qt / eco.elements.assembly) + rather than importing PyQt5 directly: IPython enforces a single Qt + binding per session, and matplotlib's conda-forge package pulls in + PySide6 unconditionally, so a hardcoded PyQt5 import here would collide + with whatever binding IPython/matplotlib already loaded and raise + "Importing PyQt5 disabled by IPython, which has already imported an + Incompatible QT Binding". qtpy detects and reuses whichever binding is + already active instead. + + The SVG is embedded directly inline in the loaded HTML (as with the + former WebKitGTK backend), and JS-to-Python communication goes through a + QWebChannel bridge rather than WebKitGTK's script-message-handler API. + """ + import xml.etree.ElementTree as ET + + from qtpy.QtCore import Qt, QObject, QUrl, Slot + from qtpy.QtWidgets import QApplication + from qtpy.QtWebChannel import QWebChannel + from qtpy.QtWebEngineWidgets import QWebEngineView + + with open(svg_path, "r", encoding="utf-8") as f: + svg_content = f.read() + + # Peek at the SVG's own dimensions so the window opens at a sane, + # correctly-proportioned size. It stays freely resizable afterward - the + # inline 's own viewBox scaling (CSS below) keeps it from + # distorting, the same way it would in a real browser tab. + width, height = 800, 600 + try: + root = ET.fromstring(svg_content) + width = float(root.get("width", width)) + height = float(root.get("height", height)) + except Exception: + pass + + html_content = """ + + + + + + + + """ + svg_content + """ + + + + """ + html_content = html_content.replace( + "__EXTRACT_COMMAND_JS__", _build_extract_command_js(namespace_prefix, exclude_group_ids) + ) + + class Bridge(QObject): + @Slot(str, bool) + def onCommand(self, command, ctrl_pressed): + if ctrl_pressed: + print(f"\n[SVG Paste] -> {command}", end="", flush=True) + ip_instance.set_next_input(command, replace=False) + else: + print(f"\nExecuting from SVG: {command}") + ip_instance.run_cell(command) + + class SvgWindow(QWebEngineView): + # Mirrors the GTK backend's window.connect("destroy", Gtk.main_quit): + # without this, closing the window would leave app.exec_() blocking + # forever and the background thread never exits. + def closeEvent(self, event): + QApplication.instance().quit() + super().closeEvent(event) + + app = QApplication.instance() + if app is None: + # Must be set before the QApplication is constructed, not after. + QApplication.setAttribute(Qt.AA_ShareOpenGLContexts) + app = QApplication([]) + + bridge = Bridge() + channel = QWebChannel() + channel.registerObject("pybridge", bridge) + + view = SvgWindow() + view.page().setWebChannel(channel) + base_url = QUrl.fromLocalFile(os.path.dirname(os.path.abspath(svg_path)) + "/") + view.setHtml(html_content, base_url) + view.setWindowTitle("Interactive SVG Viewer") + view.resize(max(int(width), 200), max(int(height), 150)) + view.show() + + # .exec() (not the legacy .exec_() alias, which PyQt6 dropped) works + # across all bindings qtpy supports. + app.exec() + + +def launch_svg_viewer(svg_path, in_window=False, namespace_prefix=None, exclude_group_ids=None): + """Spawns an isolated background service for the SVG interface. + + By default this launches a browser-based Dash server. Pass + in_window=True to instead open a native, resizable window rendered via + Qt's QWebEngineView (requires qtpy plus a Qt binding with WebEngine + support - e.g. PyQt5+PyQtWebEngine or PySide6+qt6-webengine) - no browser tab + needed, but still real browser-engine (Chromium) rendering fidelity. + + Clickable commands can come from (checked in this order): an + onclick="// eco: " attribute (Inkscape's Object Properties > + Interactivity > onclick field - wrapped as a JS comment so a real + browser opening this SVG standalone treats it as a harmless no-op + instead of trying to run Python as JavaScript), a child element + (Object Properties > Description), or - only when namespace_prefix is + set - a label whose first monospace-font tspan names a device + (e.g. "Attenuator" / "att"). + + namespace_prefix changes how *any* matched command above is executed: + it's prepended as f"{namespace_prefix}.{raw command}" and run against + that namespace object in the IPython session, e.g. namespace_prefix= + "bernina" plus a raw value of "slit_att" (from onclick, , or the + monospace convention) runs "bernina.slit_att". With no namespace_prefix, + onclick/ content runs verbatim (e.g. a full print(...) call). + + exclude_group_ids opts specific subtrees out of the + monospace-tspan convention entirely (e.g. a zoomed-in inset whose axis + labels aren't meant to be individually clickable). + """ + ip = get_ipython() + if not ip: + print("Error: Must run inside an interactive IPython terminal context.") + return + + if in_window: + try: + from qtpy.QtWidgets import QApplication # noqa: F401 + from qtpy.QtWebEngineWidgets import QWebEngineView # noqa: F401 + except Exception as err: + print(f"Error: in_window=True requires qtpy plus a Qt binding with " + f"WebEngine support (conda-forge: qtpy, pyqt, pyqtwebengine - or " + f"qtpy, pyside6, qt6-webengine). Details: {err}") + return + threading.Thread( + target=_run_qt_window, + args=(svg_path, ip, namespace_prefix, exclude_group_ids), + daemon=True, + ).start() + print("Interactive SVG window launched in the background.") + return + + threading.Thread( + target=_run_dash_server, + args=(svg_path, ip, namespace_prefix, exclude_group_ids), + daemon=True, + ).start() + print("Interactive SVG application initialized successfully!") + print("--> Open your web browser and navigate to: http://127.0.0.1:8050") +