# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025 Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Interactive PyQt6 GUI for pyOMA.core.PreProcessingTools.GeometryProcessor.
Wraps :class:`~pyOMA.core.PreProcessingTools.GeometryProcessor`: every
node/line/parent-child action mutates the ``GeometryProcessor`` instance in
place, and a :class:`~pyOMA.core.PlotMSH.ModeShapePlot` (geometry-only, no
modal data) re-renders the 3-D preview against its current state.
Widget layout lives in ``ui/geometry_processor.ui`` (compiled to
``generated/ui_geometry_processor.py`` by ``scripts/build_ui.py``); this
module only wires signals/slots and holds the table/preview logic.
"""
import os
import sys
import logging
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QMessageBox, QComboBox, QTableWidgetItem,
QFileDialog, QInputDialog,
)
from PyQt6.QtCore import Qt, QEventLoop
from .generated.ui_geometry_processor import Ui_GeometryProcessorGUI
from .HelpersGUI import save_figure_dialog, UnsavedChangesMixin
from ..core.PreProcessingTools import GeometryProcessor
from ..core.PlotMSH import ModeShapePlot
logger = logging.getLogger(__name__)
app = None
_FILE_FILTER = "Text files (*.txt);;All files (*)"
[docs]
class GeometryProcessorGUI(UnsavedChangesMixin, QMainWindow, Ui_GeometryProcessorGUI):
"""Interactive GUI for building/editing structural geometry.
Parameters
----------
geometry_data : GeometryProcessor, optional
The geometry object to inspect and edit. Actions performed through
this GUI mutate it in place. Defaults to a new, empty instance.
parent : QWidget, optional
"""
[docs]
def __init__(self, geometry_data=None, parent=None):
super().__init__(parent)
if geometry_data is None:
geometry_data = GeometryProcessor()
if not isinstance(geometry_data, GeometryProcessor):
raise TypeError(
f"geometry_data must be a GeometryProcessor instance, "
f"got {type(geometry_data).__name__}")
self.geometry_data = geometry_data
self.mode_shape_plot = ModeShapePlot(geometry_data)
self._dirty = False
self.setupUi(self)
self._wire_canvas()
self._wire_viewport()
self._wire_node_table()
self._wire_line_table()
self._wire_pc_table()
self._wire_menu()
self._wire_buttons()
self._refresh_node_table()
self._refresh_line_table()
self._refresh_pc_table()
self._redraw_geometry()
self.show()
# ------------------------------------------------------------------
# Wiring
# ------------------------------------------------------------------
def _wire_canvas(self):
fig = self.mode_shape_plot.fig
fig.set_size_inches((100, 100))
self.canvas.set_figure(fig)
self.mode_shape_plot.canvas = self.canvas
subplot = self.mode_shape_plot.subplot
self.canvas.mpl_connect('motion_notify_event', subplot._on_move)
self.canvas.mpl_connect('button_press_event', subplot._button_press)
self.canvas.mpl_connect('button_release_event', subplot._button_release)
subplot.mouse_init()
def _wire_viewport(self):
for button in (self.viewport_button_x, self.viewport_button_y,
self.viewport_button_z, self.viewport_button_iso):
button.released.connect(self._on_viewport_button)
self.reset_button.clicked.connect(self._redraw_geometry)
def _on_viewport_button(self):
self.mode_shape_plot.change_viewport(self.sender().text())
def _wire_node_table(self):
self.btn_add_node.clicked.connect(self._on_add_node)
self.btn_delete_node.clicked.connect(self._on_delete_node)
self.node_table.itemChanged.connect(self._on_node_item_changed)
def _wire_line_table(self):
self.btn_add_line.clicked.connect(self._on_add_line)
self.btn_delete_line.clicked.connect(self._on_delete_line)
def _wire_pc_table(self):
self.btn_add_pc.clicked.connect(self._on_add_pc)
self.btn_delete_pc.clicked.connect(self._on_delete_pc)
self.pc_table.itemChanged.connect(self._on_pc_item_changed)
def _wire_menu(self):
self.action_load_nodes.triggered.connect(self._on_load_nodes)
self.action_load_lines.triggered.connect(self._on_load_lines)
self.action_load_parent_childs.triggered.connect(self._on_load_parent_childs)
self.action_save_nodes.triggered.connect(self._on_save_nodes)
self.action_save_lines.triggered.connect(self._on_save_lines)
self.action_save_parent_childs.triggered.connect(self._on_save_parent_childs)
self.action_quit.triggered.connect(self.close)
def _wire_buttons(self):
self.load_state_button.clicked.connect(self.load_state)
self.save_state_button.clicked.connect(self.save_state)
self.save_figure_button.clicked.connect(self.save_figure)
self.ok_close_button.clicked.connect(self.close)
# ------------------------------------------------------------------
# Node table
# ------------------------------------------------------------------
def _refresh_node_table(self):
table = self.node_table
table.blockSignals(True)
table.setRowCount(0)
for name in sorted(self.geometry_data.nodes.keys()):
x, y, z = self.geometry_data.nodes[name]
row = table.rowCount()
table.insertRow(row)
name_item = QTableWidgetItem(name)
name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row, 0, name_item)
table.setItem(row, 1, QTableWidgetItem(f'{x:g}'))
table.setItem(row, 2, QTableWidgetItem(f'{y:g}'))
table.setItem(row, 3, QTableWidgetItem(f'{z:g}'))
table.blockSignals(False)
def _suggest_node_name(self):
i = 1
while str(i) in self.geometry_data.nodes:
i += 1
return str(i)
def _on_add_node(self):
name, ok = QInputDialog.getText(
self, "Add node", "Node name:", text=self._suggest_node_name())
if not ok or not name.strip():
return
name = name.strip()
if name in self.geometry_data.nodes:
QMessageBox.warning(self, "Add node", f"Node {name!r} already exists.")
return
self.geometry_data.add_node(name, [0.0, 0.0, 0.0])
self._dirty = True
self._refresh_node_table()
self._refresh_line_table()
self._refresh_pc_table()
self._redraw_geometry()
def _on_delete_node(self):
rows = sorted({index.row() for index in self.node_table.selectionModel().selectedRows()})
if not rows:
QMessageBox.warning(self, "Delete node", "Select at least one node first.")
return
names = [self.node_table.item(row, 0).text() for row in rows]
for name in names:
self.geometry_data.take_node(name)
self._dirty = True
self._refresh_node_table()
self._refresh_line_table()
self._refresh_pc_table()
self._redraw_geometry()
def _on_node_item_changed(self, item):
if item.column() == 0:
return # name is read-only; re-add under a new name instead of renaming
row = item.row()
name = self.node_table.item(row, 0).text()
try:
x = float(self.node_table.item(row, 1).text())
y = float(self.node_table.item(row, 2).text())
z = float(self.node_table.item(row, 3).text())
except (ValueError, AttributeError):
QMessageBox.warning(self, "Edit node", "Coordinates must be numeric.")
self._refresh_node_table()
return
# add_node() overwrites coordinates in place for an existing name
# without touching lines/parent-childs, so no take_node() needed here.
self.geometry_data.add_node(name, [x, y, z])
self._dirty = True
self._refresh_node_table()
self._redraw_geometry()
# ------------------------------------------------------------------
# Line table
# ------------------------------------------------------------------
def _refresh_line_table(self):
table = self.line_table
table.blockSignals(True)
table.setRowCount(0)
node_names = sorted(self.geometry_data.nodes.keys())
for row, (start, end) in enumerate(self.geometry_data.lines):
table.insertRow(row)
table.setCellWidget(row, 0, self._make_line_combo(node_names, start, row))
table.setCellWidget(row, 1, self._make_line_combo(node_names, end, row))
table.blockSignals(False)
def _make_line_combo(self, node_names, current, row):
combo = QComboBox()
combo.addItems(node_names)
combo.setCurrentText(current)
combo.currentTextChanged.connect(lambda _text, r=row: self._on_line_row_changed(r))
return combo
def _on_line_row_changed(self, row):
old_line = tuple(self.geometry_data.lines[row])
start = self.line_table.cellWidget(row, 0).currentText()
end = self.line_table.cellWidget(row, 1).currentText()
self.geometry_data.take_line(line=old_line)
self.geometry_data.add_line((start, end))
self._dirty = True
self._refresh_line_table()
self._redraw_geometry()
def _on_add_line(self):
node_names = sorted(self.geometry_data.nodes.keys())
if len(node_names) < 2:
QMessageBox.warning(self, "Add line", "Add at least two nodes first.")
return
self.geometry_data.add_line((node_names[0], node_names[1]))
self._dirty = True
self._refresh_line_table()
self._redraw_geometry()
def _on_delete_line(self):
rows = sorted({index.row() for index in self.line_table.selectionModel().selectedRows()})
if not rows:
QMessageBox.warning(self, "Delete line", "Select at least one line first.")
return
lines_to_remove = [tuple(self.geometry_data.lines[row]) for row in rows]
for line in lines_to_remove:
self.geometry_data.take_line(line=line)
self._dirty = True
self._refresh_line_table()
self._redraw_geometry()
# ------------------------------------------------------------------
# Parent-child table
# ------------------------------------------------------------------
def _refresh_pc_table(self):
table = self.pc_table
table.blockSignals(True)
table.setRowCount(0)
node_names = sorted(self.geometry_data.nodes.keys())
for row, (parent, x_p, y_p, z_p, child, x_c, y_c, z_c) in enumerate(
self.geometry_data.parent_childs):
table.insertRow(row)
table.setCellWidget(row, 0, self._make_pc_combo(node_names, parent, row))
table.setItem(row, 1, QTableWidgetItem(f'{x_p:g}'))
table.setItem(row, 2, QTableWidgetItem(f'{y_p:g}'))
table.setItem(row, 3, QTableWidgetItem(f'{z_p:g}'))
table.setCellWidget(row, 4, self._make_pc_combo(node_names, child, row))
table.setItem(row, 5, QTableWidgetItem(f'{x_c:g}'))
table.setItem(row, 6, QTableWidgetItem(f'{y_c:g}'))
table.setItem(row, 7, QTableWidgetItem(f'{z_c:g}'))
table.blockSignals(False)
def _make_pc_combo(self, node_names, current, row):
combo = QComboBox()
combo.addItems(node_names)
combo.setCurrentText(current)
combo.currentTextChanged.connect(lambda _text, r=row: self._on_pc_row_changed(r))
return combo
def _on_pc_item_changed(self, item):
if item.column() in (0, 4):
return # combos, not plain items
self._on_pc_row_changed(item.row())
def _on_pc_row_changed(self, row):
old_pc = self.geometry_data.parent_childs[row]
try:
parent = self.pc_table.cellWidget(row, 0).currentText()
x_p = float(self.pc_table.item(row, 1).text())
y_p = float(self.pc_table.item(row, 2).text())
z_p = float(self.pc_table.item(row, 3).text())
child = self.pc_table.cellWidget(row, 4).currentText()
x_c = float(self.pc_table.item(row, 5).text())
y_c = float(self.pc_table.item(row, 6).text())
z_c = float(self.pc_table.item(row, 7).text())
except (ValueError, AttributeError):
QMessageBox.warning(self, "Edit parent-child", "Amplitudes must be numeric.")
self._refresh_pc_table()
return
self.geometry_data.take_parent_child(ms=old_pc)
self.geometry_data.add_parent_child((parent, x_p, y_p, z_p, child, x_c, y_c, z_c))
self._dirty = True
self._refresh_pc_table()
self._redraw_geometry()
def _on_add_pc(self):
node_names = sorted(self.geometry_data.nodes.keys())
if not node_names:
QMessageBox.warning(self, "Add assignment", "Add at least one node first.")
return
parent = node_names[0]
child = node_names[1] if len(node_names) > 1 else node_names[0]
self.geometry_data.add_parent_child((parent, 1.0, 0.0, 0.0, child, 1.0, 0.0, 0.0))
self._dirty = True
self._refresh_pc_table()
self._redraw_geometry()
def _on_delete_pc(self):
rows = sorted({index.row() for index in self.pc_table.selectionModel().selectedRows()})
if not rows:
QMessageBox.warning(self, "Delete assignment", "Select at least one assignment first.")
return
pcs_to_remove = [self.geometry_data.parent_childs[row] for row in rows]
for parent_child in pcs_to_remove:
self.geometry_data.take_parent_child(ms=parent_child)
self._dirty = True
self._refresh_pc_table()
self._redraw_geometry()
# ------------------------------------------------------------------
# File menu: load/save
# ------------------------------------------------------------------
def _on_load_nodes(self):
fname, _sel = QFileDialog.getOpenFileName(self, "Load nodes", filter=_FILE_FILTER)
if not fname:
return
self.geometry_data.add_nodes(GeometryProcessor.nodes_loader(fname))
self._dirty = True
self._refresh_node_table()
self._refresh_line_table()
self._refresh_pc_table()
self._redraw_geometry()
def _on_load_lines(self):
fname, _sel = QFileDialog.getOpenFileName(self, "Load lines", filter=_FILE_FILTER)
if not fname:
return
self.geometry_data.add_lines(GeometryProcessor.lines_loader(fname))
self._dirty = True
self._refresh_line_table()
self._redraw_geometry()
def _on_load_parent_childs(self):
fname, _sel = QFileDialog.getOpenFileName(
self, "Load parent-child assignments", filter=_FILE_FILTER)
if not fname:
return
self.geometry_data.add_parent_childs(GeometryProcessor.parent_childs_loader(fname))
self._dirty = True
self._refresh_pc_table()
self._redraw_geometry()
def _on_save_nodes(self):
fname, _sel = QFileDialog.getSaveFileName(self, "Save nodes", filter=_FILE_FILTER)
if not fname:
return
self.geometry_data.nodes_saver(fname, self.geometry_data.nodes)
def _on_save_lines(self):
fname, _sel = QFileDialog.getSaveFileName(self, "Save lines", filter=_FILE_FILTER)
if not fname:
return
self.geometry_data.lines_saver(fname, self.geometry_data.lines)
def _on_save_parent_childs(self):
fname, _sel = QFileDialog.getSaveFileName(
self, "Save parent-child assignments", filter=_FILE_FILTER)
if not fname:
return
self.geometry_data.parent_childs_saver(fname, self.geometry_data.parent_childs)
[docs]
def save_state(self):
"""Save nodes/lines/parent-childs together as nodes.txt/lines.txt/
parent_childs.txt in one chosen directory (bundles the three
individual save actions above)."""
directory = QFileDialog.getExistingDirectory(
self, "Choose a directory to save the geometry state to")
if not directory:
return
self.geometry_data.save_geometry(
os.path.join(directory, 'nodes.txt'),
os.path.join(directory, 'lines.txt'),
os.path.join(directory, 'parent_childs.txt'))
[docs]
def load_state(self):
"""Load nodes/lines/parent-childs from a directory written by
:meth:`save_state`, replacing the current geometry."""
directory = QFileDialog.getExistingDirectory(
self, "Choose a directory to load the geometry state from")
if not directory:
return
nodes_file = os.path.join(directory, 'nodes.txt')
if not os.path.exists(nodes_file):
QMessageBox.warning(self, "Load failed", f"No nodes.txt found in {directory!r}.")
return
try:
loaded = GeometryProcessor.load_geometry(
nodes_file,
os.path.join(directory, 'lines.txt'),
os.path.join(directory, 'parent_childs.txt'))
except Exception as exc:
logger.exception("load_geometry failed")
QMessageBox.warning(self, "Load failed", str(exc))
return
self.geometry_data.nodes = loaded.nodes
self.geometry_data.lines = loaded.lines
self.geometry_data.parent_childs = loaded.parent_childs
self._dirty = False # freshly loaded from disk - nothing unsaved yet
self._refresh_node_table()
self._refresh_line_table()
self._refresh_pc_table()
self._redraw_geometry()
def save_figure(self):
save_figure_dialog(self, self.canvas)
def _do_save(self):
self.save_state()
self._dirty = False
# ------------------------------------------------------------------
# Preview
# ------------------------------------------------------------------
def _redraw_geometry(self):
# ModeShapePlot.reset_view() only re-adds nodes/lines/parent-childs
# still present in geometry_data; it never removes artists for ones
# that were just deleted (its add_*() calls only overwrite the slot
# for an index/key that is redrawn). So the axes are cleared and the
# per-artist bookkeeping reset to empty before every redraw, mirroring
# the fresh-axes state ModeShapePlot._setup_figure() constructs.
msh = self.mode_shape_plot
subplot = msh.subplot
subplot.cla()
subplot.set_aspect('equal', 'datalim')
subplot.patch = msh.fig.patch
subplot.grid(False)
subplot.set_axis_off()
subplot.mouse_init()
msh.patches_objects = {}
msh.lines_objects = []
msh.nd_lines_objects = []
msh.cn_lines_objects = {}
msh.arrows_objects = []
msh.axis_obj = {}
msh.reset_view()
self.canvas.draw_idle()
[docs]
def closeEvent(self, event):
if not self._prompt_save_on_close(event):
return
self.deleteLater()
return QMainWindow.closeEvent(self, event)
[docs]
def start_geometry_processor_gui(geometry_data=None):
global app
app = QApplication.instance() or QApplication(sys.argv)
form = GeometryProcessorGUI(geometry_data)
form.resize(1200, 800)
loop = QEventLoop()
form.destroyed.connect(loop.quit)
loop.exec()
return
[docs]
def main():
start_geometry_processor_gui()
if __name__ == '__main__':
main()