From f9e833ed384317e68f22a5e675e93bd3ee5bceaa Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 09:14:50 -0600 Subject: [PATCH 01/15] Integrating renderer plugin --- openmc_plotter/docks.py | 6 + openmc_plotter/main_window.py | 101 ++++++++++- openmc_plotter/renderer_widget.py | 275 ++++++++++++++++++++++++++++++ 3 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 openmc_plotter/renderer_widget.py diff --git a/openmc_plotter/docks.py b/openmc_plotter/docks.py index 36b7ba2..213636f 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) @@ -180,6 +185,7 @@ def __init__(self, model, font_metric, main_window, parent=None): self.panelLayout.addWidget(HorizontalLine()) self.panelLayout.addWidget(self.zoomWidget) self.panelLayout.addStretch() + self.panelLayout.addWidget(self.renderButton) self.setLayout(self.panelLayout) diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index 2ae8e20..2a1eb7f 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -1,7 +1,10 @@ import copy from functools import partial +import importlib.util +import os from pathlib import Path import pickle +import sys from threading import Thread from PySide6 import QtCore, QtGui @@ -9,7 +12,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 +27,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 +45,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 +71,9 @@ def __init__(self, self.default_res = resolution self.model = None self.plot_manager = None + self._render_dialog = None + self._render_plotter = None + self._renderer_classes = None def loadGui(self, use_settings_pkl=True): @@ -819,6 +835,89 @@ def showColorDialog(self): self.colorDialog.raise_() self.colorDialog.activateWindow() + def showRendererDialog(self): + if self._render_dialog is not None: + self._render_dialog.raise_() + self._render_dialog.activateWindow() + 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) + 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, dialog) + layout.addWidget(renderer_widget) + + dialog.finished.connect(self._rendererDialogClosed) + dialog.show() + + self._render_dialog = dialog + self._render_plotter = plotter + + 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 + + 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 openmc_renderer/Python. " + "Set OPENMC_RENDERER_PATH to the renderer repository root." + ) + + 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): + env_path = os.environ.get("OPENMC_RENDERER_PATH") + if env_path: + candidate = Path(env_path) / "Python" + if candidate.is_dir(): + return candidate + + for parent in Path(__file__).resolve().parents: + candidate = parent / "openmc_renderer" / "Python" + if candidate.is_dir(): + return candidate + + return None + def showExportDialog(self): self.exportDataDialog.show() self.exportDataDialog.raise_() diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py new file mode 100644 index 0000000..d84cf52 --- /dev/null +++ b/openmc_plotter/renderer_widget.py @@ -0,0 +1,275 @@ +from PySide6 import QtCore, QtGui +from PySide6.QtWidgets import (QCheckBox, QComboBox, QColorDialog, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QSlider, QSplitter, QVBoxLayout, + QWidget) + + +class RendererWidget(QWidget): + """Embedded OpenMC renderer with controls panel.""" + + def __init__(self, plotter, gl_widget_cls, parent=None): + super().__init__(parent) + self.plotter = plotter + self.gl_widget = gl_widget_cls(plotter, self) + + self._buildUi() + self._connectSignals() + self._initializeState() + + 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.saveButton = QPushButton("Save PNG", viewerWidget) + self.controlsButton = QPushButton("Controls", viewerWidget) + toolbarLayout.addWidget(self.saveButton) + toolbarLayout.addWidget(self.controlsButton) + 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.plotter.COLOR_BY_MATERIAL) + self.modeCombo.addItem("Cell", self.plotter.COLOR_BY_CELL) + modeLayout.addWidget(self.modeCombo, 1) + controlsLayout.addLayout(modeLayout) + + cameraGroup = QGroupBox("Camera", self.controlsWidget) + cameraLayout = QVBoxLayout(cameraGroup) + + 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) + + 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.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) + self._populateVisibilityList(self.plotter.material_list()) + else: + self.visibilityLayout.addWidget(QLabel("OpenMC not available.", self.scrollContainer)) + + self._updateCameraSpeeds() + + 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)) + value_label = QLabel(self.controlsWidget) + + def _updateValue(value): + scaled = self._scaleFromSlider(value, min_value, max_value) + value_label.setText(f"{scaled:.3f}") + + slider.valueChanged.connect(_updateValue) + _updateValue(slider.value()) + + layout.addWidget(label) + layout.addWidget(slider, 1) + layout.addWidget(value_label) + 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 _clearLayout(self, layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _populateVisibilityList(self, items): + self._clearLayout(self.visibilityLayout) + + 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 = f"{domain_id}" + if name: + label_text = f"{domain_id} - {name}" + label = QLabel(label_text, row) + label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) + + checkbox = QCheckBox(row) + checkbox.setChecked(True) + + 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 _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): + 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()) + self.plotter.set_color(domain_id, rgb) + self._setColorButtonStyle(button, rgb) + self.gl_widget.request_final_render() + + def _onColorModeChange(self, index): + mode = self.modeCombo.itemData(index) + self.plotter.set_color_by(mode) + if mode == self.plotter.COLOR_BY_CELL: + items = self.plotter.cell_list() + else: + items = self.plotter.material_list() + self._populateVisibilityList(items) + self.gl_widget.request_final_render() + + 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() From 6ef43a1153d99a5b9c39229853f2d45811d3b5b9 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 12:43:49 -0600 Subject: [PATCH 02/15] Use colors from the slice plot for rendering --- openmc_plotter/main_window.py | 53 ++++++++++++++++++++++++++++++- openmc_plotter/renderer_widget.py | 43 ++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index 2a1eb7f..a38f94e 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -849,13 +849,26 @@ def showRendererDialog(self): openmc_args.append(str(self.model_path)) plotter = OpenMCPlotter(args=openmc_args) + material_colors, cell_colors = self._getRendererDomainColors() + if self.model.currentView.colorby == "cell": + initial_color_mode = plotter.COLOR_BY_CELL + else: + initial_color_mode = plotter.COLOR_BY_MATERIAL + 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, dialog) + renderer_widget = RendererWidget( + plotter, + GLPlotWidget, + material_colors=material_colors, + cell_colors=cell_colors, + initial_color_mode=initial_color_mode, + parent=dialog, + ) layout.addWidget(renderer_widget) dialog.finished.connect(self._rendererDialogClosed) @@ -918,6 +931,44 @@ def _findRendererPythonDir(self): return None + def _getRendererDomainColors(self): + view = self.model.currentView + return (self._extractDomainColors(view.materials), + self._extractDomainColors(view.cells)) + + def _extractDomainColors(self, domains): + color_map = {} + for domain_id in domains.defaults: + if int(domain_id) < 0: + continue + + domain = domains[domain_id] + rgb = self._normalizeRendererColor(domain.color) + if rgb is None: + continue + color_map[int(domain_id)] = rgb + return color_map + + 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 showExportDialog(self): self.exportDataDialog.show() self.exportDataDialog.raise_() diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py index d84cf52..35f848e 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -8,10 +8,28 @@ class RendererWidget(QWidget): """Embedded OpenMC renderer with controls panel.""" - def __init__(self, plotter, gl_widget_cls, parent=None): + def __init__( + self, + plotter, + gl_widget_cls, + material_colors=None, + cell_colors=None, + initial_color_mode=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._color_maps = { + self._material_mode: dict(material_colors or {}), + self._cell_mode: dict(cell_colors or {}), + } + if initial_color_mode in (self._material_mode, self._cell_mode): + self._initial_color_mode = initial_color_mode + else: + self._initial_color_mode = self._material_mode self._buildUi() self._connectSignals() @@ -29,10 +47,11 @@ def _buildUi(self): viewerLayout.setContentsMargins(0, 0, 0, 0) toolbarLayout = QHBoxLayout() + self.controlsButton = QPushButton("What's this?", viewerWidget) + self.controlsButton.setToolTip("Show renderer controls") self.saveButton = QPushButton("Save PNG", viewerWidget) - self.controlsButton = QPushButton("Controls", viewerWidget) - toolbarLayout.addWidget(self.saveButton) toolbarLayout.addWidget(self.controlsButton) + toolbarLayout.addWidget(self.saveButton) toolbarLayout.addStretch() viewerLayout.addLayout(toolbarLayout) @@ -139,7 +158,11 @@ def _connectSignals(self): def _initializeState(self): if self.plotter.available: self.plotter.set_diffuse_fraction(0.1) - self._populateVisibilityList(self.plotter.material_list()) + initial_idx = 0 if self._initial_color_mode == self._material_mode else 1 + self.modeCombo.blockSignals(True) + self.modeCombo.setCurrentIndex(initial_idx) + self.modeCombo.blockSignals(False) + self._onColorModeChange(initial_idx) else: self.visibilityLayout.addWidget(QLabel("OpenMC not available.", self.scrollContainer)) @@ -233,13 +256,23 @@ def _onColorPick(self, domain_id, button): return rgb = (color.red(), color.green(), color.blue()) self.plotter.set_color(domain_id, rgb) + mode = self.modeCombo.currentData() + self._color_maps.setdefault(mode, {})[domain_id] = rgb self._setColorButtonStyle(button, rgb) self.gl_widget.request_final_render() + 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 _onColorModeChange(self, index): mode = self.modeCombo.itemData(index) self.plotter.set_color_by(mode) - if mode == self.plotter.COLOR_BY_CELL: + self._applyMappedColors(mode) + if mode == self._cell_mode: items = self.plotter.cell_list() else: items = self.plotter.material_list() From 907709ce5b0bb50b44a8e75cfcdbe6584c54e12c Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 14:16:58 -0600 Subject: [PATCH 03/15] Tying renderer colors into the plot model --- openmc_plotter/main_window.py | 98 +++++++++++++---- openmc_plotter/renderer_widget.py | 172 +++++++++++++++++++++++++----- 2 files changed, 226 insertions(+), 44 deletions(-) diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index a38f94e..cb8c033 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -73,6 +73,7 @@ def __init__(self, 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): @@ -512,6 +513,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} @@ -663,6 +665,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() @@ -839,6 +842,7 @@ def showRendererDialog(self): if self._render_dialog is not None: self._render_dialog.raise_() self._render_dialog.activateWindow() + self._syncRendererFromModel(use_active=True) return try: @@ -849,11 +853,10 @@ def showRendererDialog(self): openmc_args.append(str(self.model_path)) plotter = OpenMCPlotter(args=openmc_args) - material_colors, cell_colors = self._getRendererDomainColors() - if self.model.currentView.colorby == "cell": - initial_color_mode = plotter.COLOR_BY_CELL - else: - initial_color_mode = plotter.COLOR_BY_MATERIAL + 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) @@ -864,9 +867,10 @@ def showRendererDialog(self): renderer_widget = RendererWidget( plotter, GLPlotWidget, - material_colors=material_colors, - cell_colors=cell_colors, + 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) @@ -876,6 +880,7 @@ def showRendererDialog(self): self._render_dialog = dialog self._render_plotter = plotter + self._renderer_widget = renderer_widget except Exception as exc: msg_box = QMessageBox(self) @@ -890,6 +895,7 @@ def showRendererDialog(self): 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: @@ -931,23 +937,25 @@ def _findRendererPythonDir(self): return None - def _getRendererDomainColors(self): - view = self.model.currentView - return (self._extractDomainColors(view.materials), - self._extractDomainColors(view.cells)) + def _getRendererDomainData(self, view=None): + if view is None: + view = self.model.activeView + return (self._extractDomainData(view.materials), + self._extractDomainData(view.cells)) - def _extractDomainColors(self, domains): - color_map = {} + def _extractDomainData(self, domains): + domain_data = {} for domain_id in domains.defaults: - if int(domain_id) < 0: + did = int(domain_id) + if did < 0: continue - domain = domains[domain_id] - rgb = self._normalizeRendererColor(domain.color) - if rgb is None: - continue - color_map[int(domain_id)] = rgb - return color_map + 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: @@ -969,6 +977,52 @@ def _normalizeRendererColor(self, color): 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): + for table_model in (self.cellsModel, self.materialsModel): + try: + table_model.dataChanged.disconnect(self._onDomainModelDataChanged) + except (TypeError, RuntimeError): + pass + table_model.dataChanged.connect(self._onDomainModelDataChanged) + + def _onDomainModelDataChanged(self, *_args): + self._syncRendererFromModel(use_active=True) + def showExportDialog(self): self.exportDataDialog.show() self.exportDataDialog.raise_() @@ -1275,11 +1329,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() @@ -1377,6 +1433,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 @@ -1400,6 +1457,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_widget.py b/openmc_plotter/renderer_widget.py index 35f848e..0df85f9 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -12,9 +12,10 @@ def __init__( self, plotter, gl_widget_cls, - material_colors=None, - cell_colors=None, - initial_color_mode=None, + material_domains=None, + cell_domains=None, + initial_color_mode="material", + on_color_changed=None, parent=None, ): super().__init__(parent) @@ -22,14 +23,14 @@ def __init__( 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._color_maps = { - self._material_mode: dict(material_colors or {}), - self._cell_mode: dict(cell_colors or {}), - } - if initial_color_mode in (self._material_mode, self._cell_mode): - self._initial_color_mode = initial_color_mode - else: - self._initial_color_mode = self._material_mode + self._on_color_changed = on_color_changed + + self._domain_data = {} + self._color_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() @@ -65,8 +66,8 @@ def _buildUi(self): modeLayout = QHBoxLayout() modeLayout.addWidget(QLabel("Color by:", self.controlsWidget)) self.modeCombo = QComboBox(self.controlsWidget) - self.modeCombo.addItem("Material", self.plotter.COLOR_BY_MATERIAL) - self.modeCombo.addItem("Cell", self.plotter.COLOR_BY_CELL) + self.modeCombo.addItem("Material", self._material_mode) + self.modeCombo.addItem("Cell", self._cell_mode) modeLayout.addWidget(self.modeCombo, 1) controlsLayout.addLayout(modeLayout) @@ -158,11 +159,11 @@ def _connectSignals(self): def _initializeState(self): if self.plotter.available: self.plotter.set_diffuse_fraction(0.1) - initial_idx = 0 if self._initial_color_mode == self._material_mode else 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._onColorModeChange(initial_idx) + self._refreshCurrentMode(self.modeCombo.itemData(initial_idx), request_render=True) else: self.visibilityLayout.addWidget(QLabel("OpenMC not available.", self.scrollContainer)) @@ -197,6 +198,96 @@ def _sliderFromScale(self, value, min_value, max_value): 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), + } + + 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 _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) @@ -204,6 +295,30 @@ def _clearLayout(self, layout): 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) @@ -254,13 +369,22 @@ def _onColorPick(self, domain_id, button): color = QColorDialog.getColor(initial, button, "Select Color") if not color.isValid(): return + rgb = (color.red(), color.green(), color.blue()) - self.plotter.set_color(domain_id, rgb) 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: @@ -268,16 +392,16 @@ def _applyMappedColors(self, mode): except Exception: continue - def _onColorModeChange(self, index): - mode = self.modeCombo.itemData(index) + def _refreshCurrentMode(self, mode, request_render): self.plotter.set_color_by(mode) self._applyMappedColors(mode) - if mode == self._cell_mode: - items = self.plotter.cell_list() - else: - items = self.plotter.material_list() - self._populateVisibilityList(items) - self.gl_widget.request_final_render() + 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) From 528f54040ea988ea67905b096c41f81ab68884e4 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 14:20:59 -0600 Subject: [PATCH 04/15] Address Qt warning from signal emission --- openmc_plotter/main_window.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index cb8c033..492bfea 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -1013,12 +1013,15 @@ def _syncRendererFromModel(self, use_active=True, sync_color_mode=False): ) def _connectDomainModelSignals(self): + unique = QtCore.Qt.ConnectionType.UniqueConnection for table_model in (self.cellsModel, self.materialsModel): try: - table_model.dataChanged.disconnect(self._onDomainModelDataChanged) + table_model.dataChanged.connect( + self._onDomainModelDataChanged, unique + ) except (TypeError, RuntimeError): + # Already connected for this model instance. pass - table_model.dataChanged.connect(self._onDomainModelDataChanged) def _onDomainModelDataChanged(self, *_args): self._syncRendererFromModel(use_active=True) From 64ac5262607bf4fd06caa7884c5175b88693135a Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 16:53:14 -0600 Subject: [PATCH 05/15] Update formatting of domain names in the rendering window --- openmc_plotter/renderer_widget.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py index 0df85f9..607dcaa 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -321,6 +321,7 @@ def _domainItemsForMode(self, mode): def _populateVisibilityList(self, items): self._clearLayout(self.visibilityLayout) + mode = self.modeCombo.currentData() for domain_id, name in items: row = QWidget(self.scrollContainer) @@ -332,9 +333,7 @@ def _populateVisibilityList(self, items): color = self.plotter.get_color(domain_id) self._setColorButtonStyle(color_button, color) - label_text = f"{domain_id}" - if name: - label_text = f"{domain_id} - {name}" + label_text = self._formatDomainLabel(mode, domain_id, name) label = QLabel(label_text, row) label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) @@ -353,6 +352,12 @@ def _populateVisibilityList(self, items): rowLayout.addWidget(checkbox) self.visibilityLayout.addWidget(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( From da63664373f669f9694cd8c36b8051ed577d2084 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 16:58:19 -0600 Subject: [PATCH 06/15] Add column title for clarity --- openmc_plotter/renderer_widget.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py index 607dcaa..21caae6 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -322,6 +322,7 @@ def _domainItemsForMode(self, mode): def _populateVisibilityList(self, items): self._clearLayout(self.visibilityLayout) mode = self.modeCombo.currentData() + self._addVisibilityHeader() for domain_id, name in items: row = QWidget(self.scrollContainer) @@ -352,6 +353,19 @@ def _populateVisibilityList(self, items): 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: From 49ed6936939f6e941d1e575ef6560f707def5281 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 19:00:25 -0600 Subject: [PATCH 07/15] Updating the render window buttons --- openmc_plotter/renderer_widget.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py index 21caae6..317b350 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -1,7 +1,7 @@ from PySide6 import QtCore, QtGui from PySide6.QtWidgets import (QCheckBox, QComboBox, QColorDialog, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QSlider, QSplitter, QVBoxLayout, + QScrollArea, QSlider, QSplitter, QStyle, QVBoxLayout, QWidget) @@ -48,9 +48,16 @@ def _buildUi(self): viewerLayout.setContentsMargins(0, 0, 0, 0) toolbarLayout = QHBoxLayout() - self.controlsButton = QPushButton("What's this?", viewerWidget) + self.controlsButton = QPushButton("", viewerWidget) + self.controlsButton.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion) + ) self.controlsButton.setToolTip("Show renderer controls") - self.saveButton = QPushButton("Save PNG", viewerWidget) + 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() From 624da6de567ef6db2d444a57cfb8044ac0714a2b Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 19:10:16 -0600 Subject: [PATCH 08/15] Providing camera parameter info and some polishing --- openmc_plotter/main_window.py | 10 +++++ openmc_plotter/renderer_widget.py | 68 +++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index 492bfea..79f8978 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -839,6 +839,16 @@ def showColorDialog(self): 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." + ) + msg_box.setStandardButtons(QMessageBox.Ok) + msg_box.exec() + if self._render_dialog is not None: self._render_dialog.raise_() self._render_dialog.activateWindow() diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py index 317b350..4b501e5 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -1,8 +1,8 @@ from PySide6 import QtCore, QtGui -from PySide6.QtWidgets import (QCheckBox, QComboBox, QColorDialog, QGridLayout, - QGroupBox, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QSlider, QSplitter, QStyle, QVBoxLayout, - QWidget) +from PySide6.QtWidgets import (QCheckBox, QComboBox, QColorDialog, QFrame, + QGridLayout, QGroupBox, QHBoxLayout, QLabel, + QPushButton, QScrollArea, QSlider, QSplitter, + QStyle, QVBoxLayout, QWidget) class RendererWidget(QWidget): @@ -35,6 +35,7 @@ def __init__( self._buildUi() self._connectSignals() self._initializeState() + self._startCameraInfoUpdates() def _buildUi(self): self.mainLayout = QHBoxLayout(self) @@ -81,6 +82,25 @@ def _buildUi(self): 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) @@ -99,6 +119,14 @@ def _buildUi(self): 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) @@ -175,6 +203,29 @@ def _initializeState(self): 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() @@ -182,18 +233,9 @@ def _makeScaledSlider(self, parent_layout, label_text, min_value, max_value, def slider = QSlider(QtCore.Qt.Horizontal, self.controlsWidget) slider.setRange(0, 100) slider.setValue(self._sliderFromScale(default_value, min_value, max_value)) - value_label = QLabel(self.controlsWidget) - - def _updateValue(value): - scaled = self._scaleFromSlider(value, min_value, max_value) - value_label.setText(f"{scaled:.3f}") - - slider.valueChanged.connect(_updateValue) - _updateValue(slider.value()) layout.addWidget(label) layout.addWidget(slider, 1) - layout.addWidget(value_label) parent_layout.addLayout(layout) return slider From 0cb4c396d5740005bb77d5af326ec7c9eb320304 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 19:13:25 -0600 Subject: [PATCH 09/15] Connect some keyboard shortcuts --- openmc_plotter/renderer_widget.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py index 4b501e5..ea3405a 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -174,6 +174,16 @@ def _connectSignals(self): 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)) @@ -318,6 +328,10 @@ def _resolveModeValue(self, color_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" From 2c02e9ef08e14a69f30b1b4e73d27800a59ae8d8 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 20:58:52 -0600 Subject: [PATCH 10/15] Including core code for the renderer widget --- openmc_plotter/main_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index 79f8978..4637120 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -934,6 +934,10 @@ def _loadRendererClasses(self): return self._renderer_classes def _findRendererPythonDir(self): + local_runtime = Path(__file__).resolve().parent / "renderer_core" + if local_runtime.is_dir(): + return local_runtime + env_path = os.environ.get("OPENMC_RENDERER_PATH") if env_path: candidate = Path(env_path) / "Python" From f79e2bb1f8a851a036f3cddffa79fe876827ec76 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 21:07:50 -0600 Subject: [PATCH 11/15] More polishing. --- openmc_plotter/main_window.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index 4637120..c9c7f28 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -1,7 +1,6 @@ import copy from functools import partial import importlib.util -import os from pathlib import Path import pickle import sys @@ -844,11 +843,25 @@ def showRendererDialog(self): msg_box.setWindowTitle("Experimental Renderer") msg_box.setText( "The render widget is experimental.\n\n" - "Complex models may cause the plotter application to lag." + "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() @@ -914,8 +927,7 @@ def _loadRendererClasses(self): renderer_python_dir = self._findRendererPythonDir() if renderer_python_dir is None: raise FileNotFoundError( - "Could not locate openmc_renderer/Python. " - "Set OPENMC_RENDERER_PATH to the renderer repository root." + "Could not locate bundled renderer_core directory." ) renderer_python_dir_str = str(renderer_python_dir) @@ -938,19 +950,11 @@ def _findRendererPythonDir(self): if local_runtime.is_dir(): return local_runtime - env_path = os.environ.get("OPENMC_RENDERER_PATH") - if env_path: - candidate = Path(env_path) / "Python" - if candidate.is_dir(): - return candidate - - for parent in Path(__file__).resolve().parents: - candidate = parent / "openmc_renderer" / "Python" - if candidate.is_dir(): - return candidate - 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 From 5d5d9b4d9cc77bfcba52c9457d6dade4263daaef Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 22:27:27 -0600 Subject: [PATCH 12/15] Including core renderer code --- openmc_plotter/renderer_core/camera.py | 84 ++++ openmc_plotter/renderer_core/gl_widget.py | 445 ++++++++++++++++++ .../renderer_core/openmc_plotter.py | 142 ++++++ 3 files changed, 671 insertions(+) create mode 100644 openmc_plotter/renderer_core/camera.py create mode 100644 openmc_plotter/renderer_core/gl_widget.py create mode 100644 openmc_plotter/renderer_core/openmc_plotter.py diff --git a/openmc_plotter/renderer_core/camera.py b/openmc_plotter/renderer_core/camera.py new file mode 100644 index 0000000..1a078eb --- /dev/null +++ b/openmc_plotter/renderer_core/camera.py @@ -0,0 +1,84 @@ +import math +import numpy as np + + +class OrbitCamera: + def __init__(self): + self.distance = 15.0 + self.azimuth = math.radians(45.0) + self.elevation = math.radians(30.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 + + def position(self): + cos_e = math.cos(self.elevation) + sin_e = math.sin(self.elevation) + cos_a = math.cos(self.azimuth) + sin_a = math.sin(self.azimuth) + x = self.target[0] + self.distance * cos_e * cos_a + y = self.target[1] + self.distance * cos_e * sin_a + z = self.target[2] + self.distance * sin_e + return np.array([x, y, z], dtype=np.float64) + + def view_vectors(self): + pos = self.position() + forward = self.target - pos + forward = forward / np.linalg.norm(forward) + up_ref = self.world_up + if abs(np.dot(forward, up_ref)) > 0.999: + up_ref = np.array([0.0, 1.0, 0.0], dtype=np.float64) + right = np.cross(forward, up_ref) + right = right / np.linalg.norm(right) + up = np.cross(right, forward) + up = up / np.linalg.norm(up) + return forward, right, up + + def orbit(self, dx, dy): + self.azimuth -= dx * self.rotate_speed + self.elevation -= dy * self.rotate_speed + max_e = math.radians(89.0) + self.elevation = max(-max_e, min(max_e, self.elevation)) + + 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.azimuth = math.radians(45.0) + self.elevation = math.radians(35.264) + + def set_axis_view(self, axis, negative=False): + axis = axis.lower() + if axis == "x": + self.azimuth = math.pi if negative else 0.0 + self.elevation = 0.0 + elif axis == "y": + self.azimuth = -math.pi / 2.0 if negative else math.pi / 2.0 + self.elevation = 0.0 + elif axis == "z": + self.azimuth = 0.0 + self.elevation = -math.pi / 2.0 if negative else math.pi / 2.0 + + 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 From 8b9224066695ecd8377bbd588ebceb9786430cf8 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sun, 15 Feb 2026 22:37:56 -0600 Subject: [PATCH 13/15] Improve camera implementation. --- openmc_plotter/renderer_core/camera.py | 217 +++++++++++++++++++++---- 1 file changed, 185 insertions(+), 32 deletions(-) diff --git a/openmc_plotter/renderer_core/camera.py b/openmc_plotter/renderer_core/camera.py index 1a078eb..4ce3c4d 100644 --- a/openmc_plotter/renderer_core/camera.py +++ b/openmc_plotter/renderer_core/camera.py @@ -3,10 +3,10 @@ class OrbitCamera: + _EPS = 1.0e-12 + def __init__(self): self.distance = 15.0 - self.azimuth = math.radians(45.0) - self.elevation = math.radians(30.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 @@ -16,34 +16,181 @@ def __init__(self): 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): - cos_e = math.cos(self.elevation) - sin_e = math.sin(self.elevation) - cos_a = math.cos(self.azimuth) - sin_a = math.sin(self.azimuth) - x = self.target[0] + self.distance * cos_e * cos_a - y = self.target[1] + self.distance * cos_e * sin_a - z = self.target[2] + self.distance * sin_e - return np.array([x, y, z], dtype=np.float64) + return self.target + self.distance * self._offset_direction() def view_vectors(self): - pos = self.position() - forward = self.target - pos - forward = forward / np.linalg.norm(forward) - up_ref = self.world_up - if abs(np.dot(forward, up_ref)) > 0.999: - up_ref = np.array([0.0, 1.0, 0.0], dtype=np.float64) - right = np.cross(forward, up_ref) - right = right / np.linalg.norm(right) - up = np.cross(right, forward) - up = up / np.linalg.norm(up) + 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): - self.azimuth -= dx * self.rotate_speed - self.elevation -= dy * self.rotate_speed - max_e = math.radians(89.0) - self.elevation = max(-max_e, min(max_e, self.elevation)) + 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: @@ -60,20 +207,26 @@ def zoom(self, delta): self.distance = self.min_distance def set_isometric_view(self): - self.azimuth = math.radians(45.0) - self.elevation = math.radians(35.264) + 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": - self.azimuth = math.pi if negative else 0.0 - self.elevation = 0.0 + 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": - self.azimuth = -math.pi / 2.0 if negative else math.pi / 2.0 - self.elevation = 0.0 + 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": - self.azimuth = 0.0 - self.elevation = -math.pi / 2.0 if negative else math.pi / 2.0 + 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: From 4928a3ac059661c3421d634f76573ced521ade45 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Mon, 16 Feb 2026 11:53:20 -0600 Subject: [PATCH 14/15] Improve renderer button placement --- openmc_plotter/docks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmc_plotter/docks.py b/openmc_plotter/docks.py index 213636f..0b6807d 100644 --- a/openmc_plotter/docks.py +++ b/openmc_plotter/docks.py @@ -184,8 +184,8 @@ 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.addStretch() self.panelLayout.addWidget(self.renderButton) + self.panelLayout.addStretch() self.setLayout(self.panelLayout) From 8456be40f16b73b01ccdd27b7f7b2492ec03a9b7 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Mon, 16 Feb 2026 12:12:03 -0600 Subject: [PATCH 15/15] Fixing problem with domain visibility on color change --- openmc_plotter/renderer_widget.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/openmc_plotter/renderer_widget.py b/openmc_plotter/renderer_widget.py index ea3405a..9bdfcc7 100644 --- a/openmc_plotter/renderer_widget.py +++ b/openmc_plotter/renderer_widget.py @@ -27,6 +27,7 @@ def __init__( self._domain_data = {} self._color_maps = {} + self._visibility_maps = {} self._setDomainData(material_domains, cell_domains) mode_value = self._resolveModeValue(initial_color_mode) @@ -302,6 +303,18 @@ def _setDomainData(self, material_domains, cell_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"] @@ -402,7 +415,8 @@ def _populateVisibilityList(self, items): label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) checkbox = QCheckBox(row) - checkbox.setChecked(True) + 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) @@ -442,6 +456,8 @@ def _setColorButtonStyle(self, button, rgb): ) 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() @@ -474,9 +490,17 @@ def _applyMappedColors(self, mode): 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()