Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions examples/osim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# OpenSim Examples

These examples demonstrate how to use `pyorerun` with OpenSim models.

## Installation

Using `pyorerun` with OpenSim requires a careful installation procedure due to multiple dependency conflicts.

### Why the special setup?

There are **two conflicts** to navigate:

1. **ezc3d conflict**: Both `pyorerun` and `opensim` need `ezc3d`, but in different ways:
- `pyorerun` needs Python bindings (`import ezc3d`)
- `opensim` needs the C++ library (`libezc3d.so`)

The conda ezc3d package has broken Python bindings, so we overlay pip's version.

2. **casadi conflict**: The `pyorerun` conda package pulls in `casadi` through its dependencies, which breaks OpenSim's Moco module (`libosimMoco.so`).

### Step-by-step installation

To avoid conflicts, install opensim first, then pyorerun **from source**:

```bash
# 1. Create a fresh conda environment with Python
conda create -n pyorerun-osim python=3.12
conda activate pyorerun-osim

# 2. Install opensim and ezc3d FIRST (before pyorerun)
conda install -c opensim-org -c conda-forge opensim ezc3d

# 3. Overlay pip's ezc3d Python bindings (fixes the broken conda bindings)
pip install ezc3d --force-reinstall --no-deps

# 4. Install pyorerun from source (avoids conda's casadi conflict)
git clone https://github.com/Ipuch/pyorerun.git
pip install ./pyorerun

# 5. Verify everything works
python -c "import ezc3d; import opensim; import pyorerun; print('All imports OK')"
```

> [!IMPORTANT]
> - The `--no-deps` flag is critical for step 3 — it installs only ezc3d without changing numpy.
> - Installing `pyorerun` from source (step 4) avoids conda pulling in conflicting casadi.

### If you don't need OpenSim's Moco module

If you only need basic OpenSim functionality (not Moco), you can use a simpler install:

```bash
conda create -n pyorerun-osim python=3.12
conda activate pyorerun-osim
conda install -c conda-forge -c opensim-org pyorerun opensim ezc3d
pip install ezc3d --force-reinstall --no-deps
```

Then import opensim modules selectively (avoid `from opensim import *`):
```python
import opensim
model = opensim.Model("model.osim") # This works
# But avoid: opensim.MocoStudy() # This may fail due to casadi conflict
```

## Running the examples

```bash
cd examples/osim
python from_osim_model.py
```
18 changes: 14 additions & 4 deletions pyorerun/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from .live_animation import LiveModelAnimation
from .live_integration import LiveModelIntegration
from .model_components.model_display_options import DisplayModelOptions
from .model_components.model_updapter import ModelUpdater

Expand Down Expand Up @@ -33,9 +32,20 @@
# Pinocchio is not installed, these classes will not be available
pass

# Biorbd (model interfaces and biorbd-specific utilities)
try:
from .model_interfaces import (
BiorbdModel,
BiorbdModelNoMesh,
)
from .live_integration import LiveModelIntegration
from .rrbiomod import rr_biorbd as animate
except ImportError:
# biorbd is not installed, these classes will not be available
pass

# Abstract classes (always available)
from .model_interfaces import (
BiorbdModel,
BiorbdModelNoMesh,
AbstractSegment,
AbstractModel,
AbstractModelNoMesh,
Expand All @@ -46,7 +56,7 @@
from .phase_rerun import PhaseRerun
from .pyomarkers import PyoMarkers
from .pyoemg import PyoMuscles
from .rrbiomod import rr_biorbd as animate

from .rrc3d import rrc3d as c3d
from .rrtrc import rrtrc as trc
from .xp_components.timeseries_q import OsimTimeSeries
Expand Down
6 changes: 1 addition & 5 deletions pyorerun/model_components/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,7 @@ def from_file(
return cls(name, mesh, transform_callable)
elif file_path.endswith(".vtp"):
output = read_vtp_file(file_path)
is_not_a_trimesh = output["polygons"].shape[1] > 3
if is_not_a_trimesh:
raise ValueError(
f"The file {file_path} is not a triangular-only mesh. It has polygons with more than 3 vertices."
)
# Triangulation is now handled in read_vtp_file, so polygons are always triangles
mesh = Trimesh(
vertices=output["nodes"],
faces=output["polygons"],
Expand Down
9 changes: 8 additions & 1 deletion pyorerun/model_interfaces/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
from .abstract_model_interface import AbstractSegment, AbstractModel, AbstractModelNoMesh
from .biorbd_model_interface import BiorbdModelNoMesh, BiorbdModel

# Biorbd
try:
from .biorbd_model_interface import BiorbdModelNoMesh, BiorbdModel
except ImportError:
# biorbd is not installed, these classes will not be available
pass


# Opensim
try:
Expand Down
19 changes: 15 additions & 4 deletions pyorerun/model_interfaces/available_interfaces.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from .abstract_model_interface import AbstractModel, AbstractModelNoMesh
from .biorbd_model_interface import BiorbdModelNoMesh, BiorbdModel
from ..model_components.model_display_options import DisplayModelOptions

AVAILABLE_INTERFACES = {
"biorbd": (BiorbdModel, BiorbdModelNoMesh),
}
AVAILABLE_INTERFACES = {}

# Biorbd
try:
from .biorbd_model_interface import BiorbdModelNoMesh, BiorbdModel

AVAILABLE_INTERFACES["biorbd"] = (BiorbdModel, BiorbdModelNoMesh)
except ImportError:
# biorbd is not installed, these classes will not be available
pass

try:
from .osim_model_interface import OsimModelNoMesh, OsimModel
Expand Down Expand Up @@ -49,6 +55,11 @@ def model_from_file(model_path: str, options: DisplayModelOptions = None) -> tup
model = AVAILABLE_INTERFACES["opensim"][0](model_path, options=options)
no_instance_mesh = AVAILABLE_INTERFACES["opensim"][1]
elif model_path.endswith(".bioMod"):
if "biorbd" not in AVAILABLE_INTERFACES:
raise ImportError(
f"biorbd is not installed. Please install it to use biorbd models."
f"Use: conda install -c conda-forge biorbd"
)
model = AVAILABLE_INTERFACES["biorbd"][0](model_path, options=options)
no_instance_mesh = AVAILABLE_INTERFACES["biorbd"][1]
elif model_path.endswith(".urdf"):
Expand Down
105 changes: 104 additions & 1 deletion pyorerun/utils/vtp_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def read_vtp_file(filename: str) -> dict:
- "N_Obj": 1 (Only 1 object per file)
- "normals": np.ndarray (The normals)
- "nodes": np.ndarray (The nodes)
- "polygons": np.ndarray (The polygons)
- "polygons": np.ndarray (The polygons, always triangulated)

"""

Expand Down Expand Up @@ -109,4 +109,107 @@ def read_vtp_file(filename: str) -> dict:

mesh_dictionary[type_][i - 1, :] = tmp

# Triangulate polygons if necessary
if mesh_dictionary["polygons"].shape[1] > 3:
mesh_dictionary["polygons"], mesh_dictionary["nodes"], mesh_dictionary["normals"] = (
transform_polygon_to_triangles(
mesh_dictionary["polygons"],
mesh_dictionary["nodes"],
mesh_dictionary["normals"],
)
)

return mesh_dictionary


def transform_polygon_to_triangles(polygons, nodes, normals) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Transform any polygons with more than 3 edges into polygons with 3 edges (triangles)."""

if polygons.shape[1] == 3:
return polygons, nodes, normals

elif polygons.shape[1] == 4:
return convert_quadrangles_to_triangles(polygons, nodes, normals)

elif polygons.shape[1] > 4:
return convert_polygon_to_triangles(polygons, nodes, normals)

else:
raise RuntimeError("The polygons array must have at least 3 columns.")


def norm2(v):
"""Compute the squared norm of each row of the matrix v."""
return np.sum(v**2, axis=1)


def convert_quadrangles_to_triangles(polygons, nodes, normals) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Transform polygons with 4 edges (quadrangles) into polygons with 3 edges (triangles)."""
# 1. Search for quadrangles
quadrangles_idx = np.where((polygons[:, 3] != 0) & (~np.isnan(polygons[:, 3])))[0]
triangles_idx = np.where(np.isnan(polygons[:, 3]))[0]

# transform polygons[quadrangles, X] as a list of int
polygons_0 = polygons[quadrangles_idx, 0].astype(int)
polygons_1 = polygons[quadrangles_idx, 1].astype(int)
polygons_2 = polygons[quadrangles_idx, 2].astype(int)
polygons_3 = polygons[quadrangles_idx, 3].astype(int)

# 2. Determine triangles to be made
mH = 0.5 * (nodes[polygons_0] + nodes[polygons_2]) # Barycentres AC
mK = 0.5 * (nodes[polygons_1] + nodes[polygons_3]) # Barycentres BD
KH = mH - mK
AC = -nodes[polygons_0] + nodes[polygons_2] # Vector AC
BD = -nodes[polygons_1] + nodes[polygons_3] # Vector BD
# Search for the optimal segment for the quadrangle cut
type_ = np.sign((np.sum(KH * BD, axis=1) / norm2(BD)) ** 2 - (np.sum(KH * AC, axis=1) / norm2(AC)) ** 2)

# 3. Creation of new triangles
tBD = np.where(type_ >= 0)[0]
tAC = np.where(type_ < 0)[0]
# For BD
PBD_1 = np.column_stack(
[polygons[quadrangles_idx[tBD], 0], polygons[quadrangles_idx[tBD], 1], polygons[quadrangles_idx[tBD], 3]]
)
PBD_2 = np.column_stack(
[polygons[quadrangles_idx[tBD], 1], polygons[quadrangles_idx[tBD], 2], polygons[quadrangles_idx[tBD], 3]]
)
# For AC
PAC_1 = np.column_stack(
[polygons[quadrangles_idx[tAC], 0], polygons[quadrangles_idx[tAC], 1], polygons[quadrangles_idx[tAC], 2]]
)
PAC_2 = np.column_stack(
[polygons[quadrangles_idx[tAC], 2], polygons[quadrangles_idx[tAC], 3], polygons[quadrangles_idx[tAC], 0]]
)

# 4. Matrix of final polygons
new_polygons = np.vstack([polygons[triangles_idx, :3], PBD_1, PBD_2, PAC_1, PAC_2])

return new_polygons, nodes, normals


def convert_polygon_to_triangles(polygons, nodes, normals) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Transform any polygons with more than 3 edges into polygons with 3 edges (triangles).
"""

# Search for polygons with more than 3 edges
polygons_with_more_than_3_edges = np.where((polygons[:, 3] != 0) & (~np.isnan(polygons[:, 3])))[0]
polygons_with_3_edges = np.where(np.isnan(polygons[:, 3]))[0]

triangles = []
for j, poly_idx in enumerate(polygons_with_more_than_3_edges):
# get only the non-nan values
current_polygon = polygons[poly_idx, np.isnan(polygons[poly_idx]) == False]
# Split the polygons into triangles
# For simplicity, we'll use vertex 0 as the common vertex and form triangles:
# (0, 1, 2), (0, 2, 3), (0, 3, 4), ..., (0, n-2, n-1)

for i in range(1, current_polygon.shape[0] - 1):
triangles.append(np.column_stack([polygons[poly_idx, 0], polygons[poly_idx, i], polygons[poly_idx, i + 1]]))

return (
np.vstack([polygons[polygons_with_3_edges, :3], *triangles]),
nodes,
normals,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ packages = ["pyorerun"]

[project]
name = "pyorerun"
version = " 1.5.0"
version = "1.5.3"
authors = [{name = "Pierre Puchaud", email = "puchaud.pierre@gmail.com"}]
maintainers = [{name = "Pierre Puchaud", email = "puchaud.pierre@gmail.com"}]
description = "A Python package to rerun C3D files and biomechanical simulations."
Expand Down
Loading