diff --git a/eco/devices_general/cameras_swissfel.py b/eco/devices_general/cameras_swissfel.py index c24faa6..c8d1d94 100644 --- a/eco/devices_general/cameras_swissfel.py +++ b/eco/devices_general/cameras_swissfel.py @@ -305,23 +305,42 @@ class CamserverConfig(Assembly): def _spawn_separate_process_viewer( - pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None + pvname, + name=None, + cam_class=None, + pipeline_url=None, + rate_hz=10.0, + theme=None, + dockable=True, ): """Launch eco.widgets.camserver_stream_qt's own CLI as an independent - OS process and embed its window into a local wrapper via - eco.widgets.subprocess_embed -- the same spawn/WINID-handshake/embed - mechanism eco.widgets.camserver_panel_qt already uses per-viewer (see - that module and eco.widgets.subprocess_embed.spawn_and_embed), so the - resulting window can be docked into the shared EcoDesktopApp workbench - (see CameraBasler._default_dock_in) instead of popping untethered. + OS process. dockable=True (the default): embed its window into a local + wrapper via eco.widgets.subprocess_embed's WINID-handshake/embed + mechanism (the same one eco.widgets.camserver_panel_qt uses per-viewer, + see that module and eco.widgets.subprocess_embed.spawn_and_embed), so + the result can be docked into the shared EcoDesktopApp workbench (see + CameraBasler._default_dock_in) instead of popping untethered. - Why a separate process at all: Qt's event loop is only pumped between - IPython prompts (via the terminal's GUI-integration hook, same as - eco.utilities.strip_plot's own module docstring explains for exactly - this reason). A viewer built in-process therefore freezes -- both its - live image updates and the whole window's responsiveness -- for the - duration of any single blocking statement in this session (a - synchronous motor move, a long scan, ...). A separate-process viewer + dockable=False: skip that embedding entirely -- the subprocess's window + is a plain, independent, WM-managed top-level window (see + eco.widgets.subprocess_embed.DetachedProcessWindow), never dockable + (widget(dock_in=True) then silently no-ops, per + EcoDesktopApp._dock_widget_object), but immune to embedding's own + fragility: QWindow.fromWinId()/createWindowContainer()-based reparenting + is unreliable on some display setups -- confirmed live 2026-09-14 under + an SSH-X11-forwarded session ultimately backed by XWayland on the + client side (content drifting outside its container, not tracking + resizes, not receiving clicks) -- see the "Camera viewer XWayland + embedding" project memory. Prefer this whenever you don't need docking + and have seen that class of glitch. + + Why a separate process at all (either way): Qt's event loop is only + pumped between IPython prompts (via the terminal's GUI-integration + hook, same as eco.utilities.strip_plot's own module docstring explains + for exactly this reason). A viewer built in-process therefore freezes + -- both its live image updates and the whole window's responsiveness + -- for the duration of any single blocking statement in this session + (a synchronous motor move, a long scan, ...). A separate-process viewer has its own independent event loop untouched by that. `name`: the eco device's own alias name (e.g. "bernina.cam1", NOT the @@ -344,16 +363,18 @@ def _spawn_separate_process_viewer( use `separate_process=False` (in-process, `cam=self` -- e.g. camera.screenpanel_ana for intensity computation) instead. - Returns an eco.widgets.subprocess_embed.EmbeddedProcessWindow (the - .window/.stop() wrapper convention every eco Qt widget follows -- see - EcoDesktopApp._dock_widget_object) -- NOT a bare subprocess.Popen like - before, since it must be dockable via Assembly.widget()'s dock_in - machinery (see CameraBasler/CameraPCO._default_dock_in).""" + Returns an eco.widgets.subprocess_embed.EmbeddedProcessWindow or (when + dockable=False) .DetachedProcessWindow -- both follow the .window/ + .stop() wrapper convention every eco Qt widget follows (see + EcoDesktopApp._dock_widget_object), NOT a bare subprocess.Popen like + before this pair existed.""" import sys - from eco.widgets.subprocess_embed import EmbeddedProcessWindow + from eco.widgets.subprocess_embed import DetachedProcessWindow, EmbeddedProcessWindow - argv = [pvname, "--kind", "camera_pipeline", "--embed", "--rate", str(rate_hz)] + argv = [pvname, "--kind", "camera_pipeline", "--rate", str(rate_hz)] + if dockable: + argv += ["--embed"] if pipeline_url: argv += ["--pipeline-url", pipeline_url] if theme: @@ -364,7 +385,9 @@ def _spawn_separate_process_viewer( argv += ["--cam-class", cam_class] cmd = [sys.executable, "-m", "eco.widgets.camserver_stream_qt", *argv] - return EmbeddedProcessWindow(cmd, title=f"cam_server stream - {name or pvname}") + if dockable: + return EmbeddedProcessWindow(cmd, title=f"cam_server stream - {name or pvname}") + return DetachedProcessWindow(cmd) def get_camera_calibration(camera): @@ -867,35 +890,45 @@ class CameraBasler(Assembly): theme=None, auto_start=True, separate_process=True, + dockable=True, ): - """Open the live cam_server "screen panel" viewer for this camera's - own default processing pipeline -- the pipeline name is resolved - automatically from self.pvname (no need to know or guess a - pipeline/instance name), mirroring pshell's own screen panel (see - eco.widgets.camserver_stream_qt.resolve_camera_pipeline for the - exact "{camera}_sp" naming convention and auto-create-if-missing - behavior this reuses). The window's "Camera Settings" button opens - widget(normal=True) (the normal property-grid display) -- mirrors - eco.devices_general.cameras_ptz.AxisPTZ._widget_viewer(). + """Open the live cam_server viewer for this camera's own default + processing pipeline (resolved automatically from self.pvname). - separate_process=True (the default): run the viewer in its own OS - process instead of this one -- immune to this session blocking on - something (a synchronous motor move, a long scan, ...) -- pops as - its own standalone window by default, but can be docked into the - shared EcoDesktopApp workbench instead via widget(dock_in=True) - (see _default_dock_in/Assembly._maybe_dock), titled by this - camera's own eco name, and with its own independently-built camera - object (talking directly to EPICS/cam_server) behind its "Camera Settings" button - -- see _spawn_separate_process_viewer for the full trade-off and - the deferred-IPC TODO. Returns an - eco.widgets.subprocess_embed.EmbeddedProcessWindow rather than a - CamServerStreamQt instance in that case. + separate_process=True (the default): runs in its own OS process, so + it keeps updating/responding even while this session is blocked + (a motor move, a scan, ...). Costs ~10-15s to start (a fresh process + importing eco) and its "Camera Settings" button opens an + independent camera object talking directly to EPICS/cam_server -- + not this one, so e.g. camera.screenpanel_ana state set here isn't + reflected there. - separate_process=False: build the viewer in this process instead, - with a direct (not rebuilt) `cam=self` reference -- e.g. for - camera.screenpanel_ana intensity/analysis access tied to this - exact object, at the cost of the viewer freezing for the duration - of any blocking statement in this session.""" + dockable=True (the default): standalone window unless you pass + widget(dock_in=True) to dock it into the shared EcoDesktopApp + workbench -- but either way, the window is embedded into its + wrapper via X11 reparenting (QWindow.fromWinId + + createWindowContainer, see eco.widgets.subprocess_embed), which + is known to misbehave on some desktops/remote-display setups + (embedded content drifting outside its container, not tracking + resizes, or not receiving clicks -- confirmed under an + SSH-X11-forwarded session backed by XWayland). + + dockable=False: skips that embedding entirely -- a genuine, + independent, WM-managed top-level window that can never be + dockable (widget(dock_in=True) then silently no-ops), but immune + to the reparenting glitches above. Prefer this if you've hit that + class of display bug and don't need docking; separate_process= + False (below) is the other way around it, if you don't need a + separate process either. + + separate_process=False: builds the viewer in this process instead -- + starts instantly, no subprocess/X11-embedding involved so no risk + of the display glitches above, and `cam=self` is the exact same + object (screenpanel_ana etc. all tied to it). Trade-off: the viewer + freezes (no repaint, no clicks) for the duration of any blocking + statement in this session (a synchronous motor move, a long scan, + ...) -- prefer this whenever you're just looking at the image and + not about to run something long alongside it.""" alias = getattr(self, "alias", None) name = alias.get_full_name() if alias is not None else None @@ -907,6 +940,7 @@ class CameraBasler(Assembly): pipeline_url=pipeline_url, rate_hz=rate_hz, theme=theme, + dockable=dockable, ) from ..widgets.camserver_stream_qt import make_camserver_stream_qt @@ -1138,10 +1172,11 @@ class CameraPCO(Assembly): theme=None, auto_start=True, separate_process=True, + dockable=True, ): """Open the live cam_server "screen panel" viewer for this camera's own default processing pipeline -- see CameraBasler._widget_viewer(), - which this mirrors (separate_process included).""" + which this mirrors (separate_process/dockable included).""" alias = getattr(self, "alias", None) name = alias.get_full_name() if alias is not None else None @@ -1153,6 +1188,7 @@ class CameraPCO(Assembly): pipeline_url=pipeline_url, rate_hz=rate_hz, theme=theme, + dockable=dockable, ) from ..widgets.camserver_stream_qt import make_camserver_stream_qt diff --git a/eco/utilities/diffractometer_3d.html b/eco/utilities/diffractometer_3d.html new file mode 100644 index 0000000..168eef8 --- /dev/null +++ b/eco/utilities/diffractometer_3d.html @@ -0,0 +1,2109 @@ + + + + +Bernina mock diffractometer (You geometry) + + + + +
+
+
+

Mock diffractometer — simple mockup (v2)

+
+ Stripped back to basics: thin circles + rotation-direction arrows on a + bare kinematic chain, no mechanical styling yet — a starting + point to iterate on. Drag to orbit, scroll to zoom. Axes use the + SwissFEL/Bernina convention: X horizontal + transverse, Y vertical, + Z beam direction (right-handed). The + mechanism itself is computed directly on diffcalc's own axes (Y=beam, + Z=vertical there) — only these on-screen labels/arrows are + remapped. Detector order is currently gamma (outer) → delta + (inner) per direct feedback; note this doesn't exactly match + diffcalc's own composition order (delta-then-gamma) unless axis + assignments are also adjusted — flagged for follow-up once the + mockup itself is settled. +
+ +
+

Sample circles

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+

Detector circles

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+

Detector distance

+
+ +
+ + +
+
+
+ + +
+
Size is used for the detector cut of the Ewald sphere.
+
+ +
+ +
+ +
+

Display

+ + + + + + + + +
+ +
+ + +
+
+
+ Reciprocal lengths follow diffcalc's own 2π convention + (|k| = 2π/λ, |Q| = 2π/d, |a*| = 2π/a), so the tip of Q + sits on a node of the reciprocal cell. The slider sets how many cm of + the direct-space scene represent 1 Å−1 (5 cm by default). +
+
+
+
+
+ + + + + + + diff --git a/eco/utilities/recspace.py b/eco/utilities/recspace.py index 834d28b..fbe70e7 100644 --- a/eco/utilities/recspace.py +++ b/eco/utilities/recspace.py @@ -9,6 +9,11 @@ import pandas as pd import numpy as np from datetime import datetime import os +import json +import html as html_lib +import tempfile +import webbrowser +from pathlib import Path from PIL import Image from scipy.spatial.transform import Rotation @@ -27,6 +32,72 @@ from epics import PV logger = logging.getLogger(__name__) +# Vendored from swissfel/bernina-scripts apps/dev/diffractometer_3d.html: a +# self-contained Three.js reciprocal-space viewer that needs no server, only +# a JSON payload (angles, UB matrix, k-magnitude, surface normal) written +# into a hidden element it polls every 150ms. +_GEOM_WIDGET_HTML_PATH = Path(__file__).parent / "diffractometer_3d.html" + + +def _build_geom_widget_html(entries): + """Wrap the vendored 3D viewer with a sidebar that lets you click through + ``entries`` (each ``{"label": str, "payload": dict}``), writing the + clicked entry's payload into the "#diff3d-payload" element the viewer + polls.""" + scene_html = _GEOM_WIDGET_HTML_PATH.read_text() + srcdoc = html_lib.escape(scene_html) + entries_json = json.dumps(entries) + return f""" + + + +Reciprocal space geometry + + + +
+ +
+ +
+
+ + + + +""" + class Diffractometer_Dummy(Assembly): def __init__(self, *args, name=None, **kwargs): @@ -1024,6 +1095,73 @@ class DiffGeometryYou(Assembly): ) return solution + def _geom_entry(self, label, mu, delta, gamma, eta, chi, phi, energy=None, hkl=None): + """Build one clickable entry (angles + UB + surface normal, as the + vendored 3D viewer expects) for the given diffractometer position.""" + if energy is None: + energy = self.get_energy() + if hkl is None: + hkl = self.calc_hkl( + mu=mu, delta=delta, gamma=gamma, eta=eta, chi=chi, phi=phi, energy=energy + ) + if hkl is not None: + label = f"{label} ({hkl[0]:.3g}, {hkl[1]:.3g}, {hkl[2]:.3g})" + lam = self.en2lam(energy) + n_phi = np.asarray(self.ubcalc.n_phi, dtype=float).ravel() + norm = np.linalg.norm(n_phi) + return { + "label": label, + "payload": { + "angles": { + "mu": mu, + "eta": eta, + "chi": chi, + "phi": phi, + "delta": delta, + "gamma": gamma, + }, + "kMag": 2 * np.pi / lam, + "ub": np.asarray(self.ubcalc.UB, dtype=float).tolist(), + "nPhi": (n_phi / norm).tolist() if norm > 0 else [0.0, 0.0, 1.0], + }, + } + + def widget_geom(self): + """Open a browser tab visualizing this crystal's reciprocal-space + geometry: the current diffractometer position plus every stored + reference reflection, clickable in a sidebar. + + Reuses the 3D viewer from swissfel/bernina-scripts + apps/dev/diffractometer_3d.html unmodified - only the UB matrix, + current/stored angles and energy are fed in, so none of that + notebook's manual unit-cell/orientation input fields are needed here; + this crystal's own UB calculation already has them. + """ + self.to_diffcalc() + mu, delta, gamma, eta, chi, phi = self.get_diffractometer_angles() + entries = [self._geom_entry("current", mu, delta, gamma, eta, chi, phi)] + for i, refl in enumerate(self.reflections()): + mu, delta, gamma, eta, chi, phi = refl["position"] + tag = refl.get("tag") or f"#{i}" + entries.append( + self._geom_entry( + str(tag), + mu, + delta, + gamma, + eta, + chi, + phi, + energy=refl["energy"], + hkl=refl["hkl"], + ) + ) + path = Path(tempfile.gettempdir()) / f"eco_widget_geom_{self.name}.html" + path.write_text(_build_geom_widget_html(entries)) + webbrowser.open(f"file://{path}") + print(f"Opened reciprocal-space geometry view: {path}") + return path + def calc_angles_unique(self, h=None, k=None, l=None, energy=None): """calculate unique solution of diffractometer angles for a given h,k,l and energy in eV. If any of the h, k, l are not given, their current value is used instead. diff --git a/eco/widgets/subprocess_embed.py b/eco/widgets/subprocess_embed.py index 01aed32..c651188 100644 --- a/eco/widgets/subprocess_embed.py +++ b/eco/widgets/subprocess_embed.py @@ -178,6 +178,63 @@ def spawn_and_embed(target, cmd, env=None, timeout_s=EMBED_TIMEOUT_S, parent=Non return process +class DetachedProcessWindow: + """.window/.stop()-shaped wrapper (same convention as + EmbeddedProcessWindow) around a viewer subprocess that is deliberately + NOT embedded anywhere -- its window is a plain, independent, fully + WM-managed top-level window, exactly as if the CLI had been run by + hand. `.window` is always None: there is nothing local to reparent or + dock, which is the point. + + Exists to route around QWindow.fromWinId()/createWindowContainer()- + based embedding (see spawn_and_embed/EmbeddedProcessWindow) being + unreliable on some display setups -- XWayland in particular (a Wayland + compositor's X11-compat layer only partially emulates the + XReparentWindow-based trick that embedding depends on), confirmed live + 2026-09-14: embedded content drifting outside its container, not + tracking resizes, or not receiving clicks at all. A plain top-level + window never gets reparented in the first place, so it's immune to + that whole class of bug -- at the cost of never being dockable into an + EcoDesktopApp workbench (EcoDesktopApp._dock_widget_object checks for + a real `.window` QWidget and is a documented no-op otherwise, so + `widget(dock_in=True)` on one of these silently just leaves it + standalone rather than erroring).""" + + window = None + + def __init__(self, cmd, env=None): + # QProcess's signals (on_stderr below) are delivered through Qt's + # event loop like any other -- needs a QApplication to exist, same + # requirement as EmbeddedProcessWindow, even though this class + # never builds a QWidget of its own. + global _app_ref + if QtWidgets.QApplication.instance() is None: + _app_ref = QtWidgets.QApplication([]) + + self.process = QtCore.QProcess() + self.process.setProcessChannelMode(QtCore.QProcess.SeparateChannels) + + def on_stderr(): + # Same surfacing as spawn_and_embed's on_stderr -- a crash + # before/without ever showing anything would otherwise be a + # silent disappearance. + chunk = bytes(self.process.readAllStandardError()).decode(errors="ignore") + if chunk: + sys.stderr.write(f"[subprocess_embed] viewer stderr: {chunk}") + sys.stderr.flush() + + self.process.readyReadStandardError.connect(on_stderr) + self.process.setProcessEnvironment(env if env is not None else child_process_environment()) + self.process.start(cmd[0], cmd[1:]) + + def stop(self): + if self.process is not None and self.process.state() != QtCore.QProcess.NotRunning: + self.process.terminate() + if not self.process.waitForFinished(2000): + self.process.kill() + self.process.waitForFinished(2000) + + class EmbeddedProcessWindow: """.window/.stop()-shaped wrapper (eco's standard Qt-widget-wrapper convention -- DisplayQt/AxisPTZStreamQt/CamServerStreamQt/...) around diff --git a/tests/test_cameras_swissfel.py b/tests/test_cameras_swissfel.py index ff47e19..8344eb2 100644 --- a/tests/test_cameras_swissfel.py +++ b/tests/test_cameras_swissfel.py @@ -148,13 +148,17 @@ def test_camera_basler_separate_process_is_the_default(monkeypatch): def test_camera_basler_viewer_separate_process_spawns_subprocess_instead(monkeypatch): calls = {} - def fake_spawn(pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None): + def fake_spawn( + pvname, name=None, cam_class=None, pipeline_url=None, rate_hz=10.0, theme=None, + dockable=True, + ): calls["pvname"] = pvname calls["name"] = name calls["cam_class"] = cam_class calls["pipeline_url"] = pipeline_url calls["rate_hz"] = rate_hz calls["theme"] = theme + calls["dockable"] = dockable return "the subprocess handle" monkeypatch.setattr( @@ -176,10 +180,28 @@ def test_camera_basler_viewer_separate_process_spawns_subprocess_instead(monkeyp # and_cam_class below for the real-object case) assert calls == { "pvname": BASLER_PVNAME, "name": None, "cam_class": None, - "pipeline_url": None, "rate_hz": 25.0, "theme": "dark", + "pipeline_url": None, "rate_hz": 25.0, "theme": "dark", "dockable": True, } +def test_camera_basler_viewer_dockable_false_is_passed_through(monkeypatch): + """widget(dockable=False) must reach _spawn_separate_process_viewer so + it can skip the X11-embedding/dock machinery entirely (see + eco.widgets.subprocess_embed.DetachedProcessWindow) -- not silently get + dropped.""" + calls = {} + monkeypatch.setattr( + "eco.devices_general.cameras_swissfel._spawn_separate_process_viewer", + lambda pvname, **kwargs: calls.update(pvname=pvname, **kwargs) or "the subprocess handle", + ) + + fake_self = _FakeCameraSelf(BASLER_PVNAME) + result = CameraBasler._widget_viewer(fake_self, dockable=False) + + assert result == "the subprocess handle" + assert calls["dockable"] is False + + def test_camera_basler_viewer_separate_process_passes_name_and_cam_class(monkeypatch): calls = {} monkeypatch.setattr( @@ -263,6 +285,33 @@ def test_spawn_separate_process_viewer_builds_the_expected_command_line(monkeypa assert captured["title"] == "cam_server stream - bernina.cam1" +def test_spawn_separate_process_viewer_dockable_false_skips_embed_flag(monkeypatch): + """dockable=False must route through DetachedProcessWindow instead of + EmbeddedProcessWindow, and must NOT pass --embed -- the whole point is + the subprocess never becomes a reparenting target (see + eco.widgets.subprocess_embed.DetachedProcessWindow's docstring).""" + from eco.devices_general.cameras_swissfel import _spawn_separate_process_viewer + + captured = {} + + class _FakeDetachedProcessWindow: + def __init__(self, cmd): + captured["cmd"] = cmd + + def fail_if_embedded(*a, **k): + raise AssertionError("dockable=False must not build an EmbeddedProcessWindow") + + monkeypatch.setattr( + "eco.widgets.subprocess_embed.DetachedProcessWindow", _FakeDetachedProcessWindow + ) + monkeypatch.setattr("eco.widgets.subprocess_embed.EmbeddedProcessWindow", fail_if_embedded) + + result = _spawn_separate_process_viewer(BASLER_PVNAME, dockable=False) + + assert isinstance(result, _FakeDetachedProcessWindow) + assert "--embed" not in captured["cmd"] + + def test_spawn_separate_process_viewer_omits_eco_name_and_cam_class_when_not_given(monkeypatch): from eco.devices_general.cameras_swissfel import _spawn_separate_process_viewer