From 864c803f86ddf1c16549c4f6d3edf30d76c74a1e Mon Sep 17 00:00:00 2001
From: David Hammer <dhammer@mailbox.org>
Date: Wed, 2 Mar 2022 18:32:56 +0100
Subject: [PATCH] Cleanup, only mdl geometries, update scenes

---
 setup.py                        |  11 +-
 src/calng/AgipdCorrection.py    |  27 +-
 src/calng/CalibrationManager.py |   2 +-
 src/calng/DsscCorrection.py     |  26 +-
 src/calng/JungfrauCorrection.py |  29 +-
 src/calng/__init__.py           |   0
 src/calng/base_correction.py    |   2 +-
 src/calng/base_geometry.py      | 640 +++++++++++++++-----------------
 src/calng/mdl_geometry_base.py  | 369 ------------------
 src/calng/scenes.py             | 213 ++++++++++-
 10 files changed, 512 insertions(+), 807 deletions(-)
 delete mode 100644 src/calng/__init__.py
 delete mode 100644 src/calng/mdl_geometry_base.py

diff --git a/setup.py b/setup.py
index c9555f58..51f02cb4 100644
--- a/setup.py
+++ b/setup.py
@@ -25,11 +25,8 @@ setup(name='calng',
       entry_points={
           'karabo.bound_device': [
               'AgipdCorrection = calng.AgipdCorrection:AgipdCorrection',
-              'ManualAgipdGeometry = calng.AgipdCorrection:ManualAgipdGeometry',
               'DsscCorrection = calng.DsscCorrection:DsscCorrection',
-              'ManualDsscGeometry = calng.DsscCorrection:ManualDsscGeometry',
               'JungfrauCorrection = calng.JungfrauCorrection:JungfrauCorrection',
-              'ManualJungfrauGeometry = calng.JungfrauCorrection:ManualJungfrauGeometry',
               'LpdCorrection = calng.LpdCorrection:LpdCorrection',
               'ShmemToZMQ = calng.ShmemToZMQ:ShmemToZMQ',
               'ShmemTrainMatcher = calng.ShmemTrainMatcher:ShmemTrainMatcher',
@@ -38,10 +35,10 @@ setup(name='calng',
 
           'karabo.middlelayer_device': [
               'CalibrationManager = calng.CalibrationManager:CalibrationManager',
-              'MdlAgipd1MGeometry = calng.mdl_geometry_base:MdlAgipd1MGeometry',
-              'MdlDssc1MGeometry = calng.mdl_geometry_base:MdlDssc1MGeometry',
-              'MdlLpd1MGeometry = calng.mdl_geometry_base:MdlLpd1MGeometry',
-              'MdlJungfrauGeometry = calng.mdl_geometry_base:MdlJungfrauGeometry',
+              'Agipd1MGeometry = calng.geometries.Agipd1MGeometry:Agipd1MGeometry',
+              'Dssc1MGeometry = calng.geometries:Dssc1MGeometry.Dssc1MGeometry',
+              'Lpd1MGeometry = calng.geometries:Lpd1MGeometry.Lpd1MGeometry',
+              'JungfrauGeometry = calng.geometries:JungfrauGeometry.JungfrauGeometry',
           ],
       },
       package_data={'': ['kernels/*']},
diff --git a/src/calng/AgipdCorrection.py b/src/calng/AgipdCorrection.py
index caa7812e..d86b953e 100644
--- a/src/calng/AgipdCorrection.py
+++ b/src/calng/AgipdCorrection.py
@@ -14,7 +14,7 @@ from karabo.bound import (
     VECTOR_STRING_ELEMENT,
 )
 
-from . import base_calcat, base_geometry, base_gpu, utils
+from . import base_calcat, base_gpu, utils
 from ._version import version as deviceVersion
 from .base_correction import BaseCorrection, add_correction_step_schema, preview_schema
 
@@ -36,6 +36,7 @@ class AgipdGainMode(enum.IntEnum):
     FIXED_MEDIUM_GAIN = 2
     FIXED_LOW_GAIN = 3
 
+
 class CorrectionFlags(enum.IntFlag):
     NONE = 0
     THRESHOLD = 1
@@ -803,27 +804,3 @@ class AgipdCorrection(BaseCorrection):
             self.kernel_runner.override_bad_pixel_flags_to_use(
                 self._override_bad_pixel_flags
             )
-
-
-@KARABO_CLASSINFO("ManualAgipdGeometry", deviceVersion)
-class ManualAgipdGeometry(base_geometry.ManualQuadrantsGeometryBase):
-    def __init__(self, *args, **kwargs):
-        import extra_geom
-        self.geometry_class = extra_geom.AGIPD_1MGeometry
-        super().__init__(*args, **kwargs)
-
-    @staticmethod
-    def expectedParameters(expected):
-        super(ManualAgipdGeometry, ManualAgipdGeometry).expectedParameters(expected)
-
-        expected.setDefaultValue("quadrantCorners.Q1.x", -525)
-        expected.setDefaultValue("quadrantCorners.Q1.y", 625)
-
-        expected.setDefaultValue("quadrantCorners.Q2.x", -550)
-        expected.setDefaultValue("quadrantCorners.Q2.y", -10)
-
-        expected.setDefaultValue("quadrantCorners.Q3.x", 520)
-        expected.setDefaultValue("quadrantCorners.Q3.y", -160)
-
-        expected.setDefaultValue("quadrantCorners.Q4.x", 542.5)
-        expected.setDefaultValue("quadrantCorners.Q4.y", 475)
diff --git a/src/calng/CalibrationManager.py b/src/calng/CalibrationManager.py
index cf46b4c5..a8367b12 100644
--- a/src/calng/CalibrationManager.py
+++ b/src/calng/CalibrationManager.py
@@ -262,7 +262,7 @@ class CalibrationManager(DeviceClientBase, Device):
         name = params.get('name', default='overview')
         if name == 'overview':
             # Assumes there are correction devices known to manager
-            scene_data = scenes.manager_device_overview_scene(
+            scene_data = scenes.manager_device_overview(
                 self.deviceId,
                 self.getDeviceSchema(),
                 self._correction_device_schema,
diff --git a/src/calng/DsscCorrection.py b/src/calng/DsscCorrection.py
index 7bf97300..81d38e16 100644
--- a/src/calng/DsscCorrection.py
+++ b/src/calng/DsscCorrection.py
@@ -9,7 +9,7 @@ from karabo.bound import (
     VECTOR_STRING_ELEMENT,
 )
 
-from . import base_calcat, base_geometry, base_gpu, utils
+from . import base_calcat, base_gpu, utils
 from ._version import version as deviceVersion
 from .base_correction import BaseCorrection, add_correction_step_schema
 
@@ -287,27 +287,3 @@ class DsscCorrection(BaseCorrection):
 
         self._update_correction_flags()
         self.log_status_info(f"Done loading {constant.name} to GPU")
-
-
-@KARABO_CLASSINFO("ManualDsscGeometry", deviceVersion)
-class ManualDsscGeometry(base_geometry.ManualQuadrantsGeometryBase):
-    def __init__(self, *args, **kwargs):
-        import extra_geom
-        self.geometry_class = extra_geom.DSSC_1MGeometry
-        super().__init__(*args, **kwargs)
-
-    @staticmethod
-    def expectedParameters(expected):
-        super(ManualDsscGeometry, ManualDsscGeometry).expectedParameters(expected)
-
-        expected.setDefaultValue("quadrantCorners.Q1.x", -130)
-        expected.setDefaultValue("quadrantCorners.Q1.y", 5)
-
-        expected.setDefaultValue("quadrantCorners.Q2.x", -130)
-        expected.setDefaultValue("quadrantCorners.Q2.y", -125)
-
-        expected.setDefaultValue("quadrantCorners.Q3.x", 5)
-        expected.setDefaultValue("quadrantCorners.Q3.y", -125)
-
-        expected.setDefaultValue("quadrantCorners.Q4.x", 5)
-        expected.setDefaultValue("quadrantCorners.Q4.y", 5)
diff --git a/src/calng/JungfrauCorrection.py b/src/calng/JungfrauCorrection.py
index 836da284..7be0e149 100644
--- a/src/calng/JungfrauCorrection.py
+++ b/src/calng/JungfrauCorrection.py
@@ -9,10 +9,9 @@ from karabo.bound import (
     OVERWRITE_ELEMENT,
     STRING_ELEMENT,
     VECTOR_STRING_ELEMENT,
-    Hash,
 )
 
-from . import base_calcat, base_geometry, base_gpu, utils
+from . import base_calcat, base_gpu, utils
 from ._version import version as deviceVersion
 from .base_correction import BaseCorrection, add_correction_step_schema, preview_schema
 
@@ -415,29 +414,3 @@ class JungfrauCorrection(BaseCorrection):
 
         self._update_correction_flags()
         self.log_status_info(f"Done loading {constant.name} to GPU")
-
-
-@KARABO_CLASSINFO("ManualJungfrauGeometry", deviceVersion)
-class ManualJungfrauGeometry(base_geometry.ManualModulesGeometryBase):
-    def __init__(self, *args, **kwargs):
-        import extra_geom
-        self.geometry_class = extra_geom.JUNGFRAUGeometry
-        super().__init__(*args, **kwargs)
-
-    @staticmethod
-    def expectedParameters(expected):
-        # TODO: come up with some sweet defaults (this is two modules from docs 4M)
-        (
-            OVERWRITE_ELEMENT(expected)
-            .key("modules")
-            .setNewDefaultValue(
-                [
-                    Hash(
-                        "posX", 95, "posY", 564, "orientationX", -1, "orientationY", -1
-                    ),
-                    Hash(
-                        "posX", 95, "posY", 17, "orientationX", -1, "orientationY", -1
-                    ),
-                ]
-            )
-        )
diff --git a/src/calng/__init__.py b/src/calng/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/calng/base_correction.py b/src/calng/base_correction.py
index 02cf87f2..12a10b24 100644
--- a/src/calng/base_correction.py
+++ b/src/calng/base_correction.py
@@ -760,7 +760,7 @@ class BaseCorrection(PythonDevice):
         payload["name"] = name
         payload["success"] = True
         if name == "overview":
-            payload["data"] = scenes.correction_device_overview_scene(
+            payload["data"] = scenes.correction_device_overview(
                 device_id=self.getInstanceId(),
                 schema=self.getFullSchema(),
             )
diff --git a/src/calng/base_geometry.py b/src/calng/base_geometry.py
index 1e5f12d2..6ae36b79 100644
--- a/src/calng/base_geometry.py
+++ b/src/calng/base_geometry.py
@@ -1,252 +1,200 @@
 import contextlib
+import logging
 import pickle
 
 import matplotlib.pyplot as plt
 import numpy as np
-from karabo.bound import (
-    BOOL_ELEMENT,
-    DOUBLE_ELEMENT,
-    IMAGEDATA_ELEMENT,
-    INT32_ELEMENT,
-    KARABO_CLASSINFO,
-    NODE_ELEMENT,
-    OUTPUT_CHANNEL,
-    OVERWRITE_ELEMENT,
-    SLOT_ELEMENT,
-    STRING_ELEMENT,
-    TABLE_ELEMENT,
-    VECTOR_CHAR_ELEMENT,
-    VECTOR_STRING_ELEMENT,
-    Encoding,
+from karabo.middlelayer import (
+    AccessLevel,
+    AccessMode,
+    Assignment,
+    Bool,
+    Configurable,
+    DaqPolicy,
+    Device,
+    Double,
+    EncodingType,
     Hash,
+    Image,
     ImageData,
-    PythonDevice,
-    Schema,
+    Int32,
+    Node,
+    OutputChannel,
+    Slot,
     State,
+    String,
+    Unit,
+    VectorChar,
+    VectorString,
+    slot,
 )
-from karabo.common.api import KARABO_SCHEMA_DISPLAY_TYPE_SCENES as DT_SCENES
 from matplotlib.backends.backend_agg import FigureCanvasAgg
 
-from . import scenes
 from ._version import version as deviceVersion
-
-geometry_schema = Schema()
-(
-    VECTOR_CHAR_ELEMENT(geometry_schema)
-    .key("pickledGeometry")
-    .displayedName("Pickled geometry")
-    .assignmentOptional()
-    .defaultValue([])
-    .commit()
-)
-
-preview_schema = Schema()
-(IMAGEDATA_ELEMENT(preview_schema).key("overview").commit())
-
-ModuleColumn = Schema()
-(
-    DOUBLE_ELEMENT(ModuleColumn)
-    .key("posX")
-    .assignmentOptional()
-    .defaultValue(95)
-    .reconfigurable()
-    .commit(),
-
-    DOUBLE_ELEMENT(ModuleColumn)
-    .key("posY")
-    .assignmentOptional()
-    .defaultValue(564)
-    .reconfigurable()
-    .commit(),
-
-    INT32_ELEMENT(ModuleColumn)
-    .key("orientationX")
-    .assignmentOptional()
-    .defaultValue(-1)
-    .reconfigurable()
-    .commit(),
-
-    INT32_ELEMENT(ModuleColumn)
-    .key("orientationY")
-    .assignmentOptional()
-    .defaultValue(-1)
-    .reconfigurable()
-    .commit(),
-)
+from . import scenes
 
 
-@KARABO_CLASSINFO("ManualGeometryBase", deviceVersion)
-class ManualGeometryBase(PythonDevice):
-    geometry_class = None  # concrete device subclass must set this
+class GeometrySchema(Configurable):
+    pickledGeometry = VectorChar(
+        displayedName="Pickled geometry",
+        assignment=Assignment.OPTIONAL,
+        defaultValue=[],
+    )
 
-    @staticmethod
-    def expectedParameters(expected):
-        # Karabo things
-        (
-            OVERWRITE_ELEMENT(expected)
-            .key("state")
-            .setNewDefaultValue(State.INIT)
-            .commit(),
 
-            OVERWRITE_ELEMENT(expected)
-            .key("doNotCompressEvents")
-            .setNewDefaultValue(True)
-            .commit(),
+def makeXYCoordinateNode(
+    default_x, default_y, x_args=None, y_args=None, node_args=None
+):
+    class XYCoordinate(Configurable):
+        x = Double(
+            defaultValue=default_x,
+            accessMode=AccessMode.RECONFIGURABLE,
+            assignment=Assignment.OPTIONAL,
+            **({} if x_args is None else x_args),
         )
-
-        # "mandatory" for geometry serving device
-        (
-            OUTPUT_CHANNEL(expected)
-            .key("geometryOutput")
-            .dataSchema(geometry_schema)
-            .commit(),
-
-            SLOT_ELEMENT(expected)
-            .key("sendGeometry")
-            .displayedName("Send geometry")
-            .description(
-                "Send current geometry on output channel. This output channel is "
-                "typically used by assembler devices waiting for new geometries. "
-                "Updating the geometry will usually automatically cause update, but "
-                "hit this slot in case geometry is missing somewhere."
-            )
-            .allowedStates(State.ON)
-            .commit(),
-
-            SLOT_ELEMENT(expected)
-            .key("updatePreview")
-            .displayedName("Update preview")
-            .allowedStates(State.ON)
-            .commit(),
-
-            OUTPUT_CHANNEL(expected)
-            .key("previewOutput")
-            .dataSchema(preview_schema)
-            .commit(),
-
-            IMAGEDATA_ELEMENT(expected).key("layoutPreview").commit(),
+        y = Double(
+            defaultValue=default_y,
+            accessMode=AccessMode.RECONFIGURABLE,
+            assignment=Assignment.OPTIONAL,
+            **({} if y_args is None else y_args),
         )
 
-        # options to load from file
-        # future plan: pick option from manual / file / encoder or other devices
-        (
-            NODE_ELEMENT(expected)
-            .key("geometryFile")
-            .displayedName("Geometry file")
-            .description("Allows loading geometry from CrystFEL geometry file")
-            .commit(),
-
-            STRING_ELEMENT(expected)
-            .key("geometryFile.filePath")
-            .displayedName("File path")
-            .description(
-                "Full path (including filename and suffix) to the desired geometry "
-                "file. Keep in mind that the default directory is $KARABO/var/data on "
-                "device server node, so it's probably wise to give absolute path."
-            )
-            .assignmentOptional()
-            .defaultValue("")
-            .reconfigurable()
-            .commit(),
-
-            SLOT_ELEMENT(expected)
-            .key("geometryFile.load")
-            .displayedName("Load file")
-            .description(
-                "Trigger loading from file. Automatically called after starting device "
-                "if geometryFile.filePath is set. Should be manually called after "
-                "changing file path. In case loading the file fails, manual settings "
-                "will be used."
-            )
-            .allowedStates(State.ON)
-            .commit(),
-
-            BOOL_ELEMENT(expected)
-            .key("geometryFile.updateManualOnLoad")
-            .displayedName("Update manual settings")
-            .description(
-                "If this flag is on, the manual settings on this device will be "
-                "updated according to the loaded geometry file. This is useful when "
-                "you want to load a file and then tweak the geometry a bit."
-            )
-            .assignmentOptional()
-            .defaultValue(True)
-            .commit(),
+    return Node(XYCoordinate, **({} if node_args is None else node_args))
+
+
+def makeXYOffsetNode():
+    return makeXYCoordinateNode(
+        0,
+        0,
+        x_args={"unitSymbol": Unit.METER},
+        y_args={"unitSymbol": Unit.METER},
+        node_args={
+            "displayedName": "Offset",
+            "description": "See EXtra-geom documentation for details. This offset is "
+            "applied to entire detector after initial geometry is created from manual "
+            "parameters. Example: To move entire geometry up by 2 mm relative to beam, "
+            "set offset.y to 2e-3.",
+        },
+    )
+
+
+class GeometryFileNode(Configurable):
+    filePath = String(
+        defaultValue="",
+        displayedName="File path",
+        description="Full path (including filename and suffix) to the desired geometry "
+        "file. Keep in mind that the default directory is $KARABO/var/data on device "
+        "server node, so it's probably wise to give absolute path.",
+        assignment=Assignment.OPTIONAL,
+        accessMode=AccessMode.RECONFIGURABLE,
+    )
+    offset = makeXYOffsetNode()
+    updateManualOnLoad = Bool(
+        defaultValue=True,
+        displayedName="Update manual settings",
+        description="If this flag is on, the manual settings on this device will be "
+        "updated according to the loaded geometry file. This is useful when you want "
+        "to load a file and then tweak the geometry a bit. This will zero current "
+        "offset.",
+        assignment=Assignment.OPTIONAL,
+        accessMode=AccessMode.RECONFIGURABLE,
+    )
+
+
+class ManualGeometryBase(Device):
+    __version__ = deviceVersion
+    geometry_class = None  # subclass must set
+    # subclass must add slot setManual
+
+    availableScenes = VectorString(
+        displayedName="Available scenes",
+        displayType="Scenes",
+        requiredAccessLevel=AccessLevel.OBSERVER,
+        accessMode=AccessMode.READONLY,
+        defaultValue=[
+            "overview",
+        ],
+        daqPolicy=DaqPolicy.OMIT,
+    )
+
+    geometryPreview = Image(
+        ImageData(np.empty(0, dtype=np.uint32)),
+        displayedName="Geometry preview",
+        encoding=EncodingType.RGBA,
+    )
+
+    geometryOutput = OutputChannel(GeometrySchema)
+
+    geometryFile = Node(
+        GeometryFileNode,
+        displayedName="Geometry file",
+        description="Allows loading geometry from CrystFEL geometry file",
+    )
+
+    @Slot(
+        displayedName="Send geometry",
+        description="Send current geometry on output channel. This output channel is "
+        "typically used by assembler devices waiting for new geometries. Updating the "
+        "geometry will usually automatically cause update, but hit this slot in case "
+        "geometry is missing somewhere.",
+        allowedStates=[State.ACTIVE],
+    )
+    async def sendGeometry(self):
+        self.geometryOutput.schema.pickledGeometry = self.pickled_geometry
+        await self.geometryOutput.writeData()
+
+    @Slot(
+        displayedName="Update preview",
+        allowedStates=[State.ACTIVE],
+    )
+    async def updatePreview(self):
+        axis = self.geometry.inspect()
+        axis.figure.tight_layout(pad=0)
+        axis.figure.set_facecolor("none")
+        # axis.figure.set_size_inches(6, 6)
+        # axis.figure.set_dpi(300)
+        canvas = FigureCanvasAgg(axis.figure)
+        canvas.draw()
+        image_buffer = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(
+            canvas.get_width_height()[::-1] + (4,)
         )
-
-        # scenes are fun
-        (
-            VECTOR_STRING_ELEMENT(expected)
-            .key("availableScenes")
-            .setSpecialDisplayType(DT_SCENES)
-            .readOnly()
-            .initialValue(["overview"])
-            .commit(),
+        self.geometryPreview = ImageData(
+            image_buffer, encoding=EncodingType.RGBA, bitsPerPixel=3 * 8
         )
+        self._set_status("Preview updated")
 
-    def __init__(self, config):
-        super().__init__(config)
-
-        # slots go in __init__
-        self.KARABO_SLOT(self.sendGeometry)
-        self.KARABO_SLOT(self.updatePreview)
-        self.KARABO_SLOT(self.requestScene)
-        self.KARABO_SLOT(self.load_geometry_from_file, slotName="geometryFile_load")
-
-        plt.switch_backend("agg")  # plotting backend which works for preview hack
-
-        # these will be set by load_geometry_from_file or get_manual_geometry
-        self.geometry = None
-        self.pickled_geometry = None
-        self.registerInitialFunction(self._initialization)
-
-    def _initialization(self):
-        if self.get("geometryFile.filePath"):
-            self.log_status_info("geometryFile.filePath set, will try to load geometry")
-            self.load_geometry_from_file()
-        if self.geometry is None:
-            # no file path or loading failed
-            self.get_manual_geometry()
-
-        self.updateState(State.ON)
-
-    def _set_geometry(self, geometry, update_preview=True, send=True):
-        self.geometry = geometry
-        self.pickled_geometry = pickle.dumps(self.geometry)
-        if update_preview:
-            self.updatePreview()
-        if send:
-            self.sendGeometry()
-
-    def get_manual_geometry(self):
-        # subclass must implement this
-        raise NotImplementedError()
-
-    def _update_manual_from_current(self):
-        # subclass should implement this
-        # TODO: figure out for JUNGFRAUGeometry
-        raise NotImplementedError()
-
-    def load_geometry_from_file(self):
+    @Slot(
+        displayedName="Load from file",
+        allowedStates=[State.ACTIVE],
+    )
+    async def loadFromFile(self):
         with self.push_state(State.CHANGING):
             geometry = None
 
-            file_path = self.get("geometryFile.filePath")
-            self.log_status_info(f"Loading geometry from {file_path}...")
+            file_path = self.geometryFile.filePath.value
+            self._set_status(f"Loading geometry from {file_path}...")
 
             try:
                 geometry = self.geometry_class.from_crystfel_geom(file_path)
             except FileNotFoundError:
-                self.log_status_warn("Geometry file not found")
+                self._set_status("Geometry file not found", level=logging.WARN)
             except RuntimeError as e:
-                self.log_status_warn(f"Failed to load geometry file: {e}")
+                self._set_status(
+                    f"Failed to load geometry file: {e}", level=logging.WARN
+                )
             except Exception as e:
-                self.log_status_warn(f"Misc. exception when loading geometry file: {e}")
+                self._set_status(
+                    f"Misc. exception when loading geometry file: {e}",
+                    level=logging.WARN,
+                )
             else:
-                self._set_geometry(geometry)
-                self.log_status_info("Successfully loaded geometry from file")
-                if self.get("geometryFile.updateManualOnLoad"):
-                    self.log_status_info(
+                geometry = geometry.offset(
+                    (self.geometryFile.offset.x, self.geometryFile.offset.y)
+                )
+                await self._set_geometry(geometry)
+                self._set_status("Successfully loaded geometry from file")
+                if self.geometryFile.updateManualOnLoad.value:
+                    self._set_status(
                         "Updating manual settings on device to reflect loaded geometry"
                     )
                     self._update_manual_from_current()
@@ -254,160 +202,154 @@ class ManualGeometryBase(PythonDevice):
 
         return False
 
-    def requestScene(self, params):
-        payload = Hash()
-        scene_name = params.get("name", default="")
-        payload["name"] = scene_name
-        payload["success"] = True
-        if scene_name == "overview":
-            payload["data"] = scenes.manual_geometry_overview(
-                device_id=self.getInstanceId()
-            )
-        else:
-            payload["success"] = False
-        response = Hash()
-        response["type"] = "deviceScene"
-        response["origin"] = self.getInstanceId()
-        response["payload"] = payload
-        self.reply(response)
-
-    def sendGeometry(self):
-        self.writeChannel(
-            "geometryOutput", Hash("pickledGeometry", self.pickled_geometry)
-        )
-
-    def updatePreview(self):
-        axis = self.geometry.inspect()
-        axis.figure.tight_layout(pad=0)
-        axis.figure.set_facecolor("none")
-        # axis.figure.set_size_inches(6, 6)
-        # axis.figure.set_dpi(300)
-        canvas = FigureCanvasAgg(axis.figure)
-        canvas.draw()
-        image_buffer = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(
-            canvas.get_width_height()[::-1] + (4,)
-        )
-        self.set(
-            "layoutPreview",
-            ImageData(image_buffer, encoding=Encoding.RGBA, bitsPerPixel=3 * 8),
-        )
-        self.log_status_info("Preview updated")
-
-    def preReconfigure(self, config):
-        self._prereconfigure_update_hash = config
+    def _update_manual_from_current(self):
+        # subclass should implement (neat when loading from CrystFEL geom)
+        raise NotImplementedError()
 
-    def postReconfigure(self):
-        del self._prereconfigure_update_hash
+    async def _set_geometry(self, geometry, update_preview=True, send=True):
+        self.geometry = geometry
+        self.pickled_geometry = pickle.dumps(self.geometry)
+        if update_preview:
+            await self.updatePreview()
+        if send:
+            await self.sendGeometry()
 
-    def log_status_info(self, msg):
-        self.log.INFO(msg)
-        self.set("status", msg)
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        plt.switch_backend("agg")  # plotting backend which works for preview hack
 
-    def log_status_warn(self, msg):
-        self.log.WARN(msg)
-        self.set("status", msg)
+    async def onInitialization(self):
+        self.state = State.INIT
+        # TODO: try to load file if set
+        await self.setManual()
+        self.state = State.ACTIVE
 
     @contextlib.contextmanager
     def push_state(self, state):
-        previous_state = self.get("state")
-        self.updateState(state)
+        previous_state = self.state
+        self.state = state
         try:
             yield
         finally:
-            self.updateState(previous_state)
+            self.state = previous_state
+
+    def _set_status(self, text, level=logging.INFO):
+        """Add and log a status message.
+
+        Suppresses throttling from the gui server.
+        """
+
+        self.status = text
+        self.logger.log(level, text)
+
+
+def makeQuadrantCornersNode(default_values):
+    assert len(default_values) == 4
+    assert all(len(x) == 2 for x in default_values)
+
+    class QuadrantCornersNode(Configurable):
+        Q1 = makeXYCoordinateNode(*default_values[0])
+        Q2 = makeXYCoordinateNode(*default_values[1])
+        Q3 = makeXYCoordinateNode(*default_values[2])
+        Q4 = makeXYCoordinateNode(*default_values[3])
+        offset = makeXYOffsetNode()
+
+    return Node(QuadrantCornersNode)
 
 
-@KARABO_CLASSINFO("ManualQuadrantsGeometryBase", deviceVersion)
 class ManualQuadrantsGeometryBase(ManualGeometryBase):
-    @staticmethod
-    def expectedParameters(expected):
-        # note: subclasses should set better defaults
-        (NODE_ELEMENT(expected).key("quadrantCorners").commit(),)
-        for q in range(1, 5):
-            (
-                NODE_ELEMENT(expected).key(f"quadrantCorners.Q{q}").commit(),
-                DOUBLE_ELEMENT(expected)
-                .key(f"quadrantCorners.Q{q}.x")
-                .assignmentOptional()
-                .defaultValue(0)
-                .reconfigurable()
-                .commit(),
-
-                DOUBLE_ELEMENT(expected)
-                .key(f"quadrantCorners.Q{q}.y")
-                .assignmentOptional()
-                .defaultValue(0)
-                .reconfigurable()
-                .commit(),
+    quadrantCorners = None  # subclass must define (with nice defaults)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._quadrant_corners = [
+            self.quadrantCorners.Q1,
+            self.quadrantCorners.Q2,
+            self.quadrantCorners.Q3,
+            self.quadrantCorners.Q4,
+        ]
+
+    @slot
+    def requestScene(self, params):
+        name = params.get("name", default="overview")
+        if name == "overview":
+            # Assumes there are correction devices known to manager
+            scene_data = scenes.quadrant_geometry_overview(
+                self.deviceId,
             )
+            payload = Hash("success", True, "name", name, "data", scene_data)
 
-    def postReconfigure(self):
-        if any(
-            path.startswith("quadrantCorners")
-            for path in self._prereconfigure_update_hash.getPaths()
-        ):
-            self.get_manual_geometry()
+        return Hash("type", "deviceScene", "origin", self.deviceId, "payload", payload)
 
-        super().postReconfigure()
-
-    def get_manual_geometry(self):
-        self.log_status_info("Updating geometry from manual configuration")
+    @Slot(
+        displayedName="Set from device",
+        allowedStates=[State.ACTIVE],
+    )
+    async def setManual(self):
+        self._set_status("Updating geometry from manual configuration")
         with self.push_state(State.CHANGING):
-            self.quadrant_corners = tuple(
+            geometry = self.geometry_class.from_quad_positions(
+                [(Q.x.value, Q.y.value) for Q in self._quadrant_corners]
+            ).offset(
                 (
-                    self.get(f"quadrantCorners.Q{q}.x"),
-                    self.get(f"quadrantCorners.Q{q}.y"),
+                    self.quadrantCorners.offset.x.value,
+                    self.quadrantCorners.offset.y.value,
                 )
-                for q in range(1, 5)
             )
-            geometry = self.geometry_class.from_quad_positions(self.quadrant_corners)
-            self._set_geometry(geometry)
+            await self._set_geometry(geometry)
 
     def _update_manual_from_current(self):
-        update = Hash()
-        for (x, y), quadrant in zip(self.geometry.quad_positions(), range(1, 5)):
-            update.set(f"quadrantCorners.Q{quadrant}.x", x)
-            update.set(f"quadrantCorners.Q{quadrant}.y", y)
-        self.set(update)
-
-
-@KARABO_CLASSINFO("ManualModulesGeometryBase", deviceVersion)
-class ManualModulesGeometryBase(ManualGeometryBase):
-    @staticmethod
-    def expectedParameters(expected):
-        (
-            TABLE_ELEMENT(expected)
-            .key("modules")
-            .setColumns(ModuleColumn)
-            .assignmentOptional()
-            .defaultValue([])
-            .reconfigurable()
-            .commit(),
-
-            OVERWRITE_ELEMENT(expected)
-            .key("geometryFile.updateManualOnLoad")
-            .setNewDefaultValue(False)
-            .commit(),
-        )
-
-    def postReconfigure(self):
-        if self._prereconfigure_update_hash.has("modules"):
-            self.get_manual_geometry()
+        # TODO: consider what to do about offset
+        for corner, (x, y) in zip(
+            self._quadrant_corners, self.geometry.quad_positions()
+        ):
+            corner.x = x
+            corner.y = y
+        self.quadrantCorners.offset.x = 0
+        self.quadrantCorners.offset.y = 0
+
+
+class ModuleListItem(Configurable):
+    posX = Double(
+        assignment=Assignment.OPTIONAL,
+        defaultValue=0,
+    )
+    posY = Double(
+        assignment=Assignment.OPTIONAL,
+        defaultValue=0,
+    )
+    orientX = Int32(assignment=Assignment.OPTIONAL, defaultValue=1)
+    orientY = Int32(assignment=Assignment.OPTIONAL, defaultValue=1)
+
+
+class ManualModuleListGeometryBase(ManualGeometryBase):
+    moduleList = None  # subclass must define (with nice defaults)
+    offset = makeXYOffsetNode()
+
+    @slot
+    def requestScene(self, params):
+        name = params.get("name", default="overview")
+        if name == "overview":
+            # Assumes there are correction devices known to manager
+            scene_data = scenes.modules_geometry_overview(
+                self.deviceId,
+            )
+            payload = Hash("success", True, "name", name, "data", scene_data)
 
-        super().postReconfigure()
+        return Hash("type", "deviceScene", "origin", self.deviceId, "payload", payload)
 
-    def get_manual_geometry(self):
-        self.log_status_info("Updating geometry from manual configuration")
+    @Slot(
+        displayedName="Set from device",
+        allowedStates=[State.ACTIVE],
+    )
+    async def setManual(self):
+        self._set_status("Updating geometry from manual configuration")
         with self.push_state(State.CHANGING):
-            modules = self.get("modules")
-            module_pos = [
-                (module.get("posX"), module.get("posY")) for module in modules
-            ]
-            orientations = [
-                (module.get("orientationX"), module.get("orientationY"))
-                for module in modules
-            ]
             geometry = self.geometry_class.from_module_positions(
-                module_pos, orientations=orientations
-            )
-            self._set_geometry(geometry)
+                [(x, y) for (x, y, _, _) in self.moduleList.value],
+                [
+                    (orient_x, orient_y)
+                    for (_, _, orient_x, orient_y) in self.moduleList.value
+                ],
+            ).offset((self.offset.x.value, self.offset.y.value))
+            await self._set_geometry(geometry)
diff --git a/src/calng/mdl_geometry_base.py b/src/calng/mdl_geometry_base.py
deleted file mode 100644
index 4b4dad87..00000000
--- a/src/calng/mdl_geometry_base.py
+++ /dev/null
@@ -1,369 +0,0 @@
-import contextlib
-import logging
-import pickle
-
-import extra_geom
-from karabo.middlelayer import (
-    AccessMode,
-    Assignment,
-    Bool,
-    Configurable,
-    Device,
-    Double,
-    EncodingType,
-    Hash,
-    Image,
-    ImageData,
-    Int32,
-    Node,
-    OutputChannel,
-    Slot,
-    State,
-    String,
-    Unit,
-    VectorChar,
-    VectorHash,
-)
-import matplotlib.pyplot as plt
-from matplotlib.backends.backend_agg import FigureCanvasAgg
-import numpy as np
-
-from ._version import version as deviceVersion
-
-
-class GeometrySchema(Configurable):
-    pickledGeometry = VectorChar(
-        displayedName="Pickled geometry",
-        assignment=Assignment.OPTIONAL,
-        defaultValue=[],
-    )
-
-
-class GeometryFileNode(Configurable):
-    filePath = String(
-        defaultValue="",
-        displayedName="File path",
-        description="Full path (including filename and suffix) to the desired geometry "
-        "file. Keep in mind that the default directory is $KARABO/var/data on device "
-        "server node, so it's probably wise to give absolute path.",
-        assignment=Assignment.OPTIONAL,
-        accessMode=AccessMode.RECONFIGURABLE,
-    )
-    updateManualOnLoad = Bool(
-        defaultValue=True,
-        displayedName="Update manual settings",
-        description="If this flag is on, the manual settings on this device will be "
-        "updated according to the loaded geometry file. This is useful when you want "
-        "to load a file and then tweak the geometry a bit. This will zero current "
-        "offset.",
-        assignment=Assignment.OPTIONAL,
-        accessMode=AccessMode.RECONFIGURABLE,
-    )
-
-
-class ManualGeometryBase(Device):
-    __version__ = deviceVersion
-    geometry_class = None  # subclass must set
-    # subclass must add slot setManual
-
-    geometryPreview = Image(
-        ImageData(np.empty(0, dtype=np.uint32)),
-        displayedName="Geometry preview",
-        encoding=EncodingType.RGBA,
-    )
-
-    geometryOutput = OutputChannel(GeometrySchema)
-
-    geometryFile = Node(
-        GeometryFileNode,
-        displayedName="Geometry file",
-        description="Allows loading geometry from CrystFEL geometry file",
-    )
-
-    @Slot(
-        displayedName="Send geometry",
-        description="Send current geometry on output channel. This output channel is "
-        "typically used by assembler devices waiting for new geometries. Updating the "
-        "geometry will usually automatically cause update, but hit this slot in case "
-        "geometry is missing somewhere.",
-        allowedStates=[State.ACTIVE],
-    )
-    async def sendGeometry(self):
-        self.geometryOutput.schema.pickledGeometry = self.pickled_geometry
-        await self.geometryOutput.writeData()
-
-    @Slot(
-        displayedName="Update preview",
-        allowedStates=[State.ACTIVE],
-    )
-    async def updatePreview(self):
-        axis = self.geometry.inspect()
-        axis.figure.tight_layout(pad=0)
-        axis.figure.set_facecolor("none")
-        # axis.figure.set_size_inches(6, 6)
-        # axis.figure.set_dpi(300)
-        canvas = FigureCanvasAgg(axis.figure)
-        canvas.draw()
-        image_buffer = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(
-            canvas.get_width_height()[::-1] + (4,)
-        )
-        self.geometryPreview = ImageData(
-            image_buffer, encoding=EncodingType.RGBA, bitsPerPixel=3 * 8
-        )
-        self._set_status("Preview updated")
-
-    @Slot(
-        displayedName="Load from file",
-        allowedStates=[State.ACTIVE],
-    )
-    async def loadFromFile(self):
-        with self.push_state(State.CHANGING):
-            geometry = None
-
-            file_path = self.geometryFile.filePath.value
-            self._set_status(f"Loading geometry from {file_path}...")
-
-            try:
-                geometry = self.geometry_class.from_crystfel_geom(file_path)
-            except FileNotFoundError:
-                self._set_status("Geometry file not found", level=logging.WARN)
-            except RuntimeError as e:
-                self._set_status(
-                    f"Failed to load geometry file: {e}", level=logging.WARN
-                )
-            except Exception as e:
-                self._set_status(
-                    f"Misc. exception when loading geometry file: {e}",
-                    level=logging.WARN,
-                )
-            else:
-                await self._set_geometry(geometry)
-                self._set_status("Successfully loaded geometry from file")
-                if self.geometryFile.updateManualOnLoad.value:
-                    self._set_status(
-                        "Updating manual settings on device to reflect loaded geometry"
-                    )
-                    self._update_manual_from_current()
-                return True
-
-        return False
-
-    def _update_manual_from_current(self):
-        # subclass should implement (neat when loading from CrystFEL geom)
-        raise NotImplementedError()
-
-    async def _set_geometry(self, geometry, update_preview=True, send=True):
-        self.geometry = geometry
-        self.pickled_geometry = pickle.dumps(self.geometry)
-        if update_preview:
-            await self.updatePreview()
-        if send:
-            await self.sendGeometry()
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        plt.switch_backend("agg")  # plotting backend which works for preview hack
-
-    async def onInitialization(self):
-        self.state = State.INIT
-        # TODO: try to load file if set
-        await self.setManual()
-        self.state = State.ACTIVE
-
-    @contextlib.contextmanager
-    def push_state(self, state):
-        previous_state = self.state
-        self.state = state
-        try:
-            yield
-        finally:
-            self.state = previous_state
-
-    def _set_status(self, text, level=logging.INFO):
-        """Add and log a status message.
-
-        Suppresses throttling from the gui server.
-        """
-
-        self.status = text
-        self.logger.log(level, text)
-
-
-def makeXYCoordinateNode(
-    default_x, default_y, x_args=None, y_args=None, node_args=None
-):
-    class XYCoordinate(Configurable):
-        x = Double(
-            defaultValue=default_x,
-            accessMode=AccessMode.RECONFIGURABLE,
-            assignment=Assignment.OPTIONAL,
-            **({} if x_args is None else x_args),
-        )
-        y = Double(
-            defaultValue=default_y,
-            accessMode=AccessMode.RECONFIGURABLE,
-            assignment=Assignment.OPTIONAL,
-            **({} if y_args is None else y_args),
-        )
-
-    return Node(XYCoordinate, **({} if node_args is None else node_args))
-
-
-def makeXYOffsetNode():
-    return makeXYCoordinateNode(
-        0,
-        0,
-        x_args={"unitSymbol": Unit.METER},
-        y_args={"unitSymbol": Unit.METER},
-        node_args={
-            "displayedName": "Offset",
-            "description": "See EXtra-geom documentation for details. This offset is "
-            "applied to entire detector after initial geometry is created from manual "
-            "parameters. Example: To move entire geometry up by 2 mm relative to beam, "
-            "set offset.y to 2e-3.",
-        },
-    )
-
-
-def makeQuadrantCornersNode(default_values):
-    assert len(default_values) == 4
-    assert all(len(x) == 2 for x in default_values)
-
-    class QuadrantCornersNode(Configurable):
-        Q1 = makeXYCoordinateNode(*default_values[0])
-        Q2 = makeXYCoordinateNode(*default_values[1])
-        Q3 = makeXYCoordinateNode(*default_values[2])
-        Q4 = makeXYCoordinateNode(*default_values[3])
-        offset = makeXYOffsetNode()
-
-    return Node(QuadrantCornersNode)
-
-
-class ManualQuadrantsGeometryBase(ManualGeometryBase):
-    quadrantCorners = None  # subclass must define (with nice defaults)
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self._quadrant_corners = [
-            self.quadrantCorners.Q1,
-            self.quadrantCorners.Q2,
-            self.quadrantCorners.Q3,
-            self.quadrantCorners.Q4,
-        ]
-
-    @Slot(
-        displayedName="Set from device",
-        allowedStates=[State.ACTIVE],
-    )
-    async def setManual(self):
-        self._set_status("Updating geometry from manual configuration")
-        with self.push_state(State.CHANGING):
-            geometry = self.geometry_class.from_quad_positions(
-                [(Q.x.value, Q.y.value) for Q in self._quadrant_corners]
-            ).offset(
-                (
-                    self.quadrantCorners.offset.x.value,
-                    self.quadrantCorners.offset.y.value,
-                )
-            )
-            await self._set_geometry(geometry)
-
-    def _update_manual_from_current(self):
-        # TODO: consider what to do about offset
-        for corner, (x, y) in zip(
-            self._quadrant_corners, self.geometry.quad_positions()
-        ):
-            corner.x = x
-            corner.y = y
-        self.quadrantCorners.offset.x = 0
-        self.quadrantCorners.offset.y = 0
-
-
-class MdlAgipd1MGeometry(ManualQuadrantsGeometryBase):
-    geometry_class = extra_geom.AGIPD_1MGeometry
-    quadrantCorners = makeQuadrantCornersNode(
-        [
-            (-525, 625),
-            (-550, -10),
-            (520, -160),
-            (542.5, 475),
-        ]
-    )
-
-
-class MdlDssc1MGeometry(ManualQuadrantsGeometryBase):
-    geometry_class = extra_geom.DSSC_1MGeometry
-    quadrantCorners = makeQuadrantCornersNode(
-        [
-            (-130, 5),
-            (-130, -125),
-            (5, -125),
-            (5, 5),
-        ]
-    )
-
-
-class MdlLpd1MGeometry(ManualQuadrantsGeometryBase):
-    geometry_class = extra_geom.LPD_1MGeometry
-    quadrantCorners = makeQuadrantCornersNode(
-        [
-            (11.4, 299),
-            (-11.5, 8),
-            (254.5, -16),
-            (278.5, 275),
-        ]
-    )
-
-
-class ModuleListItem(Configurable):
-    posX = Double()
-    posY = Double()
-    orientX = Int32()
-    orientY = Int32()
-
-
-class ManualModuleListGeometryBase(ManualGeometryBase):
-    moduleList = None  # subclass must define (with nice defaults)
-    offset = makeXYOffsetNode()
-
-    @Slot(
-        displayedName="Set from device",
-        allowedStates=[State.ACTIVE],
-    )
-    async def setManual(self):
-        self._set_status("Updating geometry from manual configuration")
-        with self.push_state(State.CHANGING):
-            self._geometry = self.geometry_class.from_module_positions(
-                [(x, y) for (x, y, _, _) in self.moduleList.value],
-                [
-                    (orient_x, orient_y)
-                    for (_, _, orient_x, orient_y) in self.moduleList.value
-                ],
-            ).offset((self.offset.x.value, self.offset.y.value))
-            self._pickled_geometry = pickle.dumps(self._geometry)
-
-    def _update_manual_from_current(self):
-        self._set_status("Not yet able to update manual config from file")
-
-
-class MdlJungfrauGeometry(ManualModuleListGeometryBase):
-    geometry_class = extra_geom.JUNGFRAUGeometry
-    moduleList = VectorHash(
-        displayedName="Modules",
-        rows=ModuleListItem,
-        defaultValue=[
-            Hash("posX", x, "posY", y, "orientX", ox, "orientY", oy)
-            for (x, y, ox, oy) in [
-                (95, 564, -1, -1),
-                (95, 17, -1, -1),
-                (95, -530, -1, -1),
-                (95, -1077, -1, -1),
-                (-1125, -1078, 1, 1),
-                (-1125, -531, 1, 1),
-                (-1125, 16, 1, 1),
-                (-1125, 563, 1, 1),
-            ]
-        ],
-        accessMode=AccessMode.RECONFIGURABLE,
-        assignment=Assignment.OPTIONAL,
-    )
diff --git a/src/calng/scenes.py b/src/calng/scenes.py
index fb00b680..5db57128 100644
--- a/src/calng/scenes.py
+++ b/src/calng/scenes.py
@@ -20,7 +20,9 @@ from karabo.common.scenemodel.api import (
     RectangleModel,
     SceneModel,
     SceneTargetWindow,
+    TableElementModel,
     TrendGraphModel,
+    WebCamGraphModel,
     write_scene,
 )
 
@@ -596,6 +598,191 @@ class AssemblerDeviceStatus(VerticalLayout):
         )
 
 
+@titled("Manual geometry settings")
+@boxed
+class ManualQuadrantGeometrySettings(VerticalLayout):
+    def __init__(self, device_id):
+        super().__init__(padding=0)
+        self.children.append(
+            HorizontalLayout(
+                Space(width=3 * BASE_INC, height=BASE_INC),
+                LabelModel(text="x", width=4 * BASE_INC, height=BASE_INC),
+                LabelModel(text="y", width=4 * BASE_INC, height=BASE_INC),
+            )
+        )
+        self.children.extend(
+            [
+                HorizontalLayout(
+                    LabelModel(text=f"{thing}", width=3 * BASE_INC, height=BASE_INC),
+                    DoubleLineEditModel(
+                        keys=[f"{device_id}.quadrantCorners.{thing}.x"],
+                        width=4 * BASE_INC,
+                        height=BASE_INC,
+                    ),
+                    DoubleLineEditModel(
+                        keys=[f"{device_id}.quadrantCorners.{thing}.y"],
+                        width=4 * BASE_INC,
+                        height=BASE_INC,
+                    ),
+                )
+                for thing in ("Q1", "Q2", "Q3", "Q4", "offset")
+            ]
+        )
+        self.children.append(
+            DisplayCommandModel(
+                keys=[f"{device_id}.setManual"],
+                width=6 * BASE_INC,
+                height=BASE_INC,
+            ),
+        )
+
+
+@titled("Manual geometry settings")
+@boxed
+class ManualModulesGeometrySettings(VerticalLayout):
+    def __init__(self, device_id):
+        super().__init__(padding=0)
+        self.children.append(
+            TableElementModel(
+                keys=[f"{device_id}.moduleList"],
+                klass="EditableTableElement",
+                width=14 * BASE_INC,
+                height=10 * BASE_INC,
+            )
+        )
+        self.children.append(
+            HorizontalLayout(
+                LabelModel(text="Offset", width=3 * BASE_INC, height=BASE_INC),
+                DoubleLineEditModel(
+                    keys=[f"{device_id}.offset.x"],
+                    width=4 * BASE_INC,
+                    height=BASE_INC,
+                ),
+                DoubleLineEditModel(
+                    keys=[f"{device_id}.offset.y"],
+                    width=4 * BASE_INC,
+                    height=BASE_INC,
+                ),
+            )
+        )
+        self.children.append(
+            DisplayCommandModel(
+                keys=[f"{device_id}.setManual"],
+                width=6 * BASE_INC,
+                height=BASE_INC,
+            ),
+        )
+
+
+@titled("Tweak current geometry")
+@boxed
+class TweakCurrentGeometry(VerticalLayout):
+    def __init__(self, device_id):
+        super().__init__(padding=0)
+        self.children.append(
+            HorizontalLayout(
+                LabelModel(text="Offset", width=3 * BASE_INC, height=BASE_INC),
+                DoubleLineEditModel(
+                    keys=[f"{device_id}.tweakGeometry.offset.x"],
+                    width=4 * BASE_INC,
+                    height=BASE_INC,
+                ),
+                DoubleLineEditModel(
+                    keys=[f"{device_id}.tweakGeometry.offset.y"],
+                    width=4 * BASE_INC,
+                    height=BASE_INC,
+                ),
+            )
+        )
+
+
+@titled("Geometry preview")
+@boxed
+class GeometryPreview(VerticalLayout):
+    def __init__(self, device_id):
+        super().__init__(padding=0)
+        self.children.append(
+            HorizontalLayout(
+                DisplayCommandModel(
+                    keys=[f"{device_id}.updatePreview"],
+                    width=6 * BASE_INC,
+                    height=BASE_INC,
+                ),
+                DisplayCommandModel(
+                    keys=[f"{device_id}.sendGeometry"],
+                    width=6 * BASE_INC,
+                    height=BASE_INC,
+                ),
+            )
+        )
+        self.children.append(
+            WebCamGraphModel(
+                keys=[f"{device_id}.geometryPreview"],
+                width=30 * BASE_INC,
+                height=30 * BASE_INC,
+                x=PADDING,
+                y=PADDING,
+            )
+        )
+
+
+@titled("Geometry from file")
+@boxed
+class GeometryFromFileSettings(VerticalLayout):
+    def __init__(self, device_id):
+        super().__init__(padding=0)
+        self.children.append(
+            LabelModel(
+                text="File path:",
+                width=4 * BASE_INC,
+                height=BASE_INC,
+            )
+        )
+        self.children.append(
+            LineEditModel(
+                keys=[f"{device_id}.geometryFile.filePath"],
+                klass="EditableLineEdit",
+                width=8 * BASE_INC,
+                height=BASE_INC,
+            )
+        )
+        self.children.append(
+            HorizontalLayout(
+                LabelModel(text="Offset", width=3 * BASE_INC, height=BASE_INC),
+                DoubleLineEditModel(
+                    keys=[f"{device_id}.geometryFile.offset.x"],
+                    width=4 * BASE_INC,
+                    height=BASE_INC,
+                ),
+                DoubleLineEditModel(
+                    keys=[f"{device_id}.geometryFile.offset.y"],
+                    width=4 * BASE_INC,
+                    height=BASE_INC,
+                ),
+            )
+        )
+        self.children.append(
+            HorizontalLayout(
+                LabelModel(
+                    text="Update manual settings", width=6 * BASE_INC, height=BASE_INC
+                ),
+                CheckBoxModel(
+                    keys=[f"{device_id}.geometryFile.updateManualOnLoad"],
+                    klass="EditableCheckBox",
+                    width=2 * BASE_INC,
+                    height=BASE_INC,
+                ),
+            )
+        )
+        self.children.append(
+            DisplayCommandModel(
+                keys=[f"{device_id}.loadFromFile"],
+                width=6 * BASE_INC,
+                height=BASE_INC,
+            ),
+        )
+
+
 # section: generating actual scenes
 
 
@@ -624,7 +811,7 @@ def scene_generator(fun):
 
 
 @scene_generator
-def correction_device_overview_scene(device_id, schema):
+def correction_device_overview(device_id, schema):
     schema_hash = schema_to_hash(schema)
 
     return HorizontalLayout(
@@ -652,7 +839,7 @@ def correction_device_overview_scene(device_id, schema):
 
 
 @scene_generator
-def manager_device_overview_scene(
+def manager_device_overview(
     manager_device_id,
     manager_device_schema,
     correction_device_schema,
@@ -835,6 +1022,28 @@ def detector_assembler_overview(device_id, geometry_device_id):
     )
 
 
+@scene_generator
+def quadrant_geometry_overview(device_id):
+    return VerticalLayout(
+        HorizontalLayout(
+            ManualQuadrantGeometrySettings(device_id),
+            GeometryFromFileSettings(device_id),
+        ),
+        GeometryPreview(device_id),
+    )
+
+
+@scene_generator
+def modules_geometry_overview(device_id):
+    return VerticalLayout(
+        HorizontalLayout(
+            ManualModulesGeometrySettings(device_id),
+            GeometryFromFileSettings(device_id),
+        ),
+        GeometryPreview(device_id),
+    )
+
+
 # section: here be monsters
 
 
-- 
GitLab