From fd3cc8b3b8dfd8c15c667ef76a6d81210f551bb9 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 01/55] [all] Ignore bytecode and verification results --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e86d952 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# ignore cached bytecode-compiled python scene files +*__pycache__/ + +# a run's own output +verification/results/ From b092d84558518cbad11193e409c78f812acbc82b Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 02/55] [docs] Introduce the verification suite in the README --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 25d0262..843a7da 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # Verification-Validation Initially funded by TIRREX, this repository proposes a framework for verification and validation. It relies on both manufactured solutions and comparison with alternative simulation technologies. This work therefore proves the reliability and compliance of the SOFA models & algorithms. + +For how the repository is organised, how the suite works and how to extend it, see +[docs/architecture.md](docs/architecture.md). + +## Verification + +**Verification** uses the method of manufactured solutions: an exact displacement is chosen, the +loads that produce it are derived, and SOFA solves the problem on a sequence of refined meshes. +The error must shrink at the rate the discretisation predicts. Covered force fields: +- `LinearSmallStrainFEMForceField` + +## Layout + +``` +common/ shared code: geometry and SOFA helpers +verification/ the verification suite + cases//**.json one test case per file +``` + +## Requirements + +- SOFA with SofaPython3 +- numpy, sympy, matplotlib + +## Running + +```bash +python verification/run.py # one test case +python verification/run.py --all # every test case +python verification/run.py --clean # delete previous results +``` + +Results are written to `verification/results/`. From 46811280ebd7a664a2389cc3d8f876bae19c6c6f Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 03/55] [common] Add the common package --- common/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 common/__init__.py diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 From 36d1afb5defdf9b761d69601094072031fac1f63 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 04/55] [geometry] Add the tool-agnostic geometry base --- common/geometry/geometry.py | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 common/geometry/geometry.py diff --git a/common/geometry/geometry.py b/common/geometry/geometry.py new file mode 100644 index 0000000..46e44ef --- /dev/null +++ b/common/geometry/geometry.py @@ -0,0 +1,116 @@ +"""Tool-agnostic geometry descriptions for the VnV suite.""" + +from abc import ABC, abstractmethod + + +def _region_indices_from_mesh(mesh_path): + """Node indices tagged by each physical group of a Gmsh .msh file, keyed by group name.""" + import meshio + + mesh = meshio.read(mesh_path) + tag_names = {tag: name for name, (tag, _dim) in mesh.field_data.items()} + groups = {} + for block, tags in zip(mesh.cells, mesh.cell_data.get("gmsh:physical", [])): + for element, tag in zip(block.data, tags): + name = tag_names.get(tag) + if name is not None: + groups.setdefault(name, set()).update(element.tolist()) + return {name: sorted(indices) for name, indices in groups.items()} + + +class Geometry(ABC): + """A shape parameterization plus its named boundary regions.""" + + dim: int # topological dimension of the shape (and of the elements meshing it) + PARAMETER_ORDER: tuple # the family's parameters, ordered by the dimension that introduces each + + def __init__(self, *, dim=None, spatialDimensions=None, meshPath=None, **parameters): + """Build this shape, taking each name in its `PARAMETER_NAMES` as a keyword. + + e.g. ``Beam3D(length=2.0, width=1.0, height=1.0)``. Pass `dim` only for a family base, + which names no dimension of its own: ``Grid(dim=3, length=2.0, width=1.0, height=1.0)`` + builds that same box. + """ + stated = getattr(type(self), "dim", None) + if stated is None and dim is None: + raise TypeError(f"{type(self).__name__} states no dim: pass dim= to build one, or " + f"name the geometry that states it") + if stated is not None and dim is not None and dim != stated: + raise TypeError(f"{type(self).__name__} is {stated}D and cannot be built with dim={dim}") + self.dim = stated if dim is None else dim + + if set(parameters) != set(self.PARAMETER_NAMES): + raise TypeError(f"{type(self).__name__} takes {list(self.PARAMETER_NAMES)}, " + f"got {sorted(parameters)}") + + for name in self.PARAMETER_NAMES: + setattr(self, name, parameters[name]) + self._set_spatial_dimensions(spatialDimensions) + + if meshPath is not None: + self._regions = {name: (lambda nodes, indices=indices: indices) + for name, indices in _region_indices_from_mesh(meshPath).items()} + else: + self._regions = {name: (lambda nodes, predicate=predicate: + [i for i, p in enumerate(nodes) if predicate(p)]) + for name, predicate in self._analytic_predicates.items()} + + @property + def PARAMETER_NAMES(self) -> tuple: + """The parameters this shape takes: the first `dim` of its family's. + + Each dimension adds one, so a member takes as many as it has dimensions: of Grid's + ("length", "width", "height"), Bar1D takes ("length",) and Beam2D ("length", "width"). + A family that does not grow this way sets `PARAMETER_NAMES` itself instead. + """ + return self.PARAMETER_ORDER[:self.dim] + + @property + def parameters(self) -> list: + """This shape's defining parameters, read off the attributes named in `PARAMETER_NAMES`. + + e.g. Beam2D(length=2.0, width=0.5).parameters == [2.0, 0.5] + """ + return [getattr(self, name) for name in self.PARAMETER_NAMES] + + @property + def named_parameters(self) -> dict: + """Parameters keyed by name: how a manufactured solution asks for one of them. + + e.g. Beam2D(length=2.0, width=0.5).named_parameters == {"length": 2.0, "width": 0.5} + """ + return dict(zip(self.PARAMETER_NAMES, self.parameters)) + + def _set_spatial_dimensions(self, spatial_dimensions): + """Set how many dimensions of space this shape is embedded in; defaults to its own.""" + if spatial_dimensions is None: + spatial_dimensions = self.dim + + if spatial_dimensions < self.dim: + raise ValueError( + f"{type(self).__name__} is {self.dim}D and cannot be embedded in a " + f"{spatial_dimensions}D space: the embedding space must be at least as " + f"large as the shape." + ) + if spatial_dimensions > 3: + raise ValueError( + f"{type(self).__name__}: spatial_dimensions cannot exceed 3, " + f"got {spatial_dimensions}." + ) + + self.spatial_dimensions = spatial_dimensions + + @property + def region_names(self): + """This geometry's named boundary regions.""" + return self._regions.keys() + + def region_indices(self, region, nodes) -> list: + """Indices into `nodes` belonging to `region`.""" + return self._regions[region](nodes) + + @property + @abstractmethod + def _analytic_predicates(self) -> dict: + """Named boundary regions as ``{name: predicate(point) -> bool}``, from this geometry's + own shape parameters.""" From 37dc6a6130d0ce92b41ba6426d8651941d86a640 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 05/55] [geometry] Add the bar and beam grid geometries --- common/geometry/grid.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 common/geometry/grid.py diff --git a/common/geometry/grid.py b/common/geometry/grid.py new file mode 100644 index 0000000..d441498 --- /dev/null +++ b/common/geometry/grid.py @@ -0,0 +1,36 @@ +"""Rectilinear structured grid geometry in 1 to 3 dimensions: bars and beams.""" + +import numpy as np + +from .geometry import Geometry + + +class Grid(Geometry): + """A rectilinear structured grid, axis i spanning 0..parameters[i]. Always starts at the origin.""" + + PARAMETER_ORDER = ("length", "width", "height") + AXIS_NAMES = (("left", "right"), ("bottom", "top"), ("front", "back")) + + @property + def _analytic_predicates(self) -> dict: + predicates = {} + for axis, (low, high) in enumerate(self.AXIS_NAMES[:self.dim]): + extent = self.parameters[axis] + predicates[low] = lambda p, axis=axis: np.isclose(p[axis], 0.0) + predicates[high] = lambda p, axis=axis, extent=extent: np.isclose(p[axis], extent) + return predicates + + +class Bar1D(Grid): + """A 1D bar occupying ``[0, length]`` along x.""" + dim = 1 + + +class Beam2D(Grid): + """A 2D rectangle occupying ``[0, length] x [0, width]`` in the xy-plane.""" + dim = 2 + + +class Beam3D(Grid): + """A 3D box occupying ``[0, length] x [0, width] x [0, height]``.""" + dim = 3 From 34408da786d0171ea39bd20e8a58ef7f03591764 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 06/55] [geometry] Add the tire geometries --- common/geometry/tire.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 common/geometry/tire.py diff --git a/common/geometry/tire.py b/common/geometry/tire.py new file mode 100644 index 0000000..2eef1df --- /dev/null +++ b/common/geometry/tire.py @@ -0,0 +1,32 @@ +"""Tire geometry (Chamberland et al., 2010, Fig. 1).""" + +import numpy as np + +from .geometry import Geometry + + +class Tire(Geometry): + """An annulus in the xy-plane, centered on the origin, extruded along z when it is 3D.""" + + PARAMETER_ORDER = ("inner_radius", "outer_radius", "extrusion_length") + + @property + def _analytic_predicates(self) -> dict: + predicates = { + "inner": lambda p: np.isclose(np.hypot(p[0], p[1]), self.inner_radius), + "outer": lambda p: np.isclose(np.hypot(p[0], p[1]), self.outer_radius), + } + if self.dim == 3: + predicates["front"] = lambda p: np.isclose(p[2], 0.0) + predicates["back"] = lambda p: np.isclose(p[2], self.extrusion_length) + return predicates + + +class Tire2D(Tire): + """An annulus occupying ``inner_radius <= r <= outer_radius`` of the xy-plane.""" + dim = 2 + + +class Tire3D(Tire): + """A Tire2D extruded along z from 0 to extrusion_length.""" + dim = 3 From d01333f331f8d21bd5f46c0ea0b5221f6a4b1288 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 07/55] [geometry] Export the geometries --- common/geometry/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 common/geometry/__init__.py diff --git a/common/geometry/__init__.py b/common/geometry/__init__.py new file mode 100644 index 0000000..f82eb80 --- /dev/null +++ b/common/geometry/__init__.py @@ -0,0 +1,3 @@ +from .geometry import Geometry +from .grid import Grid, Bar1D, Beam2D, Beam3D +from .tire import Tire, Tire2D, Tire3D From 10b45f0f34f783ffa18e0015897f71e4062e13c7 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 08/55] [sofa] Map element kinds to SOFA components --- common/sofa/conventions.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 common/sofa/conventions.py diff --git a/common/sofa/conventions.py b/common/sofa/conventions.py new file mode 100644 index 0000000..d14acd8 --- /dev/null +++ b/common/sofa/conventions.py @@ -0,0 +1,38 @@ +"""Central map from tool-agnostic vocabulary to SOFA components.""" + +from dataclasses import dataclass + +# Spatial dimension (DOF/embedding space) -> MechanicalObject / force-field vector template. +VEC_DIM = {1: "Vec1d", 2: "Vec2d", 3: "Vec3d"} + +@dataclass(frozen=True) +class ElementKind: + """Everything the scene-building layer needs to know about one type of finite element.""" + + dim: int # topological dimension of this element + container: str # topology container class + data_name: str # Data field this kind's own container addresses its elements by + cpp: str # SOFA geometry name, for compound templates ("Vec3d,Hexahedron") + facet_kind: str | None # the kind of this element's own facets, None if it has none + grid_mapping: str | None # topological mapping generating it from the grid, None if grid-native + boundary_mappings: tuple # (element kind, mapping) steps from this element to its boundary + +ELEMENTS = { + "edge": ElementKind(dim=1, container="EdgeSetTopologyContainer", data_name="edges", + cpp="Edge", facet_kind=None, grid_mapping=None, + boundary_mappings=()), + "tri": ElementKind(dim=2, container="TriangleSetTopologyContainer", data_name="triangles", + cpp="Triangle", facet_kind="edge", grid_mapping="Quad2TriangleTopologicalMapping", + boundary_mappings=(("edge", "Triangle2EdgeTopologicalMapping"),)), + "quad": ElementKind(dim=2, container="QuadSetTopologyContainer", data_name="quads", + cpp="Quad", facet_kind="edge", grid_mapping=None, + # To arrive at a Quad2Edge map -> triangulate quads and then Tri2Edge + boundary_mappings=(("tri", "Quad2TriangleTopologicalMapping"), + ("edge", "Triangle2EdgeTopologicalMapping"))), + "tet": ElementKind(dim=3, container="TetrahedronSetTopologyContainer", data_name="tetrahedra", + cpp="Tetrahedron", facet_kind="tri", grid_mapping="Hexa2TetraTopologicalMapping", + boundary_mappings=(("tri", "Tetra2TriangleTopologicalMapping"),)), + "hexa": ElementKind(dim=3, container="HexahedronSetTopologyContainer", data_name="hexahedra", + cpp="Hexahedron", facet_kind="quad", grid_mapping=None, + boundary_mappings=(("quad", "Hexa2QuadTopologicalMapping"),)), +} From b6f010b8dfa6681fd33a0f7aff5499953bdaf70b Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 09/55] [sofa] Add the SOFA scene base --- common/sofa/scene.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 common/sofa/scene.py diff --git a/common/sofa/scene.py b/common/sofa/scene.py new file mode 100644 index 0000000..87de443 --- /dev/null +++ b/common/sofa/scene.py @@ -0,0 +1,29 @@ +"""Generic SOFA scene abstraction. Scenes in VnV build off of it.""" + +from abc import ABC, abstractmethod + + +class SofaScene(ABC): + """Abstract SOFA scene: loads its plugins, then builds the scene graph. + + A subclass appends the plugins it needs to `plugins`, and defines `body` to build the + scene graph. + """ + + plugins = [] # plugins every scene needs; a subclass appends its own on top + + def __init__(self): + # The list of plugins is kept as a copy per-instance. + self.plugins = list(self.plugins) + + def add_plugins(self, root): + """Add every plugin in `plugins` as one RequiredPlugin. Subclass appends to `self.plugins`.""" + root.addObject('RequiredPlugin', pluginName=self.plugins) + + @abstractmethod + def body(self, root): + """Build the scene graph.""" + + def build(self, root): + self.add_plugins(root) + return self.body(root) From 923095132d95c605b6b3ce99b71acb05c6ae261d Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 10/55] [sofa] Add the region clamp controller --- common/sofa/controllers.py | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 common/sofa/controllers.py diff --git a/common/sofa/controllers.py b/common/sofa/controllers.py new file mode 100644 index 0000000..4bd21e3 --- /dev/null +++ b/common/sofa/controllers.py @@ -0,0 +1,47 @@ +"""Post-init SOFA controllers.""" + +import Sofa.Core + + +class RegionClamp(Sofa.Core.Controller): + """Builds one PartialFixedProjectiveConstraint per boundary region, then applies Dirichlet + boundary conditions once the simulation has initialized. + + For each region, the nodes belonging to it are found and handed to its constraint as the indices + it fixes. If a prescribed displacement is given, those nodes are also moved from rest to rest + + u in the constraint's fixed directions before the constraint clamps them there. rest_position + itself is never changed. + """ + + def __init__(self, geometry, dofs, node, prescribe_displacement_on, vec_type, + displacement=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.geometry = geometry + self.dofs = dofs + self.displacement = displacement + + # Fix dofs partially as determined by MMS `prescribe_displacement_on` + self.region_constraints = [] + for region, fixed_directions in prescribe_displacement_on.items(): + # Pad the array with zeroes for embedded dimensions: e.g. [1, 0] -> [1, 0, 0] + padded = tuple(fixed_directions) + (0,) * (geometry.spatial_dimensions - len(fixed_directions)) + constraint = node.addObject('PartialFixedProjectiveConstraint', name='clamp' + region, + template=vec_type, fixedDirections=list(padded)) + self.region_constraints.append((region, padded, constraint)) + + def onSimulationInitDoneEvent(self, event): + rest_positions = self.dofs.rest_position.array() + + # Apply non-zero Dirichlet BC: Move each fixed direction to rest + u; rest_position is untouched. + with self.dofs.position.writeableArray() as pos: + for region, fixed_directions, constraint in self.region_constraints: + indices = self.geometry.region_indices(region, rest_positions) + + if self.displacement is not None and indices: + displacement = self.displacement(rest_positions[indices]) + + for axis, fixed in enumerate(fixed_directions): + if fixed: + pos[indices, axis] = rest_positions[indices, axis] + displacement[:, axis] + + constraint.indices.value = indices From 2df708a6e60a494ac2c7327320938690d4ba0a88 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:17 +0200 Subject: [PATCH 11/55] [sofa] Add the prefab base: force field and solvers --- common/sofa/prefabs/base.py | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 common/sofa/prefabs/base.py diff --git a/common/sofa/prefabs/base.py b/common/sofa/prefabs/base.py new file mode 100644 index 0000000..1603d4a --- /dev/null +++ b/common/sofa/prefabs/base.py @@ -0,0 +1,62 @@ +"""Prefabs a study configures: they take the case blocks they need and build themselves.""" + +import Sofa +import Sofa.Core + + +def params(config, *exclude): + """Converts a set of parameters in a configuration dictionary to SOFA Data/parameterization. + Every key is passed on except 'type' and excluded ones marked by the user through *exclude.""" + skip = ('type',) + exclude + return {k: v for k, v in config.items() if k not in skip} + + +class ScenePrefab(Sofa.Prefab): + """Base SOFA prefab: holds `configs` and offers adders a subclass calls to build itself from it.""" + + def __init__(self, *args, configs=None, geometry=None, **kwargs): + # object.__setattr__: the C++ object Sofa's own __setattr__ needs doesn't exist yet, and + # Sofa.Prefab.__init__ below calls init(), which reads configs/geometry + object.__setattr__(self, 'configs', configs or {}) + object.__setattr__(self, 'geometry', geometry) + Sofa.Prefab.__init__(self, *args, **kwargs) + + def mechanical_node(self): + raise NotImplementedError + + def add_force_field(self, node, config, material, template): + """The *FEMForceField component under test, its Data merged from `config` and `material`""" + node.addObject(config['type'], name='fem', template=template, topology='@topology', + **params(material), **params(config)) + + def add_solvers(self, node, config): + """Newton (if present) + linear solver (+ preconditioner) + integration scheme.""" + + # Newton Solver + newton_config = config.get('newton') + if newton_config is not None: + node.addObject(newton_config['type'], name='newton', **params(newton_config)) + + # Linear Solver & Preconditioner (optional) + linear_config = config['linearSolver'] + precond_config = linear_config.get('preconditioner') + if precond_config is None: + node.addObject(linear_config['type'], name='linearSolver', **params(linear_config)) + else: + precond_system_config = precond_config['system'] + node.addObject(precond_system_config['type'], name='precondSystem', **params(precond_system_config)) + node.addObject(precond_config['type'], name='precond', linearSystem='@precondSystem', + **params(precond_config, 'system')) + + linear_system_config = linear_config['system'] + node.addObject(linear_system_config['type'], name='solverSystem', + preconditionerSystem='@precondSystem', **params(linear_system_config)) + node.addObject(linear_config['type'], name='linearSolver', linearSystem='@solverSystem', + preconditioner='@precond', **params(linear_config, 'system', 'preconditioner')) + + # ODE Integration Scheme + integration_scheme = params(config['integration']) + integration_scheme['linearSolver'] = '@linearSolver' + if newton_config is not None: + integration_scheme['newtonSolver'] = '@newton' + node.addObject(config['integration']['type'], name='ode', **integration_scheme) From 4b120bb1fb022e4265f5a04b8515abf8eb887b08 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:18 +0200 Subject: [PATCH 12/55] [sofa] Add the elastic grid prefabs --- common/sofa/prefabs/grid.py | 89 +++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 common/sofa/prefabs/grid.py diff --git a/common/sofa/prefabs/grid.py b/common/sofa/prefabs/grid.py new file mode 100644 index 0000000..8af2906 --- /dev/null +++ b/common/sofa/prefabs/grid.py @@ -0,0 +1,89 @@ +"""SOFA prefabs: an elastic rectilinear structured grid in 1 to 3 dimensions, as a bar or a beam.""" + +from .base import ScenePrefab, params +from ..conventions import VEC_DIM, ELEMENTS +from ...geometry import Grid + +# Element kinds this prefab meshes, by the topological dimension of the geometry. +ELEMENT_KINDS = {1: ('edge',), 2: ('quad', 'tri'), 3: ('hexa', 'tet')} + + +def validate_parameters(name, config): + """Required ElasticGrid parameters, and an element kind its geometry can be meshed with.""" + required = ['geometry', 'grid_resolution', 'element'] + missing = [p for p in required if p not in config] + if missing: + raise ValueError(f"{name}: missing required parameters {missing}") + geometry = config['geometry'] + if not isinstance(geometry, Grid): + raise ValueError(f"{name}: geometry must be a Grid, " + f"got {type(geometry).__name__}") + kinds = ELEMENT_KINDS[geometry.dim] + if config['element'] not in kinds: + raise ValueError(f"{name}: element on a {geometry.dim}D {type(geometry).__name__} must be " + f"one of {list(kinds)}, got {config['element']!r}") + + +class ElasticGrid(ScenePrefab): + """Prefab for an elastic rectilinear structured grid model in SOFA.""" + + # The mechanical node's name in the scene graph; a named grid calls it what it is. + BODY = 'Body' + + prefabParameters = [ + {'name': 'grid_resolution', 'type': 'Vec3d', 'help': 'grid points per axis [nx, ny, nz]'}, + {'name': 'element', 'type': 'string', 'help': 'element kind (edge/tri/quad/tet/hexa)'}, + ] + + def __init__(self, *args, **kwargs): + validate_parameters(type(self).__name__, kwargs) + super().__init__(*args, **kwargs) + + def init(self): + dim = self.geometry.dim + extents = list(self.geometry.parameters) + [0.0] * (3 - dim) + VecType = VEC_DIM[self.geometry.spatial_dimensions] + element_kind = ELEMENTS[self.element.value] + grid_resolution = list(self.grid_resolution.value)[:dim] + [1] * (3 - dim) + + # Grid Topology Node + with self.addChild('Grid') as grid_node: + grid_node.addObject('RegularGridTopology', name='grid', + nx=int(grid_resolution[0]), ny=int(grid_resolution[1]), + nz=int(grid_resolution[2]), min=[0.0, 0.0, 0.0], max=extents) + + # Node containing the body's components + with self.addChild(self.BODY) as body: + self.body = body + + # Topology + if element_kind.grid_mapping is None: + body.addObject(element_kind.container, name='topology', position='@../Grid/grid.position', + **{element_kind.data_name: f'@../Grid/grid.{element_kind.data_name}'}) + else: + body.addObject(element_kind.container, name='topology', position='@../Grid/grid.position') + body.addObject(element_kind.grid_mapping, input='@../Grid/grid', output='@topology', + **params(self.configs.get('gridMapping', {}))) + body.addObject(element_kind.container.replace('Container', 'Modifier')) + # DOFs + body.addObject('MechanicalObject', name='dofs', template=VecType) + # The force field under test. + self.add_force_field(body, self.configs['forceField'], self.configs['material'], + template=f"{VecType},{element_kind.cpp}") + # ODE & Linear Solvers + self.add_solvers(body, self.configs['solvers']) + + def mechanical_node(self): + return self.body + + +class ElasticBar(ElasticGrid): + """Prefab for an elastic bar model in SOFA.""" + + BODY = 'Bar' + + +class ElasticBeam(ElasticGrid): + """Prefab for an elastic beam model in SOFA.""" + + BODY = 'Beam' From 16092380e64b8dad22f100ff2e66f7341cd9fbb1 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:18 +0200 Subject: [PATCH 13/55] [sofa] Add the elastic tire prefab --- common/sofa/prefabs/tire.py | 186 ++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 common/sofa/prefabs/tire.py diff --git a/common/sofa/prefabs/tire.py b/common/sofa/prefabs/tire.py new file mode 100644 index 0000000..0afb488 --- /dev/null +++ b/common/sofa/prefabs/tire.py @@ -0,0 +1,186 @@ +"""SOFA prefab: an elastic tire -- an annulus, extruded along z when it is 3D.""" + +import itertools + +import numpy as np + +from .base import ScenePrefab +from ..conventions import VEC_DIM, ELEMENTS +from ...geometry import Tire + +# Element kinds this prefab meshes, by the topological dimension of the geometry. +ELEMENT_KINDS = {2: ('quad', 'tri'), 3: ('hexa', 'tet')} + +# The two triangles one quadrilateral splits into, on one diagonal and on the other. +_TRIANGLE_PATTERNS = (((1, 2, 0), (3, 0, 2)), + ((0, 1, 3), (2, 3, 1))) + +# Swapping the two nodes of every hexahedron edge along one axis, as the permutation it applies to +# the eight corner slots. sofa::geometry::Hexahedron::{x,y,z}Edges. +_EDGE_SWAPS = ((1, 0, 3, 2, 5, 4, 7, 6), + (3, 2, 1, 0, 7, 6, 5, 4), + (4, 5, 6, 7, 0, 1, 2, 3)) + +# The six tetrahedra one hexahedron splits into, before and after an odd number of those swaps. +_TETRA_PATTERNS = { + False: ((0, 5, 1, 6), (0, 1, 3, 6), (1, 3, 6, 2), (6, 3, 0, 7), (6, 7, 0, 5), (7, 5, 4, 0)), + True: ((0, 5, 6, 1), (0, 1, 6, 3), (1, 3, 2, 6), (6, 3, 7, 0), (6, 7, 5, 0), (7, 5, 0, 4)), +} + + +def validate_parameters(config): + """Required ElasticTire parameters, and an element kind its geometry can be meshed with.""" + required = ['geometry', 'grid_resolution', 'element'] + missing = [p for p in required if p not in config] + if missing: + raise ValueError(f"ElasticTire: missing required parameters {missing}") + geometry = config['geometry'] + if not isinstance(geometry, Tire): + raise ValueError(f"ElasticTire: geometry must be a Tire2D or a Tire3D, " + f"got {type(geometry).__name__}") + kinds = ELEMENT_KINDS[geometry.dim] + if config['element'] not in kinds: + raise ValueError(f"ElasticTire: element on a {type(geometry).__name__} must be one of " + f"{list(kinds)}, got {config['element']!r}") + + +def annulus(geometry, cells): + """The annulus in the xy-plane: its nodes, and the quadrilaterals over them. + + `cells` counts cells radially and circumferentially. The ring closes on itself, so the + circumferential direction holds exactly that many nodes, not one more. + """ + radial, circumferential = cells + + radii = np.linspace(geometry.inner_radius, geometry.outer_radius, radial + 1) + angles = np.linspace(0.0, 2.0 * np.pi, circumferential, endpoint=False) + + r, theta = np.meshgrid(radii, angles, indexing='ij') + nodes = np.stack([r * np.cos(theta), r * np.sin(theta), np.zeros_like(r)], + axis=-1).reshape(-1, 3) + + node = np.arange(nodes.shape[0]).reshape(r.shape) + i, j = np.mgrid[0:radial, 0:circumferential] + ring = (j + 1) % circumferential # the last cell of the ring closes back onto j = 0 + + # (radial, circumferential) turns counter-clockwise, the winding GridTopology gives a cell. + corners = (node[i, j], node[i + 1, j], node[i + 1, ring], node[i, ring]) + return nodes, np.stack(corners, axis=-1).reshape(-1, 4) + + +def extrude(nodes, quads, length, axial): + """The annulus swept along z into `axial` layers: the swept nodes, and the hexahedra over them. + + Each layer repeats the annulus, so a hexahedron is its quadrilateral followed by the same one + a layer up -- again the winding GridTopology gives a cell. + """ + layer = nodes.shape[0] + swept = np.tile(nodes, (axial + 1, 1)) + swept[:, 2] = np.repeat(np.linspace(0.0, length, axial + 1), layer) + + base = np.arange(axial)[:, None, None] * layer + hexahedra = np.concatenate([quads[None] + base, quads[None] + base + layer], axis=-1) + return swept, hexahedra.reshape(-1, 8) + + +def triangulate(quads, cells): + """Two triangles per quadrilateral, split the way Quad2TriangleTopologicalMapping splits one. + + Neighbours share a whole edge, so any choice of diagonal conforms; alternating it cell by cell + only keeps the mesh from leaning the same way everywhere. + """ + i, j = np.mgrid[0:cells[0], 0:cells[1]] + diagonal = ((i ^ j) & 1).reshape(-1) + + triangles = np.empty((quads.shape[0], 2, 3), dtype=np.int64) + for choice, pattern in enumerate(_TRIANGLE_PATTERNS): + selected = diagonal == choice + triangles[selected] = quads[selected][:, pattern] + + return triangles.reshape(-1, 3) + + +def tetrahedralize(hexahedra, cells): + """Six tetrahedra per hexahedron, split the way Hexa2TetraTopologicalMapping splits one. + + The node labelling alternates with the parity of the cell's index, so neighbours agree on the + diagonal of every face they share. Across the seam that pairs the last circumferential cell + with the first, which only agree when there is an even number of them. + """ + radial, circumferential, axial = cells + if circumferential % 2: + raise ValueError(f"ElasticTire: an odd number of circumferential cells " + f"({circumferential}) leaves the tetrahedra of the closing cell " + f"disagreeing with the first on their shared diagonal") + + # Layer by layer, as `extrude` stacks them. + k, i, j = np.mgrid[0:axial, 0:radial, 0:circumferential] + parities = np.stack([i % 2 == 0, j % 2 == 1, k % 2 == 1], axis=-1).reshape(-1, 3) + + tetrahedra = np.empty((hexahedra.shape[0], 6, 4), dtype=np.int64) + for signature in itertools.product((False, True), repeat=3): + slots, swapped = list(range(8)), False + for axis, swap in enumerate(signature): + if swap: + slots = [slots[p] for p in _EDGE_SWAPS[axis]] + swapped = not swapped + pattern = [[slots[corner] for corner in tetra] for tetra in _TETRA_PATTERNS[swapped]] + + selected = np.all(parities == signature, axis=-1) + tetrahedra[selected] = hexahedra[selected][:, pattern] + + return tetrahedra.reshape(-1, 4) + + +def mesh(geometry, cells, element): + """The nodes of `geometry` and its elements of the given kind.""" + nodes, elements = annulus(geometry, cells[:2]) + if geometry.dim == 3: + nodes, elements = extrude(nodes, elements, geometry.extrusion_length, cells[2]) + + if element == 'tri': + elements = triangulate(elements, cells) + elif element == 'tet': + elements = tetrahedralize(elements, cells) + return nodes, elements + + +class ElasticTire(ScenePrefab): + """Prefab for an elastic tire model in SOFA.""" + + prefabParameters = [ + {'name': 'grid_resolution', 'type': 'Vec3d', + 'help': 'nodes per axis [radial, circumferential] and [, axial] when extruded'}, + {'name': 'element', 'type': 'string', 'help': 'element kind (quad/tri/hexa/tet)'}, + ] + + def __init__(self, *args, **kwargs): + validate_parameters(kwargs) + super().__init__(*args, **kwargs) + + def init(self): + VecType = VEC_DIM[self.geometry.spatial_dimensions] + element_kind = ELEMENTS[self.element.value] + # A study states nodes per axis, the mesh is laid out in cells. + cells = [int(count) - 1 for count in + list(self.grid_resolution.value)[:self.geometry.dim]] + + nodes, elements = mesh(self.geometry, cells, self.element.value) + + # Node containing Tire components + with self.addChild('Tire') as tire: + self.tire = tire + + # Topology: meshed directly, so no grid and no topological mapping to reach it. + tire.addObject(element_kind.container, name='topology', position=nodes.tolist(), + **{element_kind.data_name: elements.tolist()}) + # DOFs + tire.addObject('MechanicalObject', name='dofs', template=VecType) + # The force field under test. + self.add_force_field(tire, self.configs['forceField'], self.configs['material'], + template=f"{VecType},{element_kind.cpp}") + # ODE & Linear Solvers + self.add_solvers(tire, self.configs['solvers']) + + def mechanical_node(self): + return self.tire From 2f9dc3a0369366852c4f5e9ce2952a898056c423 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:18 +0200 Subject: [PATCH 14/55] [sofa] Map geometries to their prefab --- common/sofa/prefabs/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 common/sofa/prefabs/__init__.py diff --git a/common/sofa/prefabs/__init__.py b/common/sofa/prefabs/__init__.py new file mode 100644 index 0000000..8ea7478 --- /dev/null +++ b/common/sofa/prefabs/__init__.py @@ -0,0 +1,8 @@ +from .base import ScenePrefab +from .grid import ElasticGrid, ElasticBar, ElasticBeam +from .tire import ElasticTire +from ...geometry import Grid, Bar1D, Beam2D, Beam3D, Tire, Tire2D, Tire3D + +PREFAB_BY_GEOMETRY = {Grid: ElasticGrid, Bar1D: ElasticBar, + Beam2D: ElasticBeam, Beam3D: ElasticBeam, + Tire: ElasticTire, Tire2D: ElasticTire, Tire3D: ElasticTire} From 1838d6a5d95d30b37fe9f16d565e531eaf32843a Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:18 +0200 Subject: [PATCH 15/55] [sofa] Export the SOFA scene base --- common/sofa/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 common/sofa/__init__.py diff --git a/common/sofa/__init__.py b/common/sofa/__init__.py new file mode 100644 index 0000000..3175991 --- /dev/null +++ b/common/sofa/__init__.py @@ -0,0 +1 @@ +from .scene import SofaScene From 19e75106c61205e720f5879ec5cd8ed3505de796 Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:18 +0200 Subject: [PATCH 16/55] [verification] Add the verification package --- verification/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 verification/__init__.py diff --git a/verification/__init__.py b/verification/__init__.py new file mode 100644 index 0000000..e69de29 From d0321d3d852d8438f753996da46779955e68974a Mon Sep 17 00:00:00 2001 From: Themis Skamagkis Date: Mon, 12 Oct 2026 09:49:18 +0200 Subject: [PATCH 17/55] [verification] Add the linear elastic material law --- verification/manufactured/materials.py | 131 +++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 verification/manufactured/materials.py diff --git a/verification/manufactured/materials.py b/verification/manufactured/materials.py new file mode 100644 index 0000000..d804836 --- /dev/null +++ b/verification/manufactured/materials.py @@ -0,0 +1,131 @@ +"""Constitutive laws: a material declares psi(gradient); its stress and tangent are differentiated from that.""" + +from abc import ABC, abstractmethod + +import numpy as np +import sympy as sp + + +def _toLameParameters1D(youngModulus, poissonRatio): + """(mu, lambda) at d = 1, where lambda + 2 mu = E collapses Hooke to sigma = E eps.""" + return 0.5 * youngModulus, 0.0 + + +def _toLameParameters2D(youngModulus, poissonRatio): + """(mu, lambda) at d = 2, in plane stress: the convention SOFA's force fields assume.""" + return (youngModulus / (2 * (1 + poissonRatio)), + youngModulus * poissonRatio / (1 - poissonRatio ** 2)) + + +def _toLameParameters3D(youngModulus, poissonRatio): + """(mu, lambda) at d = 3.""" + return (youngModulus / (2 * (1 + poissonRatio)), + youngModulus * poissonRatio / ((1 + poissonRatio) * (1 - 2 * poissonRatio))) + + +# dim -> Young/Poisson -> (mu, lambda) +_toLame = {1: _toLameParameters1D, + 2: _toLameParameters2D, + 3: _toLameParameters3D} + + +def _tensor(dimensions, label): + """A d x d matrix of independent symbols, named "