├── su2fmt ├── __init__.py ├── types.py ├── exporter.py └── parser.py ├── .pylintrc ├── .devcontainer └── devcontainer.json ├── pyproject.toml ├── .vscode ├── launch.json └── settings.json ├── .gitpod.yml ├── .github └── workflows │ ├── pypi-publish.yml │ └── python-test.yml ├── Makefile ├── README.md ├── LICENSE ├── .gitignore ├── examples └── onera_wing.ipynb └── tests └── su2mesh_test.py /su2fmt/__init__.py: -------------------------------------------------------------------------------- 1 | from su2fmt.parser import parse_mesh, combine_meshes 2 | from su2fmt.exporter import export_mesh 3 | from su2fmt.types import SU2ElementType 4 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | 2 | [MESSAGES CONTROL] 3 | disable=import-error, 4 | invalid-name, 5 | non-ascii-name, 6 | missing-function-docstring, 7 | missing-module-docstring, 8 | missing-class-docstring, 9 | line-too-long, 10 | dangerous-default-value, 11 | line-too-long, 12 | unnecessary-lambda -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.10", 4 | // Add the IDs of extensions you want installed when the container is created. 5 | "customizations": { 6 | "vscode": { 7 | "extensions": [ 8 | "ms-python.python", 9 | "ms-python.vscode-pylance", 10 | "eamodio.gitlens" 11 | ] 12 | } 13 | }, 14 | "remoteUser": "vscode" 15 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | 6 | [project] 7 | name = "su2fmt" 8 | version = "4.0.0" 9 | readme = "README.md" 10 | description = "the open source SU2 mesh format parser and exporter" 11 | authors = [ 12 | {name = "Open Orion"} 13 | ] 14 | dependencies = [ 15 | "numpy", 16 | "meshly>=1.3.0a0" 17 | ] 18 | requires-python = ">=3.7" 19 | 20 | [tool.setuptools.packages.find] 21 | where = ["."] 22 | include = ["su2fmt*"] -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "justMyCode": false 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | image: 5 | file: .devcontainer/Dockerfile 6 | 7 | github: 8 | prebuilds: 9 | # enable for the master/default branch (defaults to true) 10 | master: true 11 | # enable for pull requests coming from this repo (defaults to true) 12 | pullRequests: false 13 | # add a "Review in Gitpod" button as a comment to pull requests (defaults to true) 14 | addComment: false 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: '3.x' 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install build 19 | pip install . 20 | 21 | - name: Build package 22 | run: python -m build --sdist 23 | - name: Publish package 24 | uses: pypa/gh-action-pypi-publish@release/v1 25 | with: 26 | password: ${{ secrets.PYPI_API_TOKEN }} 27 | packages_dir: dist/ -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.8', '3.9', '3.10', '3.11'] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install . 26 | - name: Test with unittest 27 | run: | 28 | python -m unittest discover tests -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Hypersim Build Pipeline Makefile 2 | 3 | # Build Python package only 4 | build-package: 5 | @echo "📦 Building Python package..." 6 | python -m build 7 | 8 | # Clean build artifacts 9 | clean: 10 | @echo "🧹 Cleaning build artifacts..." 11 | rm -rf dist/ build/ *.egg-info/ 12 | @echo "✅ Clean completed" 13 | 14 | # Run tests 15 | test: 16 | @echo "🧪 Running tests..." 17 | python -m unittest discover -s tests -v 18 | 19 | # Install package in development mode 20 | install: 21 | @echo "📦 Installing package in development mode..." 22 | pip install -e . 23 | pip uninstall -y su2fmt 24 | 25 | # Quick build without push (for testing) 26 | build: 27 | @echo "🏗️ Building locally (no push)..." 28 | python -m build 29 | @echo "✅ Local build completed" 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
the open source SU2 mesh format parser and exporter
4 | 5 | 6 | 7 | # About 8 | su2fmt parses and exports the SU2 mesh format in accordance with the spec here: 9 | https://su2code.github.io/docs/Mesh-File/. 10 | 11 | 12 | # Install 13 | ``` 14 | pip install git+https://github.com/OpenOrion/su2fmt.git#egg=su2fmt 15 | ``` 16 | 17 | # Example 18 | See more examples in the [examples](/examples) directory 19 | 20 | ```python 21 | from su2fmt import parse_mesh, export_mesh 22 | 23 | # parses mesh file 24 | mesh = parse_mesh("example.su2") 25 | 26 | # export mesh file 27 | export_mesh(mesh, "example_generated.su2") 28 | ``` 29 | 30 | # Devlopement Setup 31 | ``` 32 | git clone https://github.com/OpenOrion/su2fmt.git 33 | cd su2fmt 34 | make install 35 | ``` 36 | -------------------------------------------------------------------------------- /su2fmt/types.py: -------------------------------------------------------------------------------- 1 | """Shared types and mappings for SU2 mesh format.""" 2 | from enum import Enum 3 | from meshly import VTKCellType 4 | 5 | 6 | class SU2ElementType(Enum): 7 | LINE = 3 8 | TRIANGLE = 5 9 | QUADRILATERAL = 9 10 | TETRAHEDRON = 10 11 | HEXAHEDRON = 12 12 | PRISM = 13 13 | PYRAMID = 14 14 | 15 | 16 | # SU2 to VTK cell type mapping (single source of truth) 17 | SU2_TO_VTK_MAPPING = { 18 | SU2ElementType.LINE.value: VTKCellType.VTK_LINE, 19 | SU2ElementType.TRIANGLE.value: VTKCellType.VTK_TRIANGLE, 20 | SU2ElementType.QUADRILATERAL.value: VTKCellType.VTK_QUAD, 21 | SU2ElementType.TETRAHEDRON.value: VTKCellType.VTK_TETRA, 22 | SU2ElementType.HEXAHEDRON.value: VTKCellType.VTK_HEXAHEDRON, 23 | SU2ElementType.PRISM.value: VTKCellType.VTK_WEDGE, 24 | SU2ElementType.PYRAMID.value: VTKCellType.VTK_PYRAMID, 25 | } 26 | 27 | # Reverse mapping derived from the forward mapping 28 | VTK_TO_SU2_MAPPING = {v: k for k, v in SU2_TO_VTK_MAPPING.items()} 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Afshawn Lotfi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestArgs": [ 3 | "-v", 4 | "-s", 5 | "./tests", 6 | "-p", 7 | "*_test.py" 8 | ], 9 | "python.testing.pytestEnabled": false, 10 | "python.testing.unittestEnabled": true, 11 | "python.linting.enabled": false, 12 | "python.defaultInterpreterPath": "/opt/conda/bin/python", 13 | "python.linting.pylintEnabled": true, 14 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 15 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 16 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 17 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 18 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 19 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 20 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 21 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 22 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", 23 | "jupyter.notebookFileRoot": "${workspaceFolder}", 24 | "python.formatting.autopep8Args": [ 25 | "--max-line-length=1000" 26 | ], 27 | "python.linting.pylintArgs": [ 28 | "--extension-pkg-whitelist=numpy" 29 | ], 30 | "python.analysis.typeCheckingMode": "basic", 31 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.tar.gz 2 | *.zip 3 | *.cgns 4 | *.su2 5 | *.geo_unrolled 6 | *.msh 7 | su2/ 8 | cfd/ 9 | generated/ 10 | simulations/ 11 | paraview 12 | docs 13 | bin/CQ-editor* 14 | bin/CQ-editor/ 15 | *.curaprofile 16 | ./cad 17 | *.step 18 | *.geo 19 | *.stl 20 | *.out 21 | *.mod 22 | *.pkl 23 | 24 | # Byte-compiled / optimized / DLL files 25 | __pycache__/ 26 | *.py[cod] 27 | *$py.class 28 | 29 | # C extensions 30 | *.so 31 | 32 | # Distribution / packaging 33 | .Python 34 | build/ 35 | develop-eggs/ 36 | dist/ 37 | downloads/ 38 | eggs/ 39 | .eggs/ 40 | lib/ 41 | lib64/ 42 | parts/ 43 | sdist/ 44 | var/ 45 | wheels/ 46 | share/python-wheels/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | MANIFEST 51 | 52 | # PyInstaller 53 | # Usually these files are written by a python script from a template 54 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 55 | *.manifest 56 | *.spec 57 | 58 | # Installer logs 59 | pip-log.txt 60 | pip-delete-this-directory.txt 61 | 62 | # Unit test / coverage reports 63 | htmlcov/ 64 | .tox/ 65 | .nox/ 66 | .coverage 67 | .coverage.* 68 | .cache 69 | nosetests.xml 70 | coverage.xml 71 | *.cover 72 | *.py,cover 73 | .hypothesis/ 74 | .pytest_cache/ 75 | cover/ 76 | 77 | # Translations 78 | *.mo 79 | *.pot 80 | 81 | # Django stuff: 82 | *.log 83 | local_settings.py 84 | db.sqlite3 85 | db.sqlite3-journal 86 | 87 | # Flask stuff: 88 | instance/ 89 | .webassets-cache 90 | 91 | # Scrapy stuff: 92 | .scrapy 93 | 94 | # Sphinx documentation 95 | docs/_build/ 96 | 97 | # PyBuilder 98 | .pybuilder/ 99 | target/ 100 | 101 | # Jupyter Notebook 102 | .ipynb_checkpoints 103 | 104 | # IPython 105 | profile_default/ 106 | ipython_config.py 107 | 108 | # pyenv 109 | # For a library or package, you might want to ignore these files since the code is 110 | # intended to run in multiple environments; otherwise, check them in: 111 | # .python-version 112 | 113 | # pipenv 114 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 115 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 116 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 117 | # install all needed dependencies. 118 | #Pipfile.lock 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # General 164 | .DS_Store 165 | .AppleDouble 166 | .LSOverride 167 | 168 | # Icon must end with two \r 169 | Icon 170 | 171 | 172 | # Thumbnails 173 | ._* 174 | 175 | # Files that might appear in the root of a volume 176 | .DocumentRevisions-V100 177 | .fseventsd 178 | .Spotlight-V100 179 | .TemporaryItems 180 | .Trashes 181 | .VolumeIcon.icns 182 | .com.apple.timemachine.donotpresent 183 | 184 | # Directories potentially created on remote AFP share 185 | .AppleDB 186 | .AppleDesktop 187 | Network Trash Folder 188 | Temporary Items 189 | .apdisk 190 | 191 | *~ 192 | 193 | # temporary files which can be created if a process still has a handle open of a deleted file 194 | .fuse_hidden* 195 | 196 | # KDE directory preferences 197 | .directory 198 | 199 | # Linux trash folder which might appear on any partition or disk 200 | .Trash-* 201 | 202 | # .nfs files are created when an open file is removed but is still being accessed 203 | .nfs* 204 | 205 | # Windows thumbnail cache files 206 | Thumbs.db 207 | Thumbs.db:encryptable 208 | ehthumbs.db 209 | ehthumbs_vista.db 210 | 211 | # Dump file 212 | *.stackdump 213 | 214 | # Folder config file 215 | [Dd]esktop.ini 216 | 217 | # Recycle Bin used on file shares 218 | $RECYCLE.BIN/ 219 | 220 | # Windows Installer files 221 | *.cab 222 | *.msi 223 | *.msix 224 | *.msm 225 | *.msp 226 | 227 | # Windows shortcuts 228 | *.lnk 229 | -------------------------------------------------------------------------------- /examples/onera_wing.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "16e93af0", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "SU2 file already exists at examples/mesh_ONERAM6_inv_ffd.su2\n" 14 | ] 15 | } 16 | ], 17 | "source": [ 18 | "import os\n", 19 | "import urllib.request\n", 20 | "\n", 21 | "# Create examples directory if it doesn't exist\n", 22 | "if not os.path.exists('examples'):\n", 23 | " os.makedirs('examples')\n", 24 | "\n", 25 | "# Define the target file path\n", 26 | "su2_file_path = 'examples/mesh_ONERAM6_inv_ffd.su2'\n", 27 | "\n", 28 | "# Check if the file already exists\n", 29 | "if not os.path.exists(su2_file_path):\n", 30 | " print(f\"Downloading SU2 file to {su2_file_path}...\")\n", 31 | " url = 'https://raw.githubusercontent.com/su2code/Tutorials/refs/heads/master/compressible_flow/Inviscid_ONERAM6/mesh_ONERAM6_inv_ffd.su2'\n", 32 | " urllib.request.urlretrieve(url, su2_file_path)\n", 33 | " print(\"Download complete.\")\n", 34 | "else:\n", 35 | " print(f\"SU2 file already exists at {su2_file_path}\")" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": 2, 41 | "id": "469dfd40", 42 | "metadata": {}, 43 | "outputs": [], 44 | "source": [ 45 | "from su2fmt import parse_mesh, export_mesh\n", 46 | "\n", 47 | "# parses mesh file\n", 48 | "mesh = parse_mesh(\"examples/mesh_ONERAM6_inv_ffd.su2\")" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 3, 54 | "id": "344fadeb", 55 | "metadata": {}, 56 | "outputs": [ 57 | { 58 | "name": "stdout", 59 | "output_type": "stream", 60 | "text": [ 61 | "Mesh dimensions: 3D\n", 62 | "Number of vertices: 108396\n", 63 | "Number of elements: 582752\n", 64 | "Number of markers: 7\n", 65 | "Marker names: ['LOWER_SIDE', 'TIP', 'UPPER_SIDE', 'XNORMAL_FACES', 'ZNORMAL_FACES', 'YNORMAL_FACE', 'SYMMETRY_FACE']\n" 66 | ] 67 | } 68 | ], 69 | "source": [ 70 | "# Print mesh information\n", 71 | "print(f\"Mesh dimensions: {mesh.dim}D\")\n", 72 | "print(f\"Number of vertices: {mesh.vertex_count}\")\n", 73 | "print(f\"Number of elements: {mesh.polygon_count}\")\n", 74 | "print(f\"Number of markers: {len(mesh.markers)}\")\n", 75 | "print(f\"Marker names: {list(mesh.markers.keys())}\")" 76 | ] 77 | }, 78 | { 79 | "cell_type": "code", 80 | "execution_count": 4, 81 | "id": "8c725515", 82 | "metadata": {}, 83 | "outputs": [ 84 | { 85 | "name": "stdout", 86 | "output_type": "stream", 87 | "text": [ 88 | "Exported mesh to examples/mesh_ONERAM6_output.su2\n" 89 | ] 90 | } 91 | ], 92 | "source": [ 93 | "# The mesh is now a meshly.Mesh object with full meshly capabilities\n", 94 | "# You can use all meshly features like optimization, simplification, encoding, etc.\n", 95 | "\n", 96 | "# Export the mesh back to SU2 format\n", 97 | "export_mesh(mesh, \"examples/mesh_ONERAM6_output.su2\")\n", 98 | "print(\"Exported mesh to examples/mesh_ONERAM6_output.su2\")" 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "id": "d9905b7c", 104 | "metadata": {}, 105 | "source": [ 106 | "## Using meshly features\n", 107 | "\n", 108 | "Now that the mesh is a `meshly.Mesh` object, you can use all meshly features such as:\n", 109 | "- Mesh optimization (vertex cache, overdraw, vertex fetch)\n", 110 | "- Mesh simplification\n", 111 | "- Encoding/decoding for compression\n", 112 | "- Saving/loading to ZIP files\n", 113 | "- Marker management and manipulation\n", 114 | "- And more!" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 5, 120 | "id": "9e0ee32c", 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "name": "stdout", 125 | "output_type": "stream", 126 | "text": [ 127 | "Encoded mesh size: vertices=955818 bytes\n", 128 | "Saved compressed mesh to examples/mesh_ONERAM6.zip\n" 129 | ] 130 | } 131 | ], 132 | "source": [ 133 | "# Example: Use meshly utilities\n", 134 | "from meshly import MeshUtils\n", 135 | "\n", 136 | "# Encode the mesh for compression\n", 137 | "encoded = MeshUtils.encode(mesh)\n", 138 | "print(f\"Encoded mesh size: vertices={len(encoded.vertices)} bytes\")\n", 139 | "\n", 140 | "# You can also save to a zip file\n", 141 | "MeshUtils.save_to_zip(mesh, \"examples/mesh_ONERAM6.zip\")\n", 142 | "print(\"Saved compressed mesh to examples/mesh_ONERAM6.zip\")" 143 | ] 144 | } 145 | ], 146 | "metadata": { 147 | "kernelspec": { 148 | "display_name": "Python 3", 149 | "language": "python", 150 | "name": "python3" 151 | }, 152 | "language_info": { 153 | "codemirror_mode": { 154 | "name": "ipython", 155 | "version": 3 156 | }, 157 | "file_extension": ".py", 158 | "mimetype": "text/x-python", 159 | "name": "python", 160 | "nbconvert_exporter": "python", 161 | "pygments_lexer": "ipython3", 162 | "version": "3.10.19" 163 | } 164 | }, 165 | "nbformat": 4, 166 | "nbformat_minor": 5 167 | } 168 | -------------------------------------------------------------------------------- /su2fmt/exporter.py: -------------------------------------------------------------------------------- 1 | import numpy.typing as npt 2 | from typing import List 3 | import numpy as np 4 | from meshly import Mesh 5 | from su2fmt.types import SU2ElementType, VTK_TO_SU2_MAPPING 6 | 7 | ELEMENT_INDENT = " " * 2 8 | 9 | def get_element_vertex_count(element_type: int) -> int: 10 | """Get the number of vertices for a given element type.""" 11 | if element_type == SU2ElementType.LINE.value: 12 | return 2 13 | elif element_type == SU2ElementType.TRIANGLE.value: 14 | return 3 15 | elif element_type == SU2ElementType.QUADRILATERAL.value: 16 | return 4 17 | elif element_type == SU2ElementType.TETRAHEDRON.value: 18 | return 4 19 | elif element_type == SU2ElementType.HEXAHEDRON.value: 20 | return 8 21 | elif element_type == SU2ElementType.PRISM.value: 22 | return 6 23 | elif element_type == SU2ElementType.PYRAMID.value: 24 | return 5 25 | else: 26 | raise ValueError(f"Unknown element type: {element_type}") 27 | 28 | def get_unused_point_indexes(points: npt.NDArray[np.float64], indices: npt.NDArray[np.int64]): 29 | """Find unused point indexes in the mesh.""" 30 | used_point_indexes = set() 31 | point_indexes = set(range(len(points))) 32 | for point_index in indices: 33 | used_point_indexes.add(point_index) 34 | return point_indexes.difference(used_point_indexes) 35 | 36 | def export_mesh(mesh: Mesh, file_path: str): 37 | """Export a meshly.Mesh to SU2 format file.""" 38 | with open(file_path, 'w+') as file: 39 | spaces = " " * 8 40 | 41 | # Get dimension from mesh 42 | ndime = mesh.dim if hasattr(mesh, 'dim') else 3 43 | 44 | # Write zone header 45 | file.write(f"NDIME= {ndime}\n") 46 | 47 | # Write points 48 | unused_point_indexes = get_unused_point_indexes(mesh.vertices, np.asarray(mesh.indices)) if mesh.indices is not None and len(mesh.indices) > 0 else set() 49 | npoin = len(mesh.vertices) 50 | file.write(f"NPOIN= {npoin - len(unused_point_indexes)}\n") 51 | 52 | for index, point in enumerate(mesh.vertices): 53 | if index in unused_point_indexes: 54 | continue 55 | point_row = [*(point[:-1] if ndime == 2 else point), index] 56 | file.write(f"{spaces}{spaces.join(map(str, point_row))}\n") 57 | 58 | # Write elements 59 | nelem = mesh.polygon_count if mesh.indices is not None and len(mesh.indices) > 0 else 0 60 | file.write(f"NELEM= {nelem}\n") 61 | 62 | if mesh.indices is not None and len(mesh.indices) > 0 and mesh.cell_types is not None: 63 | # Reconstruct elements from flattened indices using index_sizes 64 | idx = 0 65 | element_index = 0 66 | 67 | for i, vtk_type in enumerate(mesh.cell_types): 68 | if mesh.index_sizes is not None: 69 | num_vertices = mesh.index_sizes[i] 70 | else: 71 | num_vertices = get_element_vertex_count(int(vtk_type)) 72 | 73 | # Convert VTK cell type to SU2 element type 74 | su2_type = VTK_TO_SU2_MAPPING.get(int(vtk_type), int(vtk_type)) 75 | 76 | element_vertices = mesh.indices[idx:idx + num_vertices] 77 | element_row = [su2_type, *element_vertices, element_index] 78 | file.write(f"{ELEMENT_INDENT}{spaces.join(map(str, element_row))}\n") 79 | 80 | idx += num_vertices 81 | element_index += 1 82 | 83 | # Write markers 84 | nmark = len(mesh.markers) if hasattr(mesh, 'markers') and mesh.markers else 0 85 | file.write(f"NMARK= {nmark}\n") 86 | 87 | # Get reconstructed markers (list-of-lists format) 88 | reconstructed_markers = mesh.get_reconstructed_markers() if hasattr(mesh, 'get_reconstructed_markers') else mesh.markers 89 | 90 | for marker_tag, marker_elements in reconstructed_markers.items(): 91 | file.write(f"MARKER_TAG= {marker_tag}\n") 92 | 93 | # Number of marker elements 94 | num_marker_elems = len(marker_elements) 95 | file.write(f"MARKER_ELEMS= {num_marker_elems}\n") 96 | 97 | # Get marker cell types if available 98 | marker_cell_types = mesh.marker_cell_types.get(marker_tag) if hasattr(mesh, 'marker_cell_types') else None 99 | 100 | # Write marker elements 101 | for i, element_vertices in enumerate(marker_elements): 102 | if marker_cell_types is None or i >= len(marker_cell_types): 103 | raise ValueError( 104 | f"Missing cell type information for marker '{marker_tag}' element {i}. " 105 | f"Marker has {len(marker_elements)} elements but cell_types has " 106 | f"{len(marker_cell_types) if marker_cell_types else 0} entries." 107 | ) 108 | 109 | # Convert VTK cell type to SU2 element type 110 | su2_marker_type = VTK_TO_SU2_MAPPING.get(int(marker_cell_types[i]), int(marker_cell_types[i])) 111 | 112 | element_row = [su2_marker_type, *element_vertices] 113 | file.write(f"{ELEMENT_INDENT}{spaces.join(map(str, element_row))}\n") 114 | -------------------------------------------------------------------------------- /su2fmt/parser.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Union 2 | import numpy as np 3 | import numpy.typing as npt 4 | from meshly import Mesh 5 | from su2fmt.types import SU2ElementType, SU2_TO_VTK_MAPPING 6 | 7 | def parse_mesh(file_path: str) -> Union[Mesh, List[Mesh]]: 8 | with open(file_path, 'r') as file: 9 | meshes = [] 10 | nzone: int = 1 11 | 12 | izone_len = 0 13 | izone: int = 0 14 | ndime: Optional[int] = None 15 | nelem: Optional[int] = None 16 | npoin: Optional[int] = None 17 | nmark: int = 0 18 | points: List[npt.NDArray[np.float32]] = [] 19 | elements: List[npt.NDArray[np.int64]] = [] 20 | element_types: List[SU2ElementType] = [] 21 | markers: Dict[str, List[npt.NDArray[np.int64]]] = {} 22 | marker_types: Dict[str, List[SU2ElementType]] = {} 23 | 24 | element_index: Optional[int] = None 25 | point_index: Optional[int] = None 26 | 27 | marker_tag: Optional[str] = None 28 | nmark_elems: Optional[int] = None 29 | marker_index: Optional[int] = None 30 | 31 | for line in file: 32 | line = line.strip() 33 | 34 | if line.startswith('NZONE='): 35 | nzone = int(line.split('=')[1].strip().split()[0]) 36 | elif line.startswith('NMARK='): 37 | nmark = int(line.split('=')[1].strip().split()[0]) 38 | elif line.startswith('IZONE='): 39 | izone_len += 1 40 | izone = int(line.split('=')[1].strip().split()[0]) 41 | 42 | elif line.startswith('NDIME='): 43 | if izone_len > 1: 44 | assert ndime is not None, "NDIME must be defined for zone" 45 | assert nelem is not None, "NELEM must be defined for zone" 46 | assert npoin is not None, "NPOIN must be defined for zone" 47 | 48 | # Convert to meshly format 49 | indices = [] 50 | index_sizes = [] 51 | cell_types = [] 52 | 53 | for elem, elem_type in zip(elements, element_types): 54 | indices.extend(elem) 55 | index_sizes.append(len(elem)) 56 | cell_types.append(SU2_TO_VTK_MAPPING.get(elem_type.value, elem_type.value)) 57 | 58 | # Convert markers to list-of-lists format for meshly 59 | mesh_markers = {} 60 | 61 | for marker_tag_key, marker_elements in markers.items(): 62 | # Keep markers as list of lists (meshly format) 63 | mesh_markers[marker_tag_key] = [elem.tolist() for elem in marker_elements] 64 | 65 | mesh = Mesh( 66 | vertices=np.array(points), 67 | indices=np.array(indices, dtype=np.uint32) if indices else np.array([], dtype=np.uint32), 68 | index_sizes=np.array(index_sizes, dtype=np.uint32) if index_sizes else None, 69 | cell_types=np.array(cell_types, dtype=np.uint32) if cell_types else None, 70 | markers=mesh_markers if mesh_markers else {}, 71 | dim=ndime 72 | ) 73 | meshes.append(mesh) 74 | 75 | ndime = int(line.split('=')[1].strip().split()[0]) 76 | nelem = None 77 | npoin = None 78 | elements = [] 79 | element_types = [] 80 | points = [] 81 | markers = {} 82 | marker_types = {} 83 | element_index = None 84 | point_index = None 85 | marker_tag = None 86 | nmark_elems = None 87 | marker_index = None 88 | 89 | elif line.startswith('NELEM='): 90 | nelem = int(line.split('=')[1].strip().split()[0]) 91 | element_index = 0 92 | 93 | elif line.startswith('NPOIN='): 94 | npoin = int(line.split('=')[1].strip().split()[0]) 95 | point_index = 0 96 | 97 | elif line.startswith('MARKER_TAG='): 98 | marker_tag = line.split('=')[1].strip() 99 | 100 | elif line.startswith('MARKER_ELEMS='): 101 | nmark_elems = int(line.split('=')[1].strip().split()[0]) 102 | marker_index = 0 103 | 104 | elif element_index is not None and nelem: 105 | assert nelem is not None, "NELEM must be defined for zone before reading elements" 106 | parts = line.split() 107 | elem_type = SU2ElementType(int(parts[0])) 108 | 109 | # Determine expected number of vertices for this element type 110 | type_vertex_counts = { 111 | SU2ElementType.LINE: 2, 112 | SU2ElementType.TRIANGLE: 3, 113 | SU2ElementType.QUADRILATERAL: 4, 114 | SU2ElementType.TETRAHEDRON: 4, 115 | SU2ElementType.PYRAMID: 5, 116 | SU2ElementType.PRISM: 6, 117 | SU2ElementType.HEXAHEDRON: 8, 118 | } 119 | expected_vertices = type_vertex_counts.get(elem_type) 120 | 121 | if expected_vertices is None: 122 | raise ValueError(f"Unknown element type: {elem_type} (value: {elem_type.value})") 123 | 124 | # Check if last element is an index or a vertex 125 | # If we have exactly expected_vertices + 1 elements after type, last is index 126 | # If we have exactly expected_vertices elements after type, no index 127 | num_values = len(parts) - 1 # Excluding element type 128 | 129 | if num_values == expected_vertices + 1: 130 | # Has element index at end 131 | element = np.array(parts[1:-1], dtype=np.int64) 132 | elif num_values == expected_vertices: 133 | # No element index 134 | element = np.array(parts[1:], dtype=np.int64) 135 | else: 136 | raise ValueError( 137 | f"Element type {elem_type.name} expects {expected_vertices} vertices, " 138 | f"but found {num_values} values (with or without element index). " 139 | f"Line: {line}" 140 | ) 141 | 142 | elements.append(element) 143 | element_types.append(elem_type) 144 | if element_index == nelem-1: 145 | element_index = None 146 | else: 147 | element_index += 1 148 | 149 | elif point_index is not None and npoin: 150 | assert npoin is not None, "NPOIN must be defined for zone before reading points" 151 | if ndime == 2: 152 | point = np.array(line.split()[:2]+[0], dtype=np.float32) 153 | else: 154 | point = np.array(line.split()[:3], dtype=np.float32) 155 | points.append(point) 156 | if point_index == npoin-1: 157 | point_index = None 158 | else: 159 | point_index += 1 160 | 161 | 162 | elif marker_index is not None: 163 | assert nmark_elems is not None, "MARKER_ELEMS must be defined for zone before reading markers" 164 | 165 | assert marker_tag is not None, "MARKER_TAG must be defined for marker before reading marker elements" 166 | if marker_tag not in markers: 167 | markers[marker_tag] = [] 168 | marker_types[marker_tag] = [] 169 | marker_components = line.split() 170 | markers[marker_tag].append( 171 | np.array(marker_components[1:], dtype=np.int64) 172 | ) 173 | marker_types[marker_tag].append(SU2ElementType(int(marker_components[0]))) 174 | 175 | if marker_index == nmark_elems - 1: 176 | marker_index = None 177 | else: 178 | marker_index += 1 179 | 180 | assert ndime is not None, "NDIME must be defined for zone" 181 | assert nelem is not None, "NELEM must be defined for zone" 182 | assert npoin is not None, "NPOIN must be defined for zone" 183 | 184 | # Convert final zone to meshly format 185 | indices = [] 186 | index_sizes = [] 187 | cell_types = [] 188 | 189 | for elem, elem_type in zip(elements, element_types): 190 | indices.extend(elem) 191 | index_sizes.append(len(elem)) 192 | cell_types.append(SU2_TO_VTK_MAPPING.get(elem_type.value, elem_type.value)) 193 | 194 | # Convert markers to list-of-lists format for meshly 195 | mesh_markers = {} 196 | 197 | for marker_tag_key, marker_elements in markers.items(): 198 | # Keep markers as list of lists (meshly format) 199 | mesh_markers[marker_tag_key] = [elem.tolist() for elem in marker_elements] 200 | 201 | mesh = Mesh( 202 | vertices=np.array(points), 203 | indices=np.array(indices, dtype=np.uint32) if indices else np.array([], dtype=np.uint32), 204 | index_sizes=np.array(index_sizes, dtype=np.uint32) if index_sizes else None, 205 | cell_types=np.array(cell_types, dtype=np.uint32) if cell_types else None, 206 | markers=mesh_markers if mesh_markers else {}, 207 | dim=ndime 208 | ) 209 | meshes.append(mesh) 210 | 211 | # Return single mesh if only one zone, otherwise return list 212 | if len(meshes) == 1 and nzone == 1: 213 | return meshes[0] 214 | else: 215 | return meshes 216 | 217 | def combine_meshes(meshes: List[Mesh]) -> List[Mesh]: 218 | """Combine meshes - for multi-zone support.""" 219 | # Simply return the list as meshly.Mesh.combine() can be used if needed 220 | return meshes -------------------------------------------------------------------------------- /tests/su2mesh_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | import tempfile 4 | import os 5 | from typing import List 6 | from su2fmt import parse_mesh, export_mesh, SU2ElementType 7 | from meshly import Mesh 8 | 9 | 10 | class TestSU2Mesh(unittest.TestCase): 11 | """Test cases for SU2Mesh functionality.""" 12 | 13 | def setUp(self): 14 | """Set up test fixtures.""" 15 | self.temp_dir = tempfile.mkdtemp() 16 | 17 | def tearDown(self): 18 | """Clean up test fixtures.""" 19 | import shutil 20 | shutil.rmtree(self.temp_dir) 21 | 22 | def test_basic_mesh_creation(self): 23 | """Test basic Mesh creation and properties.""" 24 | vertices = np.array([ 25 | [0.0, 0.0, 0.0], 26 | [1.0, 0.0, 0.0], 27 | [0.0, 1.0, 0.0] 28 | ], dtype=np.float32) 29 | 30 | indices = np.array([0, 1, 2], dtype=np.uint32) 31 | cell_types = np.array([SU2ElementType.TRIANGLE.value], dtype=np.uint32) 32 | index_sizes = np.array([3], dtype=np.uint32) 33 | 34 | mesh = Mesh( 35 | vertices=vertices, 36 | indices=indices, 37 | cell_types=cell_types, 38 | index_sizes=index_sizes, 39 | dim=3 40 | ) 41 | 42 | # Test computed properties 43 | self.assertEqual(mesh.dim, 3) 44 | self.assertEqual(mesh.polygon_count, 1) 45 | self.assertEqual(mesh.vertex_count, 3) 46 | self.assertEqual(mesh.index_count, 3) 47 | 48 | def test_2d_mesh_creation(self): 49 | """Test 2D mesh creation.""" 50 | vertices = np.array([ 51 | [0.0, 0.0], 52 | [1.0, 0.0], 53 | [0.0, 1.0] 54 | ], dtype=np.float32) 55 | 56 | indices = np.array([0, 1, 2], dtype=np.uint32) 57 | cell_types = np.array([SU2ElementType.TRIANGLE.value], dtype=np.uint32) 58 | index_sizes = np.array([3], dtype=np.uint32) 59 | 60 | mesh = Mesh( 61 | vertices=vertices, 62 | indices=indices, 63 | cell_types=cell_types, 64 | index_sizes=index_sizes, 65 | dim=2 66 | ) 67 | 68 | self.assertEqual(mesh.dim, 2) 69 | self.assertEqual(mesh.vertex_count, 3) 70 | 71 | def test_mesh_with_markers(self): 72 | """Test mesh creation with boundary markers.""" 73 | vertices = np.array([ 74 | [0.0, 0.0, 0.0], 75 | [1.0, 0.0, 0.0], 76 | [0.0, 1.0, 0.0], 77 | [1.0, 1.0, 0.0] 78 | ], dtype=np.float32) 79 | 80 | indices = np.array([0, 1, 2, 1, 2, 3], dtype=np.uint32) 81 | cell_types = np.array([ 82 | SU2ElementType.TRIANGLE.value, 83 | SU2ElementType.TRIANGLE.value 84 | ], dtype=np.uint32) 85 | index_sizes = np.array([3, 3], dtype=np.uint32) 86 | 87 | markers = { 88 | "wall": [[0, 1], [1, 3]], 89 | "inlet": [[0, 2]] 90 | } 91 | 92 | mesh = Mesh( 93 | vertices=vertices, 94 | indices=indices, 95 | cell_types=cell_types, 96 | index_sizes=index_sizes, 97 | markers=markers, 98 | dim=3 99 | ) 100 | 101 | self.assertEqual(len(mesh.markers), 2) 102 | self.assertIn("wall", mesh.markers) 103 | self.assertIn("inlet", mesh.markers) 104 | reconstructed = mesh.get_reconstructed_markers() 105 | self.assertEqual(len(reconstructed["wall"]), 2) 106 | self.assertEqual(len(reconstructed["inlet"]), 1) 107 | 108 | def test_different_element_types(self): 109 | """Test mesh with different element types.""" 110 | vertices = np.array([ 111 | [0.0, 0.0, 0.0], # 0 112 | [1.0, 0.0, 0.0], # 1 113 | [0.0, 1.0, 0.0], # 2 114 | [1.0, 1.0, 0.0], # 3 115 | [0.5, 0.5, 1.0] # 4 116 | ], dtype=np.float32) 117 | 118 | # Triangle + Tetrahedron 119 | indices = np.array([0, 1, 2, 0, 1, 2, 4], dtype=np.uint32) 120 | cell_types = np.array([ 121 | SU2ElementType.TRIANGLE.value, 122 | SU2ElementType.TETRAHEDRON.value 123 | ], dtype=np.uint32) 124 | index_sizes = np.array([3, 4], dtype=np.uint32) 125 | 126 | mesh = Mesh( 127 | vertices=vertices, 128 | indices=indices, 129 | cell_types=cell_types, 130 | index_sizes=index_sizes, 131 | dim=3 132 | ) 133 | 134 | self.assertEqual(mesh.polygon_count, 2) 135 | self.assertEqual(mesh.vertex_count, 5) 136 | 137 | def test_simple_mesh_parsing(self): 138 | """Test parsing a simple mesh file.""" 139 | mesh_content = """NDIME= 2 140 | NPOIN= 4 141 | 0.0000000000 0.0000000000 0 142 | 1.0000000000 0.0000000000 1 143 | 1.0000000000 1.0000000000 2 144 | 0.0000000000 1.0000000000 3 145 | NELEM= 2 146 | 5 0 1 2 0 147 | 5 0 2 3 1 148 | NMARK= 2 149 | MARKER_TAG= wall 150 | MARKER_ELEMS= 2 151 | 3 0 1 152 | 3 2 3 153 | MARKER_TAG= inlet 154 | MARKER_ELEMS= 1 155 | 3 1 2 156 | """ 157 | 158 | # Write to temporary file 159 | mesh_file = os.path.join(self.temp_dir, "test_mesh.su2") 160 | with open(mesh_file, 'w') as f: 161 | f.write(mesh_content) 162 | 163 | # Parse the mesh 164 | mesh = parse_mesh(mesh_file) 165 | assert isinstance(mesh, Mesh), "Parsed mesh should be an instance of Mesh" 166 | 167 | # Verify parsing results 168 | self.assertEqual(mesh.dim, 2) 169 | self.assertEqual(mesh.polygon_count, 2) 170 | self.assertEqual(mesh.vertex_count, 4) 171 | self.assertEqual(len(mesh.markers), 2) 172 | 173 | # Check vertices 174 | self.assertEqual(mesh.vertices.shape, (4, 3)) # Always 3D internally 175 | 176 | # Check elements 177 | self.assertEqual(len(mesh.cell_types), 2) 178 | self.assertTrue(all(et == SU2ElementType.TRIANGLE.value for et in mesh.cell_types)) 179 | 180 | # Check markers 181 | self.assertIn("wall", mesh.markers) 182 | self.assertIn("inlet", mesh.markers) 183 | reconstructed = mesh.get_reconstructed_markers() 184 | self.assertEqual(len(reconstructed["wall"]), 2) # 2 lines 185 | self.assertEqual(len(reconstructed["inlet"]), 1) # 1 line 186 | 187 | def test_mesh_with_whitespace_issues(self): 188 | """Test parsing mesh with whitespace issues (like the original problem).""" 189 | mesh_content = """NDIME= 3 190 | NPOIN= 4 4 191 | 0.0000000000 0.0000000000 0.0000000000 0 192 | 1.0000000000 0.0000000000 0.0000000000 1 193 | 0.0000000000 1.0000000000 0.0000000000 2 194 | 0.0000000000 0.0000000000 1.0000000000 3 195 | NELEM= 1 196 | 10 0 1 2 3 0 197 | NMARK= 1 198 | MARKER_TAG= boundary_face 199 | MARKER_ELEMS= 1 200 | 5 0 1 2 201 | """ 202 | 203 | mesh_file = os.path.join(self.temp_dir, "whitespace_mesh.su2") 204 | with open(mesh_file, 'w') as f: 205 | f.write(mesh_content) 206 | 207 | # This should parse without errors despite the whitespace 208 | mesh = parse_mesh(mesh_file) 209 | assert isinstance(mesh, Mesh), "Parsed mesh should be an instance of Mesh" 210 | self.assertEqual(mesh.dim, 3) 211 | self.assertEqual(mesh.vertex_count, 4) 212 | self.assertEqual(mesh.polygon_count, 1) 213 | self.assertEqual(len(mesh.markers), 1) 214 | self.assertIn("boundary_face", mesh.markers) 215 | 216 | def test_round_trip_parse_export(self): 217 | """Test round-trip: parse -> export -> parse.""" 218 | original_content = """NDIME= 3 219 | NPOIN= 4 220 | 0.0000000000 0.0000000000 0.0000000000 0 221 | 1.0000000000 0.0000000000 0.0000000000 1 222 | 0.0000000000 1.0000000000 0.0000000000 2 223 | 0.0000000000 0.0000000000 1.0000000000 3 224 | NELEM= 1 225 | 10 0 1 2 3 0 226 | NMARK= 1 227 | MARKER_TAG= boundary 228 | MARKER_ELEMS= 1 229 | 5 0 1 2 230 | """ 231 | 232 | # Write original file 233 | original_file = os.path.join(self.temp_dir, "original.su2") 234 | with open(original_file, 'w') as f: 235 | f.write(original_content) 236 | 237 | # Parse 238 | mesh1 = parse_mesh(original_file) 239 | assert isinstance(mesh1, Mesh), "Parsed mesh should be an instance of Mesh" 240 | # Export 241 | exported_file = os.path.join(self.temp_dir, "exported.su2") 242 | export_mesh(mesh1, exported_file) 243 | 244 | # Parse exported file 245 | mesh2 = parse_mesh(exported_file) 246 | assert isinstance(mesh2, Mesh), "Parsed mesh should be an instance of Mesh" 247 | # Compare meshes 248 | self.assertEqual(mesh1.dim, mesh2.dim) 249 | self.assertEqual(mesh1.polygon_count, mesh2.polygon_count) 250 | self.assertEqual(mesh1.vertex_count, mesh2.vertex_count) 251 | self.assertEqual(len(mesh1.markers), len(mesh2.markers)) 252 | 253 | # Compare vertices (allowing for small floating point differences) 254 | np.testing.assert_allclose(mesh1.vertices, mesh2.vertices, rtol=1e-6) 255 | 256 | # Compare cell types 257 | np.testing.assert_array_equal(mesh1.cell_types, mesh2.cell_types) 258 | 259 | # Compare markers 260 | markers1 = mesh1.get_reconstructed_markers() 261 | markers2 = mesh2.get_reconstructed_markers() 262 | for marker_name in markers1: 263 | self.assertIn(marker_name, markers2) 264 | self.assertEqual(len(markers1[marker_name]), len(markers2[marker_name])) 265 | 266 | def test_meshly_features(self): 267 | """Test that meshly features work correctly.""" 268 | vertices = np.array([ 269 | [0.0, 0.0, 0.0], 270 | [1.0, 0.0, 0.0], 271 | [0.0, 1.0, 0.0] 272 | ], dtype=np.float32) 273 | 274 | indices = np.array([0, 1, 2], dtype=np.uint32) 275 | cell_types = np.array([SU2ElementType.TRIANGLE.value], dtype=np.uint32) 276 | index_sizes = np.array([3], dtype=np.uint32) 277 | 278 | mesh = Mesh( 279 | vertices=vertices, 280 | indices=indices, 281 | cell_types=cell_types, 282 | index_sizes=index_sizes, 283 | dim=3 284 | ) 285 | 286 | # Test basic meshly properties 287 | self.assertEqual(mesh.vertex_count, 3) 288 | self.assertEqual(mesh.index_count, 3) 289 | self.assertEqual(mesh.polygon_count, 1) 290 | 291 | # Test model serialization 292 | mesh_dict = mesh.model_dump() 293 | self.assertIn("vertices", mesh_dict) 294 | self.assertIn("indices", mesh_dict) 295 | self.assertIn("cell_types", mesh_dict) 296 | self.assertIn("dim", mesh_dict) 297 | 298 | def test_hexahedron_mesh(self): 299 | """Test mesh with hexahedral elements.""" 300 | # Create a simple cube with one hexahedron 301 | vertices = np.array([ 302 | [0.0, 0.0, 0.0], # 0 303 | [1.0, 0.0, 0.0], # 1 304 | [1.0, 1.0, 0.0], # 2 305 | [0.0, 1.0, 0.0], # 3 306 | [0.0, 0.0, 1.0], # 4 307 | [1.0, 0.0, 1.0], # 5 308 | [1.0, 1.0, 1.0], # 6 309 | [0.0, 1.0, 1.0] # 7 310 | ], dtype=np.float32) 311 | 312 | # Hexahedron indices (8 vertices) 313 | indices = np.array([0, 1, 2, 3, 4, 5, 6, 7], dtype=np.uint32) 314 | cell_types = np.array([SU2ElementType.HEXAHEDRON.value], dtype=np.uint32) 315 | index_sizes = np.array([8], dtype=np.uint32) 316 | 317 | mesh = Mesh( 318 | vertices=vertices, 319 | indices=indices, 320 | cell_types=cell_types, 321 | index_sizes=index_sizes, 322 | dim=3 323 | ) 324 | 325 | self.assertEqual(mesh.polygon_count, 1) 326 | self.assertEqual(mesh.vertex_count, 8) 327 | self.assertEqual(len(mesh.cell_types), 1) 328 | self.assertEqual(mesh.cell_types[0], SU2ElementType.HEXAHEDRON.value) 329 | 330 | def test_empty_mesh(self): 331 | """Test handling of empty mesh.""" 332 | vertices = np.array([], dtype=np.float32).reshape(0, 3) 333 | indices = np.array([], dtype=np.uint32) 334 | 335 | mesh = Mesh( 336 | vertices=vertices, 337 | indices=indices, 338 | dim=3 339 | ) 340 | 341 | self.assertEqual(mesh.vertex_count, 0) 342 | self.assertEqual(mesh.index_count, 0) 343 | 344 | def test_multizone_parsing(self): 345 | """Test parsing of multizone mesh files.""" 346 | multizone_content = """NZONE= 2 347 | 348 | IZONE= 1 349 | NDIME= 2 350 | NELEM= 2 351 | 5 0 1 2 0 352 | 5 0 2 3 1 353 | NPOIN= 4 354 | 0.0 0.0 0 355 | 1.0 0.0 1 356 | 1.0 1.0 2 357 | 0.0 1.0 3 358 | NMARK= 1 359 | MARKER_TAG= wall1 360 | MARKER_ELEMS= 2 361 | 3 0 1 362 | 3 2 3 363 | 364 | IZONE= 2 365 | NDIME= 2 366 | NELEM= 1 367 | 5 0 1 2 0 368 | NPOIN= 3 369 | 2.0 0.0 0 370 | 3.0 0.0 1 371 | 2.5 1.0 2 372 | NMARK= 1 373 | MARKER_TAG= wall2 374 | MARKER_ELEMS= 1 375 | 3 0 1 376 | """ 377 | 378 | # Write to temporary file 379 | mesh_file = os.path.join(self.temp_dir, "multizone_mesh.su2") 380 | with open(mesh_file, 'w') as f: 381 | f.write(multizone_content) 382 | 383 | # Parse the mesh 384 | result = parse_mesh(mesh_file) 385 | assert isinstance(result, List), "Parsed result should be a list of Mesh" 386 | 387 | # Should return a list of meshes 388 | self.assertIsInstance(result, list, "Multizone mesh should return a list") 389 | self.assertEqual(len(result), 2, "Should have 2 zones") 390 | 391 | # Check first zone 392 | zone1 = result[0] 393 | self.assertIsInstance(zone1, Mesh, "Zone 1 should be Mesh") 394 | self.assertEqual(zone1.dim, 2, "Zone 1 dim should be 2") 395 | self.assertEqual(zone1.polygon_count, 2, "Zone 1 should have 2 elements") 396 | self.assertEqual(zone1.vertex_count, 4, "Zone 1 should have 4 points") 397 | self.assertEqual(len(zone1.markers), 1, "Zone 1 should have 1 marker") 398 | self.assertIn('wall1', zone1.markers, "Zone 1 should have wall1 marker") 399 | 400 | # Check second zone 401 | zone2 = result[1] 402 | self.assertIsInstance(zone2, Mesh, "Zone 2 should be Mesh") 403 | self.assertEqual(zone2.dim, 2, "Zone 2 dim should be 2") 404 | self.assertEqual(zone2.polygon_count, 1, "Zone 2 should have 1 element") 405 | self.assertEqual(zone2.vertex_count, 3, "Zone 2 should have 3 points") 406 | self.assertEqual(len(zone2.markers), 1, "Zone 2 should have 1 marker") 407 | self.assertIn('wall2', zone2.markers, "Zone 2 should have wall2 marker") 408 | 409 | def test_single_zone_backward_compatibility(self): 410 | """Test that single zone files still work (backward compatibility).""" 411 | # This test uses the existing simple mesh content from test_simple_mesh_parsing 412 | mesh_content = """NDIME= 2 413 | NPOIN= 4 414 | 0.0000000000 0.0000000000 0 415 | 1.0000000000 0.0000000000 1 416 | 1.0000000000 1.0000000000 2 417 | 0.0000000000 1.0000000000 3 418 | NELEM= 2 419 | 5 0 1 2 0 420 | 5 0 2 3 1 421 | NMARK= 2 422 | MARKER_TAG= wall 423 | MARKER_ELEMS= 2 424 | 3 0 1 425 | 3 2 3 426 | MARKER_TAG= inlet 427 | MARKER_ELEMS= 1 428 | 3 1 2 429 | """ 430 | 431 | # Write to temporary file 432 | mesh_file = os.path.join(self.temp_dir, "single_zone_mesh.su2") 433 | with open(mesh_file, 'w') as f: 434 | f.write(mesh_content) 435 | 436 | # Parse the mesh 437 | result = parse_mesh(mesh_file) 438 | assert isinstance(result, Mesh), "Parsed mesh should be an instance of Mesh" 439 | # Should return a single mesh, not a list 440 | self.assertIsInstance(result, Mesh, "Single zone mesh should return Mesh directly") 441 | self.assertEqual(result.dim, 2, "Single zone dim should be 2") 442 | self.assertEqual(result.polygon_count, 2, "Single zone should have 2 elements") 443 | self.assertEqual(result.vertex_count, 4, "Single zone should have 4 points") 444 | self.assertEqual(len(result.markers), 2, "Single zone should have 2 markers") 445 | self.assertIn('wall', result.markers, "Single zone should have wall marker") 446 | self.assertIn('inlet', result.markers, "Single zone should have inlet marker") 447 | 448 | def test_multizone_with_different_dimensions(self): 449 | """Test multizone mesh with zones having different dimensions.""" 450 | multizone_mixed_content = """NZONE= 2 451 | 452 | IZONE= 1 453 | NDIME= 2 454 | NELEM= 1 455 | 5 0 1 2 0 456 | NPOIN= 3 457 | 0.0 0.0 0 458 | 1.0 0.0 1 459 | 0.5 1.0 2 460 | NMARK= 1 461 | MARKER_TAG= boundary2d 462 | MARKER_ELEMS= 1 463 | 3 0 1 464 | 465 | IZONE= 2 466 | NDIME= 3 467 | NELEM= 1 468 | 10 0 1 2 3 0 469 | NPOIN= 4 470 | 0.0 0.0 0.0 0 471 | 1.0 0.0 0.0 1 472 | 0.5 1.0 0.0 2 473 | 0.5 0.5 1.0 3 474 | NMARK= 1 475 | MARKER_TAG= boundary3d 476 | MARKER_ELEMS= 1 477 | 5 0 1 2 478 | """ 479 | 480 | # Write to temporary file 481 | mesh_file = os.path.join(self.temp_dir, "mixed_dimension_mesh.su2") 482 | with open(mesh_file, 'w') as f: 483 | f.write(multizone_mixed_content) 484 | 485 | # Parse the mesh 486 | result = parse_mesh(mesh_file) 487 | assert isinstance(result, list), "Parsed result should be a list of Mesh" 488 | 489 | # Should return a list of meshes 490 | self.assertIsInstance(result, list, "Multizone mesh should return a list") 491 | self.assertEqual(len(result), 2, "Should have 2 zones") 492 | 493 | # Check 2D zone 494 | zone1 = result[0] 495 | self.assertEqual(zone1.dim, 2, "Zone 1 should be 2D") 496 | self.assertEqual(zone1.polygon_count, 1, "Zone 1 should have 1 element") 497 | self.assertIn('boundary2d', zone1.markers, "Zone 1 should have boundary2d marker") 498 | 499 | # Check 3D zone 500 | zone2 = result[1] 501 | self.assertEqual(zone2.dim, 3, "Zone 2 should be 3D") 502 | self.assertEqual(zone2.polygon_count, 1, "Zone 2 should have 1 element") 503 | self.assertIn('boundary3d', zone2.markers, "Zone 2 should have boundary3d marker") 504 | 505 | 506 | if __name__ == '__main__': 507 | unittest.main() 508 | --------------------------------------------------------------------------------