diff --git a/openmc_plotter/docks.py b/openmc_plotter/docks.py
index 36b7ba2..0b6807d 100644
--- a/openmc_plotter/docks.py
+++ b/openmc_plotter/docks.py
@@ -172,6 +172,11 @@ def __init__(self, model, font_metric, main_window, parent=None):
self.zoomWidget = QWidget()
self.zoomWidget.setLayout(self.zoomLayout)
+ # OpenMC renderer launcher
+ self.renderButton = QPushButton('Render')
+ self.renderButton.setMinimumHeight(self.font_metric.height() * 1.6)
+ self.renderButton.clicked.connect(self.main_window.showRendererDialog)
+
# Create Layout
self.panelLayout = QVBoxLayout()
self.panelLayout.addWidget(self.originGroupBox)
@@ -179,6 +184,7 @@ def __init__(self, model, font_metric, main_window, parent=None):
self.panelLayout.addWidget(self.resGroupBox)
self.panelLayout.addWidget(HorizontalLine())
self.panelLayout.addWidget(self.zoomWidget)
+ self.panelLayout.addWidget(self.renderButton)
self.panelLayout.addStretch()
self.setLayout(self.panelLayout)
diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py
index 2ae8e20..c9c7f28 100755
--- a/openmc_plotter/main_window.py
+++ b/openmc_plotter/main_window.py
@@ -1,7 +1,9 @@
import copy
from functools import partial
+import importlib.util
from pathlib import Path
import pickle
+import sys
from threading import Thread
from PySide6 import QtCore, QtGui
@@ -9,7 +11,7 @@
from PySide6.QtWidgets import (QApplication, QLabel, QSizePolicy, QMainWindow,
QScrollArea, QMessageBox, QFileDialog,
QColorDialog, QInputDialog, QWidget,
- QGestureEvent)
+ QGestureEvent, QDialog, QVBoxLayout)
import openmc
import openmc.lib
@@ -24,6 +26,7 @@
from .plotgui import PlotImage, ColorDialog
from .docks import TabbedDock
from .overlays import ShortcutsOverlay
+from .renderer_widget import RendererWidget
from .tools import ExportDataDialog, SourceSitesDialog
@@ -41,6 +44,15 @@ def _openmcReload(threads=None, model_path='.'):
openmc.lib.settings.verbosity = 1
+def _load_module_from_path(module_name, module_path):
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Unable to load module from {module_path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
class MainWindow(QMainWindow):
def __init__(self,
font=QtGui.QFontMetrics(QtGui.QFont()),
@@ -58,6 +70,10 @@ def __init__(self,
self.default_res = resolution
self.model = None
self.plot_manager = None
+ self._render_dialog = None
+ self._render_plotter = None
+ self._renderer_widget = None
+ self._renderer_classes = None
def loadGui(self, use_settings_pkl=True):
@@ -496,6 +512,7 @@ def loadModel(self, reload=False, use_settings_pkl=True):
self.cellsModel = DomainTableModel(self.model.activeView.cells)
self.materialsModel = DomainTableModel(self.model.activeView.materials)
+ self._connectDomainModelSignals()
openmc_args = {'threads': self.threads, 'model_path': self.model_path}
@@ -647,6 +664,7 @@ def plotSourceSites(self):
self.sourceSitesDialog.activateWindow()
def applyChanges(self):
+ self._syncRendererFromModel(use_active=True)
if self.model.activeView != self.model.currentView:
if self.model.activeView.selectedTally is not None:
self.tallyPanel.updateModel()
@@ -819,6 +837,213 @@ def showColorDialog(self):
self.colorDialog.raise_()
self.colorDialog.activateWindow()
+ def showRendererDialog(self):
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(QMessageBox.Information)
+ msg_box.setWindowTitle("Experimental Renderer")
+ msg_box.setText(
+ "The render widget is experimental.\n\n"
+ "Complex models may cause the plotter application to lag or, in some cases, crash."
+ )
+ msg_box.setStandardButtons(QMessageBox.Ok)
+ msg_box.exec()
+
+ if not self._hasSolidRayTracePlot():
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(QMessageBox.Warning)
+ msg_box.setWindowTitle("Renderer Unavailable")
+ msg_box.setText(
+ "This OpenMC installation is missing:\n"
+ "openmc.lib.capi.SolidRayTracePlot\n\n"
+ "Install an OpenMC build that provides SolidRayTracePlot "
+ "to use the render widget."
+ )
+ msg_box.setStandardButtons(QMessageBox.Ok)
+ msg_box.exec()
+ return
+
+ if self._render_dialog is not None:
+ self._render_dialog.raise_()
+ self._render_dialog.activateWindow()
+ self._syncRendererFromModel(use_active=True)
+ return
+
+ try:
+ OpenMCPlotter, GLPlotWidget = self._loadRendererClasses()
+ openmc_args = ["-c"]
+ if self.threads is not None:
+ openmc_args += ["-s", str(self.threads)]
+ openmc_args.append(str(self.model_path))
+
+ plotter = OpenMCPlotter(args=openmc_args)
+ material_domains, cell_domains = self._getRendererDomainData(
+ view=self.model.activeView
+ )
+ initial_color_mode = self.model.activeView.colorby
+
+ dialog = QDialog(self)
+ dialog.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+ dialog.setWindowTitle("OpenMC Renderer")
+ dialog.resize(900, 700)
+
+ layout = QVBoxLayout(dialog)
+ renderer_widget = RendererWidget(
+ plotter,
+ GLPlotWidget,
+ material_domains=material_domains,
+ cell_domains=cell_domains,
+ initial_color_mode=initial_color_mode,
+ on_color_changed=self._onRendererDomainColorChanged,
+ parent=dialog,
+ )
+ layout.addWidget(renderer_widget)
+
+ dialog.finished.connect(self._rendererDialogClosed)
+ dialog.show()
+
+ self._render_dialog = dialog
+ self._render_plotter = plotter
+ self._renderer_widget = renderer_widget
+
+ except Exception as exc:
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(QMessageBox.Warning)
+ msg_box.setText(
+ "Unable to start the OpenMC renderer.\n\n"
+ f"{exc}\n\n"
+ "Ensure openmc_renderer is available and dependencies are installed."
+ )
+ msg_box.exec()
+
+ def _rendererDialogClosed(self, _result):
+ self._render_dialog = None
+ self._render_plotter = None
+ self._renderer_widget = None
+
+ def _loadRendererClasses(self):
+ if self._renderer_classes is not None:
+ return self._renderer_classes
+
+ renderer_python_dir = self._findRendererPythonDir()
+ if renderer_python_dir is None:
+ raise FileNotFoundError(
+ "Could not locate bundled renderer_core directory."
+ )
+
+ renderer_python_dir_str = str(renderer_python_dir)
+ if renderer_python_dir_str not in sys.path:
+ sys.path.insert(0, renderer_python_dir_str)
+
+ plotter_module = _load_module_from_path(
+ "openmc_renderer_plotter", renderer_python_dir / "openmc_plotter.py"
+ )
+ gl_module = _load_module_from_path(
+ "openmc_renderer_gl_widget", renderer_python_dir / "gl_widget.py"
+ )
+
+ self._renderer_classes = (plotter_module.OpenMCPlotter,
+ gl_module.GLPlotWidget)
+ return self._renderer_classes
+
+ def _findRendererPythonDir(self):
+ local_runtime = Path(__file__).resolve().parent / "renderer_core"
+ if local_runtime.is_dir():
+ return local_runtime
+
+ return None
+
+ def _hasSolidRayTracePlot(self):
+ return getattr(openmc.lib, "SolidRayTracePlot", None) is not None
+
+ def _getRendererDomainData(self, view=None):
+ if view is None:
+ view = self.model.activeView
+ return (self._extractDomainData(view.materials),
+ self._extractDomainData(view.cells))
+
+ def _extractDomainData(self, domains):
+ domain_data = {}
+ for domain_id in domains.defaults:
+ did = int(domain_id)
+ if did < 0:
+ continue
+
+ domain = domains[did]
+ domain_data[did] = {
+ "name": domain.name if domain.name is not None else "",
+ "color": self._normalizeRendererColor(domain.color),
+ }
+ return domain_data
+
+ def _normalizeRendererColor(self, color):
+ if color is None:
+ return None
+
+ if isinstance(color, str):
+ color_name = color.lower()
+ if color_name not in openmc.plots._SVG_COLORS:
+ return None
+ color = openmc.plots._SVG_COLORS[color_name]
+
+ try:
+ rgb = tuple(int(component) for component in color)
+ except TypeError:
+ return None
+
+ if len(rgb) != 3:
+ return None
+
+ return tuple(max(0, min(255, component)) for component in rgb)
+
+ def _onRendererDomainColorChanged(self, domain_kind, domain_id, color):
+ rgb = self._normalizeRendererColor(color)
+ if rgb is None:
+ return
+
+ if domain_kind == "cell":
+ domains = self.model.activeView.cells
+ else:
+ domains = self.model.activeView.materials
+
+ domain_id = int(domain_id)
+ if domain_id not in domains.defaults:
+ return
+ if self._normalizeRendererColor(domains[domain_id].color) == rgb:
+ return
+
+ domains.set_color(domain_id, rgb)
+ self._syncRendererFromModel(use_active=True)
+ self.applyChanges()
+
+ def _syncRendererFromModel(self, use_active=True, sync_color_mode=False):
+ if self._renderer_widget is None:
+ return
+
+ view = self.model.activeView if use_active else self.model.currentView
+ material_domains, cell_domains = self._getRendererDomainData(view=view)
+ color_mode = None
+ if sync_color_mode:
+ color_mode = "cell" if view.colorby == "cell" else "material"
+ self._renderer_widget.syncDomainData(
+ material_domains=material_domains,
+ cell_domains=cell_domains,
+ color_mode=color_mode,
+ )
+
+ def _connectDomainModelSignals(self):
+ unique = QtCore.Qt.ConnectionType.UniqueConnection
+ for table_model in (self.cellsModel, self.materialsModel):
+ try:
+ table_model.dataChanged.connect(
+ self._onDomainModelDataChanged, unique
+ )
+ except (TypeError, RuntimeError):
+ # Already connected for this model instance.
+ pass
+
+ def _onDomainModelDataChanged(self, *_args):
+ self._syncRendererFromModel(use_active=True)
+
def showExportDialog(self):
self.exportDataDialog.show()
self.exportDataDialog.raise_()
@@ -1125,11 +1350,13 @@ def restoreWindowSettings(self):
def resetModels(self):
self.cellsModel = DomainTableModel(self.model.activeView.cells)
self.materialsModel = DomainTableModel(self.model.activeView.materials)
+ self._connectDomainModelSignals()
self.cellsModel.beginResetModel()
self.cellsModel.endResetModel()
self.materialsModel.beginResetModel()
self.materialsModel.endResetModel()
self.colorDialog.updateDomainTabs()
+ self._syncRendererFromModel(use_active=True)
def showCurrentView(self):
self.updateScale()
@@ -1227,6 +1454,7 @@ def requestPlotUpdate(self, view=None):
self.model.makePlot(view_snapshot, self.model.ids_map, self.model.properties)
self.resetModels()
self.showCurrentView()
+ self._syncRendererFromModel(use_active=False)
if not self.plot_manager.is_busy:
self._on_plot_idle()
return
@@ -1250,6 +1478,7 @@ def _on_plot_finished(self, view_snapshot, view_params, ids_map, properties):
self.model.makePlot(view_snapshot, ids_map, properties)
self.resetModels()
self.showCurrentView()
+ self._syncRendererFromModel(use_active=False)
def _on_plot_error(self, error_msg):
msg_box = QMessageBox()
diff --git a/openmc_plotter/renderer_core/camera.py b/openmc_plotter/renderer_core/camera.py
new file mode 100644
index 0000000..4ce3c4d
--- /dev/null
+++ b/openmc_plotter/renderer_core/camera.py
@@ -0,0 +1,237 @@
+import math
+import numpy as np
+
+
+class OrbitCamera:
+ _EPS = 1.0e-12
+
+ def __init__(self):
+ self.distance = 15.0
+ self.target = np.array([0.0, 0.0, 0.0], dtype=np.float64)
+ self.world_up = np.array([0.0, 0.0, 1.0], dtype=np.float64)
+ self.fov = 45.0
+
+ self.rotate_speed = 0.005
+ self.pan_speed = 1.0
+ self.zoom_speed = 0.1
+ self.min_distance = 1.0
+
+ self._orientation = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
+ self._set_from_spherical(math.radians(45.0), math.radians(30.0))
+
+ @staticmethod
+ def _normalize(vec):
+ norm = np.linalg.norm(vec)
+ if norm <= OrbitCamera._EPS:
+ return vec
+ return vec / norm
+
+ @staticmethod
+ def _quat_normalize(quat):
+ norm = np.linalg.norm(quat)
+ if norm <= OrbitCamera._EPS:
+ return np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
+ return quat / norm
+
+ @staticmethod
+ def _quat_conjugate(quat):
+ return np.array([quat[0], -quat[1], -quat[2], -quat[3]], dtype=np.float64)
+
+ @staticmethod
+ def _quat_multiply(q1, q2):
+ w1, x1, y1, z1 = q1
+ w2, x2, y2, z2 = q2
+ return np.array(
+ [
+ w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
+ w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
+ w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
+ w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
+ ],
+ dtype=np.float64,
+ )
+
+ @staticmethod
+ def _quat_from_axis_angle(axis, angle):
+ axis = np.asarray(axis, dtype=np.float64)
+ axis_norm = np.linalg.norm(axis)
+ if axis_norm <= OrbitCamera._EPS or abs(angle) <= OrbitCamera._EPS:
+ return np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
+ axis = axis / axis_norm
+ half = 0.5 * angle
+ s = math.sin(half)
+ return np.array([math.cos(half), axis[0] * s, axis[1] * s, axis[2] * s], dtype=np.float64)
+
+ @staticmethod
+ def _quat_rotate(quat, vec):
+ q_vec = np.array([0.0, vec[0], vec[1], vec[2]], dtype=np.float64)
+ rotated = OrbitCamera._quat_multiply(
+ OrbitCamera._quat_multiply(quat, q_vec),
+ OrbitCamera._quat_conjugate(quat),
+ )
+ return rotated[1:]
+
+ @staticmethod
+ def _quat_from_matrix(matrix):
+ m = np.asarray(matrix, dtype=np.float64)
+ trace = m[0, 0] + m[1, 1] + m[2, 2]
+ if trace > 0.0:
+ s = math.sqrt(trace + 1.0) * 2.0
+ w = 0.25 * s
+ x = (m[2, 1] - m[1, 2]) / s
+ y = (m[0, 2] - m[2, 0]) / s
+ z = (m[1, 0] - m[0, 1]) / s
+ elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]:
+ s = math.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2]) * 2.0
+ w = (m[2, 1] - m[1, 2]) / s
+ x = 0.25 * s
+ y = (m[0, 1] + m[1, 0]) / s
+ z = (m[0, 2] + m[2, 0]) / s
+ elif m[1, 1] > m[2, 2]:
+ s = math.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2]) * 2.0
+ w = (m[0, 2] - m[2, 0]) / s
+ x = (m[0, 1] + m[1, 0]) / s
+ y = 0.25 * s
+ z = (m[1, 2] + m[2, 1]) / s
+ else:
+ s = math.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1]) * 2.0
+ w = (m[1, 0] - m[0, 1]) / s
+ x = (m[0, 2] + m[2, 0]) / s
+ y = (m[1, 2] + m[2, 1]) / s
+ z = 0.25 * s
+ quat = np.array([w, x, y, z], dtype=np.float64)
+ return OrbitCamera._quat_normalize(quat)
+
+ def _offset_direction(self):
+ return self._normalize(self._quat_rotate(self._orientation, np.array([1.0, 0.0, 0.0], dtype=np.float64)))
+
+ def _spherical_angles(self):
+ offset = self._offset_direction()
+ azimuth = math.atan2(offset[1], offset[0])
+ elevation = math.atan2(offset[2], math.hypot(offset[0], offset[1]))
+ return azimuth, elevation
+
+ def _set_from_spherical(self, azimuth, elevation):
+ cos_e = math.cos(elevation)
+ offset = np.array(
+ [
+ cos_e * math.cos(azimuth),
+ cos_e * math.sin(azimuth),
+ math.sin(elevation),
+ ],
+ dtype=np.float64,
+ )
+ self._set_from_forward(-offset, up_hint=self.world_up)
+
+ def _set_from_forward(self, forward, up_hint=None):
+ forward = self._normalize(np.asarray(forward, dtype=np.float64))
+ if np.linalg.norm(forward) <= self._EPS:
+ return
+
+ if up_hint is None:
+ up_hint = self.world_up
+ up_ref = self._normalize(np.asarray(up_hint, dtype=np.float64))
+ right = np.cross(forward, up_ref)
+ if np.linalg.norm(right) <= self._EPS:
+ alt_up = np.array([0.0, 1.0, 0.0], dtype=np.float64)
+ if abs(np.dot(forward, alt_up)) > 0.95:
+ alt_up = np.array([1.0, 0.0, 0.0], dtype=np.float64)
+ right = np.cross(forward, alt_up)
+
+ right = self._normalize(right)
+ up = self._normalize(np.cross(right, forward))
+ offset = -forward
+ basis = np.column_stack((offset, right, up))
+ self._orientation = self._quat_from_matrix(basis)
+
+ @property
+ def azimuth(self):
+ azimuth, _ = self._spherical_angles()
+ return azimuth
+
+ @azimuth.setter
+ def azimuth(self, value):
+ _, elevation = self._spherical_angles()
+ self._set_from_spherical(float(value), elevation)
+
+ @property
+ def elevation(self):
+ _, elevation = self._spherical_angles()
+ return elevation
+
+ @elevation.setter
+ def elevation(self, value):
+ azimuth, _ = self._spherical_angles()
+ self._set_from_spherical(azimuth, float(value))
+
+ def position(self):
+ return self.target + self.distance * self._offset_direction()
+
+ def view_vectors(self):
+ offset = self._offset_direction()
+ forward = -offset
+ right = self._normalize(self._quat_rotate(self._orientation, np.array([0.0, 1.0, 0.0], dtype=np.float64)))
+ up = self._normalize(self._quat_rotate(self._orientation, np.array([0.0, 0.0, 1.0], dtype=np.float64)))
+ # Re-orthonormalize to avoid numerical drift after many updates.
+ right = self._normalize(np.cross(forward, up))
+ up = self._normalize(np.cross(right, forward))
+ return forward, right, up
+
+ def orbit(self, dx, dy):
+ yaw = -dx * self.rotate_speed
+ pitch = -dy * self.rotate_speed
+ if abs(yaw) > self._EPS:
+ q_yaw = self._quat_from_axis_angle(self.world_up, yaw)
+ self._orientation = self._quat_normalize(
+ self._quat_multiply(q_yaw, self._orientation)
+ )
+ if abs(pitch) > self._EPS:
+ _, right, _ = self.view_vectors()
+ q_pitch = self._quat_from_axis_angle(right, pitch)
+ self._orientation = self._quat_normalize(
+ self._quat_multiply(q_pitch, self._orientation)
+ )
+
+ def pan(self, dx, dy, viewport_height):
+ if viewport_height <= 0:
+ return
+ _, right, up = self.view_vectors()
+ scale = 2.0 * self.distance * math.tan(math.radians(self.fov) * 0.5) / viewport_height
+ # Keep horizontal pan behavior, but invert vertical pan to match the
+ # rendered image orientation in the embedded widget.
+ self.target += (-right * dx + up * dy) * scale * self.pan_speed
+
+ def zoom(self, delta):
+ self.distance *= (1.0 - delta * self.zoom_speed)
+ if self.distance < self.min_distance:
+ self.distance = self.min_distance
+
+ def set_isometric_view(self):
+ self._set_from_spherical(math.radians(45.0), math.radians(35.264))
+
+ def set_axis_view(self, axis, negative=False):
+ axis = axis.lower()
+ offset = None
+ if axis == "x":
+ offset = np.array([-1.0, 0.0, 0.0], dtype=np.float64) if negative else np.array([1.0, 0.0, 0.0], dtype=np.float64)
+ elif axis == "y":
+ offset = np.array([0.0, -1.0, 0.0], dtype=np.float64) if negative else np.array([0.0, 1.0, 0.0], dtype=np.float64)
+ elif axis == "z":
+ offset = np.array([0.0, 0.0, -1.0], dtype=np.float64) if negative else np.array([0.0, 0.0, 1.0], dtype=np.float64)
+ if offset is None:
+ return
+
+ forward = -offset
+ up_hint = self.world_up
+ if axis == "z":
+ # Avoid ambiguous world-up alignment in top/bottom views.
+ up_hint = np.array([0.0, 1.0, 0.0], dtype=np.float64)
+ self._set_from_forward(forward, up_hint=up_hint)
+
+ def set_speeds(self, rotate_speed=None, pan_speed=None, zoom_speed=None):
+ if rotate_speed is not None:
+ self.rotate_speed = float(rotate_speed)
+ if pan_speed is not None:
+ self.pan_speed = float(pan_speed)
+ if zoom_speed is not None:
+ self.zoom_speed = float(zoom_speed)
diff --git a/openmc_plotter/renderer_core/gl_widget.py b/openmc_plotter/renderer_core/gl_widget.py
new file mode 100644
index 0000000..90a6384
--- /dev/null
+++ b/openmc_plotter/renderer_core/gl_widget.py
@@ -0,0 +1,445 @@
+import time
+import numpy as np
+from PySide6 import QtCore, QtGui, QtWidgets
+from PySide6.QtOpenGLWidgets import QOpenGLWidget
+from OpenGL.GL import (
+ glBindTexture,
+ glClear,
+ glClearColor,
+ glDeleteTextures,
+ glDisable,
+ glEnable,
+ glGenTextures,
+ glLoadIdentity,
+ glMatrixMode,
+ glOrtho,
+ glPixelStorei,
+ glTexImage2D,
+ glTexParameteri,
+ glTexSubImage2D,
+ glViewport,
+ glBegin,
+ glEnd,
+ glTexCoord2f,
+ glVertex2f,
+ GL_COLOR_BUFFER_BIT,
+ GL_DEPTH_TEST,
+ GL_MODELVIEW,
+ GL_PROJECTION,
+ GL_QUADS,
+ GL_RGB,
+ GL_TEXTURE_2D,
+ GL_TEXTURE_MAG_FILTER,
+ GL_TEXTURE_MIN_FILTER,
+ GL_LINEAR,
+ GL_UNSIGNED_BYTE,
+ GL_UNPACK_ALIGNMENT,
+)
+
+from camera import OrbitCamera
+
+
+class GLPlotWidget(QOpenGLWidget):
+ def __init__(self, plotter, parent=None):
+ super().__init__(parent)
+ self._plotter = plotter
+ self._camera = OrbitCamera()
+ self._texture = None
+ self._dirty = True
+ self._last_pos = None
+ self._buttons = set()
+ self._render_mode = "final"
+ self._interactive_scale = 0.35
+ self._min_frame_interval = 1.0 / 15.0
+ self._last_render_time = 0.0
+ self._render_size = None
+
+ self._idle_timer = QtCore.QTimer(self)
+ self._idle_timer.setSingleShot(True)
+ self._idle_timer.timeout.connect(self._request_final_render)
+
+ self._throttle_timer = QtCore.QTimer(self)
+ self._throttle_timer.setSingleShot(True)
+ self._throttle_timer.timeout.connect(self._do_update)
+
+ self._light_follows_camera = True
+ self._light_control_mode = False
+ self._light_distance = self._camera.distance
+ self._light_azimuth = self._camera.azimuth
+ self._light_elevation = self._camera.elevation
+
+ self._help_overlay = QtWidgets.QFrame(self)
+ self._help_overlay.setVisible(False)
+ self._help_overlay.setFocusPolicy(QtCore.Qt.StrongFocus)
+ self._help_overlay.setAttribute(QtCore.Qt.WA_StyledBackground, True)
+ self._help_overlay.setStyleSheet(
+ "QFrame { background-color: rgba(0, 0, 0, 200); color: white; }"
+ )
+ self._help_overlay.installEventFilter(self)
+
+ overlay_layout = QtWidgets.QVBoxLayout(self._help_overlay)
+ overlay_layout.setContentsMargins(24, 24, 24, 24)
+
+ title = QtWidgets.QLabel("OpenMC Renderer Controls", self._help_overlay)
+ title.setStyleSheet("font-size: 18px; font-weight: bold;")
+ overlay_layout.addWidget(title)
+
+ help_text = QtWidgets.QTextBrowser(self._help_overlay)
+ help_text.setFrameStyle(QtWidgets.QFrame.NoFrame)
+ help_text.setOpenExternalLinks(False)
+ help_text.setStyleSheet("background: transparent; color: white;")
+ help_text.setHtml(
+ """
+Camera Controls
+Left drag: Orbit camera
+Right drag: Pan camera
+Mouse wheel: Zoom
+Camera presets: Iso, +/-X, +/-Y, +/-Z buttons
+
+Light Controls
+Light follows camera: toggles light to camera position
+Light control mode: left drag rotates light, right drag changes distance
+Mouse wheel changes light distance when light control mode is active
+
+Display
+Color by: switch between material and cell coloring
+Visibility list: toggle per material/cell
+Color swatch: edit per material/cell color
+Save PNG: Ctrl+S / Cmd+S
+"""
+ )
+ overlay_layout.addWidget(help_text, 1)
+
+ hint = QtWidgets.QLabel("Press ? or Esc to close", self._help_overlay)
+ hint.setStyleSheet("color: #dddddd;")
+ overlay_layout.addWidget(hint)
+
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ def minimumSizeHint(self):
+ return QtCore.QSize(400, 300)
+
+ def sizeHint(self):
+ return QtCore.QSize(900, 700)
+
+ def initializeGL(self):
+ glClearColor(0.05, 0.05, 0.06, 1.0)
+ glDisable(GL_DEPTH_TEST)
+ glEnable(GL_TEXTURE_2D)
+
+ self._texture = glGenTextures(1)
+ glBindTexture(GL_TEXTURE_2D, self._texture)
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
+ self._dirty = True
+
+ def resizeGL(self, width, height):
+ glViewport(0, 0, width, height)
+ if width > 0 and height > 0:
+ self._render_mode = "interactive"
+ self._dirty = True
+ self._idle_timer.start(250)
+ self._request_redraw()
+ self._help_overlay.setGeometry(self.rect())
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self._help_overlay.setGeometry(self.rect())
+
+ def eventFilter(self, obj, event):
+ if obj == self._help_overlay:
+ if event.type() in (QtCore.QEvent.MouseButtonPress, QtCore.QEvent.KeyPress):
+ self.toggle_help_overlay()
+ return True
+ return super().eventFilter(obj, event)
+
+ def paintGL(self):
+ glClear(GL_COLOR_BUFFER_BIT)
+
+ if self._dirty:
+ self._sync_plotter_camera()
+ width, height = self._current_render_size()
+ self._ensure_plotter_size(width, height)
+ image = self._plotter.create_image()
+ self._upload_texture(image)
+ self._dirty = False
+ self._last_render_time = time.monotonic()
+
+ self._draw_textured_quad()
+
+ def _sync_plotter_camera(self):
+ pos = self._camera.position()
+ _, _, up = self._camera.view_vectors()
+ if self._light_follows_camera:
+ self._sync_light_from_camera()
+ light_pos = pos
+ else:
+ light_pos = self._light_position()
+ self._plotter.set_camera(
+ position=pos,
+ look_at=self._camera.target,
+ up=up,
+ fov=self._camera.fov,
+ light_position=light_pos,
+ )
+
+ def _upload_texture(self, image):
+ if self._texture is None:
+ return
+ image = np.ascontiguousarray(image, dtype=np.uint8)
+ height, width, _ = image.shape
+ glBindTexture(GL_TEXTURE_2D, self._texture)
+ glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
+ glTexImage2D(
+ GL_TEXTURE_2D,
+ 0,
+ GL_RGB,
+ width,
+ height,
+ 0,
+ GL_RGB,
+ GL_UNSIGNED_BYTE,
+ image,
+ )
+
+ def _current_render_size(self):
+ width = max(1, self.width())
+ height = max(1, self.height())
+ if self._render_mode == "interactive":
+ width = max(64, int(width * self._interactive_scale))
+ height = max(64, int(height * self._interactive_scale))
+ return width, height
+
+ def _ensure_plotter_size(self, width, height):
+ if self._render_size != (width, height):
+ self._plotter.set_pixels(width, height)
+ self._render_size = (width, height)
+
+ def _do_update(self):
+ self.update()
+
+ def _request_redraw(self, force=False):
+ if force:
+ self.update()
+ return
+ now = time.monotonic()
+ elapsed = now - self._last_render_time
+ if elapsed >= self._min_frame_interval and not self._throttle_timer.isActive():
+ self.update()
+ return
+ if not self._throttle_timer.isActive():
+ delay = max(0.0, self._min_frame_interval - elapsed)
+ self._throttle_timer.start(int(delay * 1000))
+
+ def _request_interactive_render(self):
+ self._render_mode = "interactive"
+ self._dirty = True
+ self._idle_timer.start(250)
+ self._request_redraw()
+
+ def _request_final_render(self):
+ self._render_mode = "final"
+ self._dirty = True
+ self._request_redraw(force=True)
+
+ def request_final_render(self):
+ self._request_final_render()
+
+ def toggle_help_overlay(self):
+ if self._help_overlay.isVisible():
+ self._help_overlay.hide()
+ else:
+ self._help_overlay.setGeometry(self.rect())
+ self._help_overlay.show()
+ self._help_overlay.raise_()
+ self._help_overlay.setFocus(QtCore.Qt.ActiveWindowFocusReason)
+
+ def save_screenshot(self):
+ image = self.grabFramebuffer()
+ if image.isNull():
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Save PNG",
+ "Failed to capture the current frame.",
+ )
+ return
+
+ default_dir = QtCore.QStandardPaths.writableLocation(
+ QtCore.QStandardPaths.PicturesLocation
+ )
+ if not default_dir:
+ default_dir = QtCore.QDir.homePath()
+ timestamp = QtCore.QDateTime.currentDateTime().toString(
+ "yyyyMMdd_HHmmss"
+ )
+ default_name = f"openmc_render_{timestamp}.png"
+ default_path = QtCore.QDir(default_dir).filePath(default_name)
+
+ filename, _ = QtWidgets.QFileDialog.getSaveFileName(
+ self,
+ "Save Rendered Image",
+ default_path,
+ "PNG Images (*.png)",
+ )
+ if not filename:
+ return
+ if not filename.lower().endswith(".png"):
+ filename += ".png"
+ if not image.save(filename, "PNG"):
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Save PNG",
+ f"Failed to save image to:\n{filename}",
+ )
+
+ def set_light_follows_camera(self, enabled):
+ self._light_follows_camera = bool(enabled)
+ if enabled:
+ self._sync_light_from_camera()
+ self._request_final_render()
+
+ def set_light_control_mode(self, enabled):
+ self._light_control_mode = bool(enabled)
+ self.setCursor(QtCore.Qt.CrossCursor if self._light_control_mode else QtCore.Qt.ArrowCursor)
+
+ def _light_position(self):
+ cos_e = np.cos(self._light_elevation)
+ sin_e = np.sin(self._light_elevation)
+ cos_a = np.cos(self._light_azimuth)
+ sin_a = np.sin(self._light_azimuth)
+ x = self._camera.target[0] + self._light_distance * cos_e * cos_a
+ y = self._camera.target[1] + self._light_distance * cos_e * sin_a
+ z = self._camera.target[2] + self._light_distance * sin_e
+ return np.array([x, y, z], dtype=np.float64)
+
+ def _sync_light_from_camera(self):
+ self._light_distance = self._camera.distance
+ self._light_azimuth = self._camera.azimuth
+ self._light_elevation = self._camera.elevation
+
+ def _draw_textured_quad(self):
+ if self._texture is None:
+ return
+ glMatrixMode(GL_PROJECTION)
+ glLoadIdentity()
+ glOrtho(0, 1, 0, 1, -1, 1)
+ glMatrixMode(GL_MODELVIEW)
+ glLoadIdentity()
+
+ glBindTexture(GL_TEXTURE_2D, self._texture)
+ glBegin(GL_QUADS)
+ # OpenMC image rows are top-to-bottom; OpenGL texture coordinates are
+ # bottom-to-top. Flip the texture vertically to preserve axis direction.
+ glTexCoord2f(0.0, 1.0)
+ glVertex2f(0.0, 0.0)
+ glTexCoord2f(1.0, 1.0)
+ glVertex2f(1.0, 0.0)
+ glTexCoord2f(1.0, 0.0)
+ glVertex2f(1.0, 1.0)
+ glTexCoord2f(0.0, 0.0)
+ glVertex2f(0.0, 1.0)
+ glEnd()
+
+ def mousePressEvent(self, event):
+ if self._help_overlay.isVisible():
+ event.accept()
+ return
+ self._last_pos = event.position()
+ self._buttons.add(event.button())
+ event.accept()
+
+ def set_isometric_view(self):
+ self._camera.set_isometric_view()
+ if self._light_follows_camera:
+ self._sync_light_from_camera()
+ self._request_final_render()
+
+ def set_axis_view(self, axis, negative=False):
+ self._camera.set_axis_view(axis, negative=negative)
+ if self._light_follows_camera:
+ self._sync_light_from_camera()
+ self._request_final_render()
+
+ def set_camera_speeds(self, rotate=None, pan=None, zoom=None):
+ self._camera.set_speeds(rotate_speed=rotate, pan_speed=pan, zoom_speed=zoom)
+
+ def mouseReleaseEvent(self, event):
+ if self._help_overlay.isVisible():
+ event.accept()
+ return
+ if event.button() in self._buttons:
+ self._buttons.remove(event.button())
+ if not self._buttons:
+ self._request_final_render()
+ event.accept()
+
+ def mouseMoveEvent(self, event):
+ if self._help_overlay.isVisible():
+ event.accept()
+ return
+ if self._last_pos is None:
+ self._last_pos = event.position()
+ return
+
+ dx = event.position().x() - self._last_pos.x()
+ dy = event.position().y() - self._last_pos.y()
+
+ if self._light_control_mode:
+ if QtCore.Qt.LeftButton in self._buttons:
+ self._light_azimuth += dx * 0.005
+ self._light_elevation += dy * 0.005
+ max_e = np.radians(89.0)
+ self._light_elevation = max(-max_e, min(max_e, self._light_elevation))
+ self._request_interactive_render()
+ elif QtCore.Qt.RightButton in self._buttons:
+ self._light_distance *= 1.0 + (dy * 0.01)
+ if self._light_distance < 1.0:
+ self._light_distance = 1.0
+ self._request_interactive_render()
+ else:
+ if QtCore.Qt.LeftButton in self._buttons:
+ self._camera.orbit(dx, dy)
+ self._request_interactive_render()
+ elif QtCore.Qt.RightButton in self._buttons:
+ self._camera.pan(dx, dy, self.height())
+ self._request_interactive_render()
+
+ self._last_pos = event.position()
+
+ def wheelEvent(self, event):
+ if self._help_overlay.isVisible():
+ event.accept()
+ return
+ delta = event.angleDelta().y() / 120.0
+ if self._light_control_mode and not self._light_follows_camera:
+ self._light_distance *= 1.0 - delta * 0.1
+ if self._light_distance < 1.0:
+ self._light_distance = 1.0
+ self._request_interactive_render()
+ else:
+ self._camera.zoom(delta)
+ self._request_interactive_render()
+
+ def keyPressEvent(self, event):
+ key = event.key()
+ if event.matches(QtGui.QKeySequence.Save):
+ self.save_screenshot()
+ event.accept()
+ return
+ if key == QtCore.Qt.Key_F1 or (
+ key == QtCore.Qt.Key_Slash and event.modifiers() & QtCore.Qt.ShiftModifier
+ ):
+ self.toggle_help_overlay()
+ event.accept()
+ return
+ if key == QtCore.Qt.Key_Escape and self._help_overlay.isVisible():
+ self.toggle_help_overlay()
+ event.accept()
+ return
+ super().keyPressEvent(event)
+
+ def closeEvent(self, event):
+ if self._texture is not None:
+ glDeleteTextures(1, [self._texture])
+ self._texture = None
+ super().closeEvent(event)
diff --git a/openmc_plotter/renderer_core/openmc_plotter.py b/openmc_plotter/renderer_core/openmc_plotter.py
new file mode 100644
index 0000000..3651b12
--- /dev/null
+++ b/openmc_plotter/renderer_core/openmc_plotter.py
@@ -0,0 +1,142 @@
+import numpy as np
+
+OPENMC_IMPORT_ERROR = None
+try:
+ import openmc.lib as omlib
+ from openmc.lib import plot as plotlib
+ OPENMC_AVAILABLE = True
+except Exception as exc: # pragma: no cover - fallback for missing openmc/lib
+ OPENMC_AVAILABLE = False
+ OPENMC_IMPORT_ERROR = exc
+ omlib = None
+ plotlib = None
+
+
+class OpenMCPlotter:
+ COLOR_BY_MATERIAL = plotlib.SolidRayTracePlot.COLOR_BY_MATERIAL if OPENMC_AVAILABLE else 0
+ COLOR_BY_CELL = plotlib.SolidRayTracePlot.COLOR_BY_CELL if OPENMC_AVAILABLE else 1
+
+ def __init__(self, args=None, width=800, height=600):
+ self._width = int(width)
+ self._height = int(height)
+ self._available = OPENMC_AVAILABLE
+ self._plot = None
+
+ if self._available:
+ if not omlib.is_initialized:
+ omlib.init(args=args or [], output=True)
+ self._plot = plotlib.SolidRayTracePlot()
+ self._plot.set_color_by(self.COLOR_BY_MATERIAL)
+ self._plot.set_pixels(self._width, self._height)
+ self._plot.set_default_colors()
+ self._plot.set_all_opaque()
+
+ # Default camera
+ self.set_camera(
+ position=(10.0, 10.0, 10.0),
+ look_at=(0.0, 0.0, 0.0),
+ up=(0.0, 0.0, 1.0),
+ fov=45.0,
+ light_position=(10.0, 10.0, 10.0),
+ )
+
+ @property
+ def available(self):
+ return self._available
+
+ @property
+ def import_error(self):
+ return OPENMC_IMPORT_ERROR
+
+ def set_pixels(self, width, height):
+ self._width = int(width)
+ self._height = int(height)
+ if self._plot is not None:
+ self._plot.set_pixels(self._width, self._height)
+
+ def set_camera(self, position, look_at, up, fov, light_position=None):
+ if self._plot is None:
+ return
+ self._plot.set_camera_position(*position)
+ self._plot.set_look_at(*look_at)
+ self._plot.set_up(*up)
+ self._plot.set_fov(float(fov))
+ if light_position is None:
+ light_position = position
+ self._plot.set_light_position(*light_position)
+
+ def material_list(self):
+ if not self._available:
+ return []
+ mats = []
+ for mat_id in omlib.materials:
+ mat = omlib.materials[mat_id]
+ name = mat.name
+ label = name if name else ""
+ mats.append((mat_id, label))
+ mats.sort(key=lambda item: item[0])
+ return mats
+
+ def cell_list(self):
+ if not self._available:
+ return []
+ cells = []
+ for cell_id in omlib.cells:
+ cell = omlib.cells[cell_id]
+ name = cell.name
+ label = name if name else ""
+ cells.append((cell_id, label))
+ cells.sort(key=lambda item: item[0])
+ return cells
+
+ def set_color_by(self, mode):
+ if self._plot is None:
+ return
+ self._plot.set_color_by(int(mode))
+ self._plot.set_default_colors()
+ self._plot.set_all_opaque()
+
+ def set_visibility(self, domain_id, visible):
+ if self._plot is None:
+ return
+ self._plot.set_visibility(int(domain_id), bool(visible))
+
+ def get_color(self, domain_id):
+ if self._plot is None:
+ return (128, 128, 128)
+ return self._plot.get_color(int(domain_id))
+
+ def set_color(self, domain_id, color):
+ if self._plot is None:
+ return
+ self._plot.set_color(int(domain_id), color)
+
+ def set_material_visibility(self, material_id, visible):
+ self.set_visibility(material_id, visible)
+
+ def set_diffuse_fraction(self, value):
+ if self._plot is None:
+ return
+ self._plot.set_diffuse_fraction(float(value))
+
+ def create_image(self):
+ if self._plot is None:
+ return self._fallback_image()
+ self._plot.update_view()
+ return self._plot.create_image()
+
+ def finalize(self):
+ if self._available and omlib.is_initialized:
+ omlib.finalize()
+
+ def _fallback_image(self):
+ # Simple checkerboard to confirm rendering path when OpenMC isn't available.
+ tile = 32
+ h, w = self._height, self._width
+ y = np.arange(h)[:, None]
+ x = np.arange(w)[None, :]
+ checker = ((x // tile) + (y // tile)) % 2
+ img = np.zeros((h, w, 3), dtype=np.uint8)
+ img[checker == 0] = (40, 40, 40)
+ img[checker == 1] = (80, 80, 80)
+ return img
diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py
new file mode 100644
index 0000000..9bdfcc7
--- /dev/null
+++ b/openmc_plotter/renderer_widget.py
@@ -0,0 +1,538 @@
+from PySide6 import QtCore, QtGui
+from PySide6.QtWidgets import (QCheckBox, QComboBox, QColorDialog, QFrame,
+ QGridLayout, QGroupBox, QHBoxLayout, QLabel,
+ QPushButton, QScrollArea, QSlider, QSplitter,
+ QStyle, QVBoxLayout, QWidget)
+
+
+class RendererWidget(QWidget):
+ """Embedded OpenMC renderer with controls panel."""
+
+ def __init__(
+ self,
+ plotter,
+ gl_widget_cls,
+ material_domains=None,
+ cell_domains=None,
+ initial_color_mode="material",
+ on_color_changed=None,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self.plotter = plotter
+ self.gl_widget = gl_widget_cls(plotter, self)
+ self._material_mode = self.plotter.COLOR_BY_MATERIAL
+ self._cell_mode = self.plotter.COLOR_BY_CELL
+ self._on_color_changed = on_color_changed
+
+ self._domain_data = {}
+ self._color_maps = {}
+ self._visibility_maps = {}
+ self._setDomainData(material_domains, cell_domains)
+
+ mode_value = self._resolveModeValue(initial_color_mode)
+ self._initial_mode = self._material_mode if mode_value is None else mode_value
+
+ self._buildUi()
+ self._connectSignals()
+ self._initializeState()
+ self._startCameraInfoUpdates()
+
+ def _buildUi(self):
+ self.mainLayout = QHBoxLayout(self)
+ self.mainLayout.setContentsMargins(0, 0, 0, 0)
+
+ self.splitter = QSplitter(QtCore.Qt.Horizontal, self)
+ self.mainLayout.addWidget(self.splitter)
+
+ viewerWidget = QWidget(self.splitter)
+ viewerLayout = QVBoxLayout(viewerWidget)
+ viewerLayout.setContentsMargins(0, 0, 0, 0)
+
+ toolbarLayout = QHBoxLayout()
+ self.controlsButton = QPushButton("", viewerWidget)
+ self.controlsButton.setIcon(
+ self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion)
+ )
+ self.controlsButton.setToolTip("Show renderer controls")
+ self.saveButton = QPushButton("", viewerWidget)
+ self.saveButton.setIcon(
+ self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
+ )
+ self.saveButton.setToolTip("Save PNG")
+ toolbarLayout.addWidget(self.controlsButton)
+ toolbarLayout.addWidget(self.saveButton)
+ toolbarLayout.addStretch()
+
+ viewerLayout.addLayout(toolbarLayout)
+ viewerLayout.addWidget(self.gl_widget)
+
+ self.controlsWidget = QWidget(self.splitter)
+ self.controlsWidget.setMinimumWidth(320)
+ controlsLayout = QVBoxLayout(self.controlsWidget)
+ controlsLayout.setContentsMargins(8, 8, 8, 8)
+
+ modeLayout = QHBoxLayout()
+ modeLayout.addWidget(QLabel("Color by:", self.controlsWidget))
+ self.modeCombo = QComboBox(self.controlsWidget)
+ self.modeCombo.addItem("Material", self._material_mode)
+ self.modeCombo.addItem("Cell", self._cell_mode)
+ modeLayout.addWidget(self.modeCombo, 1)
+ controlsLayout.addLayout(modeLayout)
+
+ cameraGroup = QGroupBox("Camera", self.controlsWidget)
+ cameraLayout = QVBoxLayout(cameraGroup)
+
+ cameraInfoLayout = QGridLayout()
+ cameraInfoLayout.addWidget(QLabel("Position:", cameraGroup), 0, 0)
+ cameraInfoLayout.addWidget(QLabel("Look At:", cameraGroup), 1, 0)
+ cameraInfoLayout.addWidget(QLabel("Up:", cameraGroup), 2, 0)
+
+ self.cameraPositionValue = QLabel("", cameraGroup)
+ self.cameraLookAtValue = QLabel("", cameraGroup)
+ self.cameraUpValue = QLabel("", cameraGroup)
+
+ for value_label in (self.cameraPositionValue,
+ self.cameraLookAtValue,
+ self.cameraUpValue):
+ value_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
+
+ cameraInfoLayout.addWidget(self.cameraPositionValue, 0, 1)
+ cameraInfoLayout.addWidget(self.cameraLookAtValue, 1, 1)
+ cameraInfoLayout.addWidget(self.cameraUpValue, 2, 1)
+ cameraLayout.addLayout(cameraInfoLayout)
+
+ presetLayout = QGridLayout()
+ self.isoButton = QPushButton("Iso", cameraGroup)
+ self.xPosButton = QPushButton("+X", cameraGroup)
+ self.xNegButton = QPushButton("-X", cameraGroup)
+ self.yPosButton = QPushButton("+Y", cameraGroup)
+ self.yNegButton = QPushButton("-Y", cameraGroup)
+ self.zPosButton = QPushButton("+Z", cameraGroup)
+ self.zNegButton = QPushButton("-Z", cameraGroup)
+
+ presetLayout.addWidget(self.isoButton, 0, 0, 1, 2)
+ presetLayout.addWidget(self.xPosButton, 1, 0)
+ presetLayout.addWidget(self.xNegButton, 1, 1)
+ presetLayout.addWidget(self.yPosButton, 2, 0)
+ presetLayout.addWidget(self.yNegButton, 2, 1)
+ presetLayout.addWidget(self.zPosButton, 3, 0)
+ presetLayout.addWidget(self.zNegButton, 3, 1)
+ cameraLayout.addLayout(presetLayout)
+
+ sensitivityDivider = QFrame(cameraGroup)
+ sensitivityDivider.setFrameShape(QFrame.HLine)
+ sensitivityDivider.setFrameShadow(QFrame.Sunken)
+ cameraLayout.addWidget(sensitivityDivider)
+
+ sensitivityLabel = QLabel("Camera Sensitivity", cameraGroup)
+ cameraLayout.addWidget(sensitivityLabel)
+
+ self.rotateSlider = self._makeScaledSlider(cameraLayout, "Rotate", 0.001, 0.02, 0.005)
+ self.panSlider = self._makeScaledSlider(cameraLayout, "Pan", 0.2, 5.0, 1.0)
+ self.zoomSlider = self._makeScaledSlider(cameraLayout, "Zoom", 0.02, 0.5, 0.1)
+ controlsLayout.addWidget(cameraGroup)
+
+ lightGroup = QGroupBox("Lighting", self.controlsWidget)
+ lightLayout = QVBoxLayout(lightGroup)
+
+ self.lightFollowCheckbox = QCheckBox("Light follows camera", lightGroup)
+ self.lightFollowCheckbox.setChecked(True)
+ lightLayout.addWidget(self.lightFollowCheckbox)
+
+ self.lightControlCheckbox = QCheckBox("Light control mode", lightGroup)
+ lightLayout.addWidget(self.lightControlCheckbox)
+
+ diffuseLayout = QHBoxLayout()
+ diffuseLayout.addWidget(QLabel("Diffuse", lightGroup))
+ self.diffuseSlider = QSlider(QtCore.Qt.Horizontal, lightGroup)
+ self.diffuseSlider.setRange(0, 100)
+ self.diffuseSlider.setValue(10)
+ self.diffuseValueLabel = QLabel("0.10", lightGroup)
+ diffuseLayout.addWidget(self.diffuseSlider, 1)
+ diffuseLayout.addWidget(self.diffuseValueLabel)
+ lightLayout.addLayout(diffuseLayout)
+
+ controlsLayout.addWidget(lightGroup)
+
+ self.scrollArea = QScrollArea(self.controlsWidget)
+ self.scrollArea.setWidgetResizable(True)
+ self.scrollContainer = QWidget(self.scrollArea)
+ self.visibilityLayout = QVBoxLayout(self.scrollContainer)
+ self.visibilityLayout.setAlignment(QtCore.Qt.AlignTop)
+ self.scrollContainer.setLayout(self.visibilityLayout)
+ self.scrollArea.setWidget(self.scrollContainer)
+ controlsLayout.addWidget(self.scrollArea, 1)
+
+ self.splitter.addWidget(viewerWidget)
+ self.splitter.addWidget(self.controlsWidget)
+ self.splitter.setStretchFactor(0, 1)
+ self.splitter.setStretchFactor(1, 0)
+ self.splitter.setSizes([900, 320])
+
+ def _connectSignals(self):
+ self.saveButton.clicked.connect(self.gl_widget.save_screenshot)
+ self.controlsButton.clicked.connect(self.gl_widget.toggle_help_overlay)
+
+ self.modeCombo.currentIndexChanged.connect(self._onColorModeChange)
+ self._cellShortcut = QtGui.QShortcut(QtGui.QKeySequence("Alt+C"), self)
+ self._cellShortcut.setContext(QtCore.Qt.WidgetWithChildrenShortcut)
+ self._cellShortcut.activated.connect(
+ lambda: self._setColorMode(self._cell_mode)
+ )
+ self._materialShortcut = QtGui.QShortcut(QtGui.QKeySequence("Alt+M"), self)
+ self._materialShortcut.setContext(QtCore.Qt.WidgetWithChildrenShortcut)
+ self._materialShortcut.activated.connect(
+ lambda: self._setColorMode(self._material_mode)
+ )
+
+ self.isoButton.clicked.connect(self.gl_widget.set_isometric_view)
+ self.xPosButton.clicked.connect(lambda: self.gl_widget.set_axis_view("x", negative=False))
+ self.xNegButton.clicked.connect(lambda: self.gl_widget.set_axis_view("x", negative=True))
+ self.yPosButton.clicked.connect(lambda: self.gl_widget.set_axis_view("y", negative=False))
+ self.yNegButton.clicked.connect(lambda: self.gl_widget.set_axis_view("y", negative=True))
+ self.zPosButton.clicked.connect(lambda: self.gl_widget.set_axis_view("z", negative=False))
+ self.zNegButton.clicked.connect(lambda: self.gl_widget.set_axis_view("z", negative=True))
+
+ self.rotateSlider.valueChanged.connect(self._updateCameraSpeeds)
+ self.panSlider.valueChanged.connect(self._updateCameraSpeeds)
+ self.zoomSlider.valueChanged.connect(self._updateCameraSpeeds)
+
+ self.lightFollowCheckbox.toggled.connect(self._onLightFollowToggle)
+ self.lightControlCheckbox.toggled.connect(self._onLightControlToggle)
+ self.diffuseSlider.valueChanged.connect(self._onDiffuseChange)
+
+ def _initializeState(self):
+ if self.plotter.available:
+ self.plotter.set_diffuse_fraction(0.1)
+ initial_idx = 0 if self._initial_mode == self._material_mode else 1
+ self.modeCombo.blockSignals(True)
+ self.modeCombo.setCurrentIndex(initial_idx)
+ self.modeCombo.blockSignals(False)
+ self._refreshCurrentMode(self.modeCombo.itemData(initial_idx), request_render=True)
+ else:
+ self.visibilityLayout.addWidget(QLabel("OpenMC not available.", self.scrollContainer))
+
+ self._updateCameraSpeeds()
+ self._refreshCameraInfo()
+
+ def _startCameraInfoUpdates(self):
+ self._cameraInfoTimer = QtCore.QTimer(self)
+ self._cameraInfoTimer.setInterval(100)
+ self._cameraInfoTimer.timeout.connect(self._refreshCameraInfo)
+ self._cameraInfoTimer.start()
+
+ def _formatVector(self, vec):
+ return f"({vec[0]:.3f}, {vec[1]:.3f}, {vec[2]:.3f})"
+
+ def _refreshCameraInfo(self):
+ camera = getattr(self.gl_widget, "_camera", None)
+ if camera is None:
+ return
+
+ position = camera.position()
+ look_at = camera.target
+ _, _, up = camera.view_vectors()
+
+ self.cameraPositionValue.setText(self._formatVector(position))
+ self.cameraLookAtValue.setText(self._formatVector(look_at))
+ self.cameraUpValue.setText(self._formatVector(up))
+
+ def _makeScaledSlider(self, parent_layout, label_text, min_value, max_value, default_value):
+ layout = QHBoxLayout()
+ label = QLabel(label_text, self.controlsWidget)
+ slider = QSlider(QtCore.Qt.Horizontal, self.controlsWidget)
+ slider.setRange(0, 100)
+ slider.setValue(self._sliderFromScale(default_value, min_value, max_value))
+
+ layout.addWidget(label)
+ layout.addWidget(slider, 1)
+ parent_layout.addLayout(layout)
+ return slider
+
+ def _sliderFromScale(self, value, min_value, max_value):
+ if max_value <= min_value:
+ return 0
+ return int(round((value - min_value) / (max_value - min_value) * 100))
+
+ def _scaleFromSlider(self, value, min_value, max_value):
+ return min_value + (max_value - min_value) * (value / 100.0)
+
+ def _normalizeRgb(self, color):
+ if color is None:
+ return None
+
+ try:
+ rgb = tuple(int(component) for component in color)
+ except (TypeError, ValueError):
+ return None
+
+ if len(rgb) != 3:
+ return None
+
+ return tuple(max(0, min(255, component)) for component in rgb)
+
+ def _normalizeDomainMap(self, domains):
+ normalized = {}
+ if not domains:
+ return normalized
+
+ for domain_id, payload in domains.items():
+ try:
+ did = int(domain_id)
+ except (TypeError, ValueError):
+ continue
+
+ name = ""
+ color = None
+
+ if isinstance(payload, dict):
+ name = payload.get("name") or ""
+ color = payload.get("color")
+
+ normalized[did] = {
+ "name": str(name),
+ "color": self._normalizeRgb(color),
+ }
+
+ return normalized
+
+ def _setDomainData(self, material_domains, cell_domains):
+ self._domain_data = {
+ self._material_mode: self._normalizeDomainMap(material_domains),
+ self._cell_mode: self._normalizeDomainMap(cell_domains),
+ }
+
+ previous_visibility = self._visibility_maps
+ self._visibility_maps = {
+ self._material_mode: {
+ domain_id: previous_visibility.get(self._material_mode, {}).get(domain_id, True)
+ for domain_id in self._domain_data[self._material_mode]
+ },
+ self._cell_mode: {
+ domain_id: previous_visibility.get(self._cell_mode, {}).get(domain_id, True)
+ for domain_id in self._domain_data[self._cell_mode]
+ },
+ }
+
+ self._color_maps = {
+ self._material_mode: {
+ domain_id: entry["color"]
+ for domain_id, entry in self._domain_data[self._material_mode].items()
+ if entry["color"] is not None
+ },
+ self._cell_mode: {
+ domain_id: entry["color"]
+ for domain_id, entry in self._domain_data[self._cell_mode].items()
+ if entry["color"] is not None
+ },
+ }
+
+ def _resolveModeValue(self, color_mode):
+ if color_mode in (self._material_mode, self._cell_mode):
+ return color_mode
+
+ if isinstance(color_mode, str):
+ mode_name = color_mode.strip().lower()
+ if mode_name == "material":
+ return self._material_mode
+ if mode_name == "cell":
+ return self._cell_mode
+
+ return None
+
+ def _setColorMode(self, mode):
+ index = 0 if mode == self._material_mode else 1
+ self.modeCombo.setCurrentIndex(index)
+
+ def _modeValueToName(self, mode):
+ return "cell" if mode == self._cell_mode else "material"
+
+ def syncDomainData(self, material_domains=None, cell_domains=None, color_mode=None):
+ self._setDomainData(material_domains, cell_domains)
+
+ if not self.plotter.available:
+ return
+
+ mode_value = self._resolveModeValue(color_mode)
+ if mode_value is not None:
+ index = 0 if mode_value == self._material_mode else 1
+ self.modeCombo.blockSignals(True)
+ self.modeCombo.setCurrentIndex(index)
+ self.modeCombo.blockSignals(False)
+
+ current_mode = self.modeCombo.currentData()
+ self._refreshCurrentMode(current_mode, request_render=True)
+
+ def _clearLayout(self, layout):
+ while layout.count():
+ item = layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.deleteLater()
+
+ def _domainItemsForMode(self, mode):
+ if mode == self._cell_mode:
+ base_items = self.plotter.cell_list() if self.plotter.available else []
+ else:
+ base_items = self.plotter.material_list() if self.plotter.available else []
+
+ domain_data = self._domain_data.get(mode, {})
+
+ items = []
+ used_ids = set()
+ for domain_id, fallback_name in base_items:
+ did = int(domain_id)
+ info = domain_data.get(did, {})
+ name = info.get("name") or fallback_name or ""
+ items.append((did, name))
+ used_ids.add(did)
+
+ for did in sorted(domain_data):
+ if did in used_ids:
+ continue
+ items.append((did, domain_data[did].get("name") or ""))
+
+ return items
+
+ def _populateVisibilityList(self, items):
+ self._clearLayout(self.visibilityLayout)
+ mode = self.modeCombo.currentData()
+ self._addVisibilityHeader()
+
+ for domain_id, name in items:
+ row = QWidget(self.scrollContainer)
+ rowLayout = QHBoxLayout(row)
+ rowLayout.setContentsMargins(0, 0, 0, 0)
+
+ color_button = QPushButton(row)
+ color_button.setFixedSize(20, 20)
+ color = self.plotter.get_color(domain_id)
+ self._setColorButtonStyle(color_button, color)
+
+ label_text = self._formatDomainLabel(mode, domain_id, name)
+ label = QLabel(label_text, row)
+ label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
+
+ checkbox = QCheckBox(row)
+ visible = self._visibility_maps.setdefault(mode, {}).setdefault(domain_id, True)
+ checkbox.setChecked(visible)
+
+ checkbox.toggled.connect(
+ lambda checked, did=domain_id: self._onVisibilityToggle(did, checked)
+ )
+ color_button.clicked.connect(
+ lambda _=False, did=domain_id, button=color_button: self._onColorPick(did, button)
+ )
+
+ rowLayout.addWidget(color_button)
+ rowLayout.addWidget(label, 1)
+ rowLayout.addWidget(checkbox)
+ self.visibilityLayout.addWidget(row)
+
+ def _addVisibilityHeader(self):
+ header_row = QWidget(self.scrollContainer)
+ header_layout = QHBoxLayout(header_row)
+ header_layout.setContentsMargins(0, 0, 0, 2)
+
+ empty_label = QLabel("", header_row)
+ visibility_label = QLabel("Visibility", header_row)
+ visibility_label.setAlignment(QtCore.Qt.AlignCenter)
+
+ header_layout.addWidget(empty_label, 1)
+ header_layout.addWidget(visibility_label)
+ self.visibilityLayout.addWidget(header_row)
+
+ def _formatDomainLabel(self, mode, domain_id, name):
+ domain_kind = "Cell" if mode == self._cell_mode else "Material"
+ if name:
+ return f'{domain_kind} {domain_id}: "{name}"'
+ return f"{domain_kind} {domain_id}"
+
+ def _setColorButtonStyle(self, button, rgb):
+ r, g, b = rgb
+ button.setStyleSheet(
+ f"background-color: rgb({r}, {g}, {b}); border: 1px solid #555;"
+ )
+
+ def _onVisibilityToggle(self, domain_id, checked):
+ mode = self.modeCombo.currentData()
+ self._visibility_maps.setdefault(mode, {})[int(domain_id)] = bool(checked)
+ self.plotter.set_visibility(domain_id, checked)
+ self.gl_widget.request_final_render()
+
+ def _onColorPick(self, domain_id, button):
+ current = self.plotter.get_color(domain_id)
+ initial = QtGui.QColor(*current)
+ color = QColorDialog.getColor(initial, button, "Select Color")
+ if not color.isValid():
+ return
+
+ rgb = (color.red(), color.green(), color.blue())
+ mode = self.modeCombo.currentData()
+
+ self.plotter.set_color(domain_id, rgb)
+ self._color_maps.setdefault(mode, {})[domain_id] = rgb
+ mode_domain_data = self._domain_data.setdefault(mode, {})
+ entry = mode_domain_data.setdefault(domain_id, {"name": "", "color": None})
+ entry["color"] = rgb
+
+ self._setColorButtonStyle(button, rgb)
+ self.gl_widget.request_final_render()
+
+ if self._on_color_changed is not None:
+ self._on_color_changed(self._modeValueToName(mode), int(domain_id), rgb)
+
+ def _applyMappedColors(self, mode):
+ for domain_id, rgb in self._color_maps.get(mode, {}).items():
+ try:
+ self.plotter.set_color(domain_id, rgb)
+ except Exception:
+ continue
+
+ def _applyMappedVisibility(self, mode):
+ for domain_id, visible in self._visibility_maps.get(mode, {}).items():
+ try:
+ self.plotter.set_visibility(domain_id, visible)
+ except Exception:
+ continue
+
+ def _refreshCurrentMode(self, mode, request_render):
+ self.plotter.set_color_by(mode)
+ self._applyMappedColors(mode)
+ self._applyMappedVisibility(mode)
+ self._populateVisibilityList(self._domainItemsForMode(mode))
+ if request_render:
+ self.gl_widget.request_final_render()
+
+ def _onColorModeChange(self, index):
+ mode = self.modeCombo.itemData(index)
+ self._refreshCurrentMode(mode, request_render=True)
+
+ def _updateCameraSpeeds(self):
+ rotate = self._scaleFromSlider(self.rotateSlider.value(), 0.001, 0.02)
+ pan = self._scaleFromSlider(self.panSlider.value(), 0.2, 5.0)
+ zoom = self._scaleFromSlider(self.zoomSlider.value(), 0.02, 0.5)
+ self.gl_widget.set_camera_speeds(rotate=rotate, pan=pan, zoom=zoom)
+
+ def _onLightFollowToggle(self, checked):
+ if checked and self.lightControlCheckbox.isChecked():
+ self.lightControlCheckbox.blockSignals(True)
+ self.lightControlCheckbox.setChecked(False)
+ self.lightControlCheckbox.blockSignals(False)
+ self.gl_widget.set_light_control_mode(False)
+ self.gl_widget.set_light_follows_camera(checked)
+
+ def _onLightControlToggle(self, checked):
+ if checked and self.lightFollowCheckbox.isChecked():
+ self.lightFollowCheckbox.blockSignals(True)
+ self.lightFollowCheckbox.setChecked(False)
+ self.lightFollowCheckbox.blockSignals(False)
+ self.gl_widget.set_light_follows_camera(False)
+ self.gl_widget.set_light_control_mode(checked)
+
+ def _onDiffuseChange(self, value):
+ diffuse = value / 100.0
+ self.diffuseValueLabel.setText(f"{diffuse:.2f}")
+ self.plotter.set_diffuse_fraction(diffuse)
+ self.gl_widget.request_final_render()