Source code for pySimBlocks.gui.blocks.systems.sofa.sofa_plant
# ******************************************************************************
# pySimBlocks
# Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
# for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
# Authors: see Authors.txt
# ******************************************************************************
import os
import subprocess
import sys
from pathlib import Path
from typing import Literal
from PySide6.QtWidgets import QFormLayout, QLabel, QLineEdit, QPushButton
from pySimBlocks.gui.blocks.block_meta import BlockMeta
from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta
from pySimBlocks.gui.blocks.port_meta import PortMeta
from pySimBlocks.gui.models import BlockInstance, PortInstance
[docs]
class SofaPlantMeta(BlockMeta):
"""Describe the GUI metadata of the SOFA plant block."""
def __init__(self):
"""Initialize SOFA-plant block metadata.
Args:
None.
Raises:
None.
"""
self.name = "SofaPlant"
self.category = "systems"
self.type = "sofa_plant"
self.summary = "SOFA-based dynamic plant."
self.description = (
"Executes a SOFA simulation in a worker process."
)
self.parameters = [
ParameterMeta(
name="scene_file",
type="string",
required=True,
description="Path to the SOFA scene file, relative to the project.yaml file."
),
ParameterMeta(
name="input_keys",
type="list[string]",
required=True,
description="List of input keys corresponding to SOFA input ports."
),
ParameterMeta(
name="output_keys",
type="list[string]",
required=True,
description="List of output keys corresponding to SOFA output ports."
),
ParameterMeta(
name="slider_params",
type="dict",
description="Dictionary of slider parameters to be modified in the SOFA scene at runtime."
),
ParameterMeta(
name="sample_time",
type="float"
)
]
self.inputs = [
PortMeta(
name="sofa_inputs",
display_as="",
shape=[]
)
]
self.outputs = [
PortMeta(
name="sofa_outputs",
display_as="",
shape=[]
)
]
# --------------------------------------------------------------------------
# Public Methods
# --------------------------------------------------------------------------
[docs]
def resolve_port_group(
self,
port_meta: PortMeta,
direction: Literal["input", "output"],
instance: "BlockInstance"
) -> list["PortInstance"]:
"""Resolve dynamic SOFA input and output ports from configured key lists.
Args:
port_meta: Declared port metadata.
direction: Direction of the port group.
instance: Block instance whose ports are being built.
Returns:
Concrete ports for the requested port group.
"""
if direction == "input" and port_meta.name == "sofa_inputs":
keys = instance.parameters.get("input_keys", [])
if keys is None:
return []
return [
PortInstance(
name=key,
display_as=key,
direction="input",
block=instance
)
for key in keys
]
if direction == "output" and port_meta.name == "sofa_outputs":
keys = instance.parameters.get("output_keys", [])
if keys is None:
return []
return [
PortInstance(
name=key,
display_as=key,
direction="output",
block=instance
)
for key in keys
]
return super().resolve_port_group(port_meta, direction, instance)
[docs]
def build_param(
self,
session,
form: QFormLayout,
readonly: bool = False,
):
"""Build the SOFA-plant parameter editor.
Args:
session: Active dialog session.
form: Form layout receiving the widgets.
readonly: Whether the dialog is read-only.
"""
name_edit = QLineEdit(session.instance.name)
name_edit.textChanged.connect(
lambda val: self._on_param_changed(val, "name", session, readonly)
)
form.addRow(QLabel("Block name:"), name_edit)
if readonly:
name_edit.setReadOnly(True)
session.name_edit = name_edit
for pmeta in self.parameters:
if pmeta.name == "scene_file":
self.build_file_param_row(
session,
form,
pmeta,
readonly=readonly,
file_filter="SOFA scene files (*.py);;All files (*)",
)
continue
label, widget = self._create_param_row(session, pmeta, readonly)
if widget is None:
continue
if readonly:
self._set_readonly_style(widget)
form.addRow(label, widget)
session.param_widgets[pmeta.name] = widget
session.param_labels[pmeta.name] = label
[docs]
def build_post_param(self, session, form: QFormLayout, readonly: bool = False):
"""Build the post-parameter section with the open-file action.
Args:
session: Active dialog session.
form: Form layout receiving the widgets.
readonly: Whether the dialog is read-only.
"""
open_btn = QPushButton("Open file")
open_btn.clicked.connect(lambda: self._open_file_from_session(session))
form.addRow(QLabel(""), open_btn)
session.open_file_btn = open_btn
self._refresh_open_button_state(session)
[docs]
def refresh_form(self, session):
"""Refresh widget visibility and the file-open button state.
Args:
session: Active dialog session.
"""
super().refresh_form(session)
self._refresh_open_button_state(session)
# --------------------------------------------------------------------------
# Private Methods
# --------------------------------------------------------------------------
def _resolve_file_path(self, session) -> Path | None:
"""Resolve the configured SOFA scene path for the current session."""
raw = session.local_params.get("scene_file")
if not raw:
return None
path = Path(str(raw)).expanduser()
if not path.is_absolute() and session.project_dir is not None:
path = (session.project_dir / path).resolve()
return path
def _refresh_open_button_state(self, session) -> None:
"""Enable or disable the open-file button from the resolved path."""
btn = getattr(session, "open_file_btn", None)
if btn is None:
return
target = self._resolve_file_path(session)
exists = target is not None and target.is_file()
btn.setEnabled(exists)
if exists:
btn.setToolTip(str(target))
else:
btn.setToolTip("Set a valid existing scene_file to open the file.")
def _open_file_from_session(self, session) -> None:
"""Open the configured scene file with the platform default app."""
target = self._resolve_file_path(session)
if target is None or not target.is_file():
return
if sys.platform.startswith("darwin"):
subprocess.Popen(["open", str(target)])
elif os.name == "nt":
os.startfile(str(target))
else:
subprocess.Popen(["xdg-open", str(target)])