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 @@
+
+
+
+ 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
+
+
+
+
+
+
Reflections
+
+
+
+
+
+
+
+
+
+
+"""
+
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