├── requirements.txt ├── resource ├── images │ ├── banner_1_full.png │ ├── banner_1_thumb.png │ ├── example_simple_1.png │ ├── example_simple_2.png │ ├── example_simple_3.png │ └── example_advanced_1.png └── rocket_stubby.csm ├── src ├── write_mapbc.py ├── extrude_config.py ├── parse_dat.py ├── gmsh_helpers.py ├── gen_blmesh.py ├── gen_farfield.py └── ugrid_tools.py ├── LICENSE ├── example_simple.py ├── example_advanced.py ├── .gitignore └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | gmsh==4.13.1 2 | matplotlib==3.8.4 3 | numpy==2.1.0 4 | pandas==2.2.2 5 | scipy==1.14.1 6 | -------------------------------------------------------------------------------- /resource/images/banner_1_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliottmckee/cfd-meshman/HEAD/resource/images/banner_1_full.png -------------------------------------------------------------------------------- /resource/images/banner_1_thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliottmckee/cfd-meshman/HEAD/resource/images/banner_1_thumb.png -------------------------------------------------------------------------------- /resource/images/example_simple_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliottmckee/cfd-meshman/HEAD/resource/images/example_simple_1.png -------------------------------------------------------------------------------- /resource/images/example_simple_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliottmckee/cfd-meshman/HEAD/resource/images/example_simple_2.png -------------------------------------------------------------------------------- /resource/images/example_simple_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliottmckee/cfd-meshman/HEAD/resource/images/example_simple_3.png -------------------------------------------------------------------------------- /resource/images/example_advanced_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliottmckee/cfd-meshman/HEAD/resource/images/example_advanced_1.png -------------------------------------------------------------------------------- /src/write_mapbc.py: -------------------------------------------------------------------------------- 1 | 2 | def write_mapbc(filename, tags, bctypes, names): 3 | 4 | lines = [f'{tag}\t{bc}\t{name}\n' for tag, bc, name in zip(tags, bctypes, names)] 5 | 6 | with open(filename, 'w') as f: 7 | f.write(f'{len(tags)}\n') 8 | f.writelines(lines) 9 | 10 | if __name__ == '__main__': 11 | write_mapbc('t16.mapbc', tags=[1,3], bctypes=[3000, 5050],names=['wall', 'farfield']) 12 | -------------------------------------------------------------------------------- /src/extrude_config.py: -------------------------------------------------------------------------------- 1 | EXTRUDE_CONFIG = '''input_file: {surfmesh_stem}.vtp 2 | layers_file: layers.csv 3 | output_file: {blmesh_ugrid} 4 | direction: nominal 5 | smooth_normals: true 6 | smooth_normals_iterations: 25 7 | smooth_null_space: true 8 | null_space_iterations: 20 9 | null_space_limit: 2.0 10 | null_space_relaxation: 0.5 11 | eigenvector_threshold: 0.5 12 | smooth_curvature: true 13 | curvature_factor: 4.5 14 | symmetry: none 15 | symmetry_threshold: 2.0 16 | unmodified_layers: 3 17 | debug_mode: false 18 | debug_start_layer: 1''' 19 | 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 elliottmckee 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 | -------------------------------------------------------------------------------- /src/parse_dat.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | import matplotlib.pyplot as plt 5 | 6 | 7 | def parse_dat(filename): 8 | 9 | with open(filename, 'r') as fid: 10 | fid.readline() 11 | 12 | var_line = fid.readline() 13 | var_line = var_line.replace('VARIABLES=', '') 14 | var_line = var_line.replace('"\n', '') 15 | varnames = [ var.strip('"')for var in var_line.split(' ')] 16 | 17 | fid.readline() 18 | 19 | return pd.read_csv(fid, sep=' ', names=varnames, engine='python') 20 | 21 | 22 | 23 | def plot_residuals(df): 24 | 25 | for col in df.columns: 26 | if 'R_' in col: 27 | 28 | plt.figure() 29 | plt.plot(df['Iteration'], df[col]) 30 | 31 | plt.yscale('log') 32 | plt.xlabel('Iteration') 33 | plt.ylabel('Value') 34 | plt.title(col) 35 | 36 | plt.show() 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | if __name__ == '__main__': 51 | 52 | data = parse_dat('Fin_Can_Stubby_trisurf_v1p2_FINAL_hist.dat') 53 | 54 | plot_residuals(data) 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /example_simple.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Example driver showing simplified end-to-end workflow 3 | 4 | NOTE 5 | - Mesh build is performed wherever the .msh file is by default (so for this script, in resource/) 6 | - There are a lot of intermediate files that get written in this workflow, that arent cleaned up. 7 | ''' 8 | import os 9 | from src.ugrid_tools import UMesh 10 | from src.gen_blmesh import gen_blmesh 11 | from src.gen_farfield import gen_farfield 12 | 13 | # input .msh surface mesh 14 | # can make in gmsh gui, or alternatively, use gmsh python api (see advanced example) 15 | # must export from GMSH as version 2 ascii! 16 | SurfMesh = UMesh('resource/rocket_stubby_surf.msh') 17 | 18 | # convert surface mesh from .msh to ugrid 19 | SurfMesh.write('resource/rocket_stubby_surf.ugrid') 20 | 21 | # mesh_tools: extrude BoundaryLayer mesh from surface mesh, convert back to .msh 22 | BoundLayerMesh = gen_blmesh('resource/rocket_stubby_surf.ugrid', num_bl_layers=10, near_wall_spacing=4.2e-5, bl_growth_rate=1.5) 23 | BoundLayerMesh.write('resource/rocket_stubby_BLMESH.msh') 24 | 25 | # gmsh: generate BoundaryLayer+Farfield mesh, by building around/outward-from the boundary layer mesh 26 | VolumeMesh = gen_farfield('resource/rocket_stubby_BLMESH.msh', farfield_radius=15, farfield_Lc=25, extend_power=.2) 27 | 28 | # finally, convert volume mesh from .msh to .ugrid 29 | VolumeMesh.write('resource/rocket_stubby_VOLMESH_FINAL.ugrid') 30 | 31 | -------------------------------------------------------------------------------- /src/gmsh_helpers.py: -------------------------------------------------------------------------------- 1 | import gmsh 2 | 3 | 4 | def sphere_surf(x, y, z, r, lc, surf_tag, physical_group): 5 | # This function will create a spherical shell (surf_tag) 6 | 7 | p1 = gmsh.model.geo.addPoint(x, y, z, lc) 8 | p2 = gmsh.model.geo.addPoint(x + r, y, z, lc) 9 | p3 = gmsh.model.geo.addPoint(x, y + r, z, lc) 10 | p4 = gmsh.model.geo.addPoint(x, y, z + r, lc) 11 | p5 = gmsh.model.geo.addPoint(x - r, y, z, lc) 12 | p6 = gmsh.model.geo.addPoint(x, y - r, z, lc) 13 | p7 = gmsh.model.geo.addPoint(x, y, z - r, lc) 14 | 15 | c1 = gmsh.model.geo.addCircleArc(p2, p1, p7) 16 | c2 = gmsh.model.geo.addCircleArc(p7, p1, p5) 17 | c3 = gmsh.model.geo.addCircleArc(p5, p1, p4) 18 | c4 = gmsh.model.geo.addCircleArc(p4, p1, p2) 19 | c5 = gmsh.model.geo.addCircleArc(p2, p1, p3) 20 | c6 = gmsh.model.geo.addCircleArc(p3, p1, p5) 21 | c7 = gmsh.model.geo.addCircleArc(p5, p1, p6) 22 | c8 = gmsh.model.geo.addCircleArc(p6, p1, p2) 23 | c9 = gmsh.model.geo.addCircleArc(p7, p1, p3) 24 | c10 = gmsh.model.geo.addCircleArc(p3, p1, p4) 25 | c11 = gmsh.model.geo.addCircleArc(p4, p1, p6) 26 | c12 = gmsh.model.geo.addCircleArc(p6, p1, p7) 27 | 28 | l1 = gmsh.model.geo.addCurveLoop([c5, c10, c4]) 29 | l2 = gmsh.model.geo.addCurveLoop([c9, -c5, c1]) 30 | l3 = gmsh.model.geo.addCurveLoop([c12, -c8, -c1]) 31 | l4 = gmsh.model.geo.addCurveLoop([c8, -c4, c11]) 32 | l5 = gmsh.model.geo.addCurveLoop([-c10, c6, c3]) 33 | l6 = gmsh.model.geo.addCurveLoop([-c11, -c3, c7]) 34 | l7 = gmsh.model.geo.addCurveLoop([-c2, -c7, -c12]) 35 | l8 = gmsh.model.geo.addCurveLoop([-c6, -c9, c2]) 36 | 37 | s1 = gmsh.model.geo.addSurfaceFilling([l1]) 38 | s2 = gmsh.model.geo.addSurfaceFilling([l2]) 39 | s3 = gmsh.model.geo.addSurfaceFilling([l3]) 40 | s4 = gmsh.model.geo.addSurfaceFilling([l4]) 41 | s5 = gmsh.model.geo.addSurfaceFilling([l5]) 42 | s6 = gmsh.model.geo.addSurfaceFilling([l6]) 43 | s7 = gmsh.model.geo.addSurfaceFilling([l7]) 44 | s8 = gmsh.model.geo.addSurfaceFilling([l8]) 45 | 46 | sl = gmsh.model.geo.addSurfaceLoop([s1, s2, s3, s4, s5, s6, s7, s8], surf_tag) 47 | 48 | gmsh.model.geo.synchronize() 49 | gmsh.model.addPhysicalGroup(2, [s1, s2, s3, s4, s5, s6, s7, s8], physical_group) 50 | return sl 51 | 52 | 53 | def collect_size_fields(size_fields_dict): 54 | # GMSH didn't like me trying to pass size fields directly through/into functions that build meshes 55 | # so just using a dictionary outside the function + this helper to mirror this functionality 56 | # in a pythonic fashion 57 | 58 | gmsh_size_fields = []; 59 | 60 | # for each size field 61 | for size_field, params in size_fields_dict.items(): 62 | sf_curr = gmsh.model.mesh.field.add(size_field) 63 | gmsh_size_fields.append(sf_curr) 64 | 65 | # for each parameter in size field 66 | for param, val in params.items(): 67 | gmsh.model.mesh.field.setNumber(sf_curr, param, val) 68 | 69 | return gmsh_size_fields 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/gen_blmesh.py: -------------------------------------------------------------------------------- 1 | import os 2 | import csv 3 | import subprocess 4 | import warnings 5 | from pathlib import Path 6 | 7 | from .ugrid_tools import UMesh 8 | from .extrude_config import EXTRUDE_CONFIG 9 | 10 | 11 | def gen_blmesh(surfmesh_ugrid_path, num_bl_layers=10, near_wall_spacing=1e-4, bl_growth_rate=1.3, write_vtk=False): 12 | ''' 13 | TODO: 14 | - MAKE THIS USE A TEMP DIR? Add cleanup command? 15 | - more explicit warnings if subprocess calls fail 16 | - Allow parameter overwrites, or pointing to new default extrude inputs file 17 | - Remove clunky interaction with mesh_tools mesh_convert.py, and just add VTP writer to Umesh 18 | - Output stream of extrude command to pipe for realtime outputs 19 | 20 | NOTES: 21 | - All work is performed in directory where surface mesh file lives 22 | 23 | INPUTS: 24 | surfmesh_ugrid_path: str, path to .ugrid surface mesh file to exrude 25 | 26 | OUTPUTS: 27 | 28 | blmesh_msh: str, path to output BL mesh file in .msh format 29 | ''' 30 | 31 | # input checking 32 | if not surfmesh_ugrid_path.endswith('.ugrid'): 33 | raise TypeError('input must be a .ugrid') 34 | 35 | # paths 36 | base_dir = os.getcwd() 37 | work_dir = os.path.dirname(surfmesh_ugrid_path) # forcing to build mesh location for now 38 | 39 | surfmesh_stem = Path(surfmesh_ugrid_path).stem 40 | blmesh_ugrid = f'{surfmesh_stem}_BLMESH.ugrid' 41 | blmesh_vtk = f'{surfmesh_stem}_BLMESH.vtk' 42 | blmesh_ugrid_path = os.path.join(work_dir, blmesh_ugrid) 43 | blmesh_vtk_path = os.path.join(work_dir, blmesh_vtk) 44 | 45 | # generate layer spacing 46 | layers = [near_wall_spacing] 47 | for i in range(0, num_bl_layers): 48 | layers.append(layers[i]*bl_growth_rate) 49 | print(f'Layers: {layers}') 50 | 51 | # cd into "workdir" 52 | os.chdir(work_dir) 53 | 54 | # Call mesh_tools mesh_convert.py to convert .ugrid to .vtp 55 | print('Calling mesh_tools mesh_convert to convert .ugrid to .vtp...\n') 56 | cmd = f'conda run --verbose -n mesh_tools_p3p7 mesh_convert.py -i {surfmesh_stem}.ugrid' 57 | result = subprocess.run(cmd, shell=True, capture_output=True, text=True) 58 | print(result.stdout); print(result.stderr) 59 | 60 | # Write extrude.inputs 61 | with open('extrude.inputs', 'w') as fid: 62 | for line in iter(EXTRUDE_CONFIG.splitlines()): 63 | fid.write(eval(f"f'{line}'")+"\n") 64 | 65 | # Write layers.csv 66 | with open('layers.csv', 'w') as fid: 67 | write = csv.writer(fid) 68 | write.writerow(layers) 69 | 70 | # Call mesh_tools extrude to get BL mesh 71 | print('Calling mesh_tools extrude...\n') 72 | cmd = 'extrude' 73 | result = subprocess.run(cmd, shell=True, capture_output=True, text=True) 74 | print(result.stdout); print(result.stderr) 75 | 76 | # hacky vtk conversion, since its better at visualization than GMSH 77 | if write_vtk: 78 | print(f'Converting BL mesh to VTK using meshio (workaround)...\n') 79 | cmd = f'meshio convert {blmesh_ugrid} {blmesh_vtk}' 80 | result = subprocess.run(cmd, shell=True, capture_output=True, text=True) 81 | print(result.stdout); print(result.stderr) 82 | 83 | # Read in resultant mesh 84 | BLMesh = UMesh(blmesh_ugrid) 85 | 86 | # Change back to base dir 87 | os.chdir(base_dir) 88 | 89 | return BLMesh 90 | 91 | -------------------------------------------------------------------------------- /example_advanced.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Example driver showing advanced end-to-end workflow highlighting 3 | - using gmsh api for "semi-automated" surface meshing 4 | - gmsh size fields common between surface and volume 5 | 6 | NOTE 7 | - Mesh build is performed wherever the .msh file is by default (so for this script, in resource/) 8 | - There are a lot of intermediate files that get written in this workflow, that arent cleaned up. 9 | ''' 10 | 11 | import os 12 | import gmsh 13 | from src.ugrid_tools import UMesh 14 | from src.gen_blmesh import gen_blmesh 15 | from src.gen_farfield import gen_farfield 16 | from src.gmsh_helpers import collect_size_fields 17 | 18 | 19 | ############################ 20 | # SURFACE MESH WITH GMSH API 21 | ### 22 | gmsh.initialize() 23 | gmsh.option.setNumber("General.NumThreads", 4) 24 | 25 | # merge in BL mesh file 26 | gmsh.merge('resource/rocket_stubby.step') 27 | gmsh.model.geo.synchronize() 28 | 29 | # lets selectively refine *a few* of the point sizings around the fin edge radii 30 | points_refine = [68,69,102,103,106,107,72,73,88,89,114,115,84,85,111,110,56,57,94,95,90,91,52,53,33,34,75,76,79,80,37,38,25,26,64,41,42,64,6,7,51,18,19,27,25,26,64] 31 | points_dimTags = [(0, point) for point in points_refine] 32 | gmsh.model.mesh.setSize(points_dimTags, 0.008) 33 | 34 | # Lets pass some additional size-fields in 35 | # This will get applied congrously to the surface and volume meshes! 36 | # This dictionary mirrors how you specify size fields in the GMSH api 37 | size_fields_dict = {'Cylinder':{'VIn': 0.02, 38 | 'VOut': 1e22, 39 | 'XAxis': 0.45, 40 | 'YAxis': 0.0, 41 | 'ZAxis': 0.0, 42 | 'XCenter': -0.1, 43 | 'YCenter': 0.0, 44 | 'ZCenter': 0.0, 45 | 'Radius': 0.3}, 46 | 'Ball':{'Radius': 0.05, 47 | 'Thickness':0.05, 48 | 'VIn': 0.015, 49 | 'VOut': 1e22, 50 | 'XCenter': 0.718}} 51 | 52 | # have to massage a bit into gmsh format 53 | size_field_tags = collect_size_fields(size_fields_dict) 54 | 55 | # take min of all size fields 56 | min_all_sizefields = gmsh.model.mesh.field.add("Min") 57 | gmsh.model.mesh.field.setNumbers(min_all_sizefields, "FieldsList", size_field_tags) 58 | gmsh.model.mesh.field.setAsBackgroundMesh(min_all_sizefields) 59 | 60 | # gmsh options 61 | gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 1) 62 | gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 1) 63 | gmsh.option.setNumber("Mesh.MeshSizeMax", 0.03) 64 | 65 | # generate mesh 66 | gmsh.model.mesh.generate(2) 67 | 68 | # save out 69 | gmsh.option.setNumber("Mesh.MshFileVersion", 2.2) 70 | gmsh.write('resource/rocket_stubby_advanced.msh') 71 | gmsh.finalize() 72 | ############################ 73 | 74 | 75 | # convert surface mesh from .msh to ugrid 76 | SurfMesh = UMesh('resource/rocket_stubby_advanced.msh') 77 | SurfMesh.write('resource/rocket_stubby_advanced.ugrid') 78 | 79 | # mesh_tools: extrude BoundaryLayer mesh from surface mesh, convert back to .msh 80 | BoundLayerMesh = gen_blmesh('resource/rocket_stubby_advanced.ugrid', num_bl_layers=9, near_wall_spacing=4.2e-5, bl_growth_rate=1.5) 81 | BoundLayerMesh.write('resource/rocket_stubby_advanced_BLMESH.msh') 82 | 83 | # gmsh: generate BoundaryLayer+Farfield mesh, by building around/outward-from the boundary layer mesh 84 | VolumeMesh = gen_farfield('resource/rocket_stubby_advanced_BLMESH.msh', farfield_radius=15, farfield_Lc=25, extend_power=.2, size_fields_dict=size_fields_dict) 85 | 86 | # finally, convert volume mesh from .msh to .ugrid 87 | VolumeMesh.write('resource/rocket_stubby_advanced_VOLMESH_FINAL.ugrid') 88 | 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | # Project-specific 165 | /workdirs 166 | /src/mesh_io.py 167 | -------------------------------------------------------------------------------- /src/gen_farfield.py: -------------------------------------------------------------------------------- 1 | import os 2 | import math 3 | import csv 4 | import subprocess 5 | import warnings 6 | import gmsh 7 | import numpy as np 8 | 9 | from pathlib import Path 10 | 11 | from .gmsh_helpers import sphere_surf, collect_size_fields 12 | from .ugrid_tools import UMesh 13 | 14 | 15 | def gen_farfield(bl_msh_path, farfield_radius=10, farfield_Lc=2, extend_power=0.5, numthreads=4, size_fields_dict={}): 16 | ''' 17 | TODO: 18 | - I TRIED TO MAKE THIS WORK WITH OPENCASCADE BUT WAS HAVING ISSUES. AM PROBABLY JUST DUMB. TRY AGAIN LATER 19 | See e.g. 't1.py', `t16.py', `t18.py', `t19.py' or `t20.py' for complete examples based on OpenCASCADE, and `examples/api' for more. 20 | - Robustify identification of which tag represents the boundary layer interface/top-cap. 21 | - Clean up "Extend" field implementation 22 | - Allow size fields to be passed-in 23 | - Do in tempdir? Add file cleanup functionality? 24 | 25 | INPUTS: 26 | bl_msh_path: str, path to .msh formatted boundary layer mesh (from gen_blmesh.py) 27 | 28 | OUTPUTS: 29 | VolMesh: UMesh, volume mesh (boundary layer + farfield) 30 | 31 | NOTES: 32 | - If things break below- it is likely due to assumptions about surface tagging, mainly for the BLMESH outer-most interface/top-cap surface. Double check these assumptions if having issues 33 | 34 | GMSH tidbits 35 | # t10.py, Mesh size fields 36 | # Finally, while the default "Frontal-Delaunay" 2D meshing algorithm 37 | # (Mesh.Algorithm = 6) usually leads to the highest quality meshes, the 38 | # "Delaunay" algorithm (Mesh.Algorithm = 5) will handle complex mesh size fields 39 | # better - in particular size fields with large element size gradients: 40 | 41 | # t12.py 42 | # // "Compound" meshing constraints allow to generate meshes across surface 43 | # // boundaries, which can be useful e.g. for imported CAD models (e.g. STEP) with 44 | # // undesired small features. 45 | 46 | # t16.py 47 | # If we had wanted five empty holes we would have used `cut()' again. Here we 48 | # want five spherical inclusions, whose mesh should be conformal with the mesh 49 | # of the cube: we thus use `fragment()', which intersects all volumes in a 50 | # conformal manner (without creating duplicate interfaces): 51 | # ov, ovv = gmsh.model.occ.fragment([(3, 3)], holes) 52 | 53 | # x1.py 54 | # Create a geometry for the discrete curves and surfaces, so that we can remesh 55 | # them later on: 56 | # gmsh.model.mesh.createGeometry() 57 | # Note that for more complicated meshes, e.g. for on input unstructured STL 58 | # mesh, we could use `classifySurfaces()' to automatically create the discrete 59 | # entities and the topology; but we would then have to extract the boundaries 60 | # afterwards. 61 | 62 | ''' 63 | 64 | # paths 65 | volmesh_msh = os.path.splitext(bl_msh_path)[0]+'_VOLMESH.msh' 66 | 67 | # init gmsh 68 | gmsh.initialize() 69 | gmsh.option.setNumber('Geometry.Tolerance', 1e-16) 70 | gmsh.option.setNumber("General.NumThreads", numthreads) 71 | gmsh.model.add("model_1") 72 | 73 | # merge in BL mesh file 74 | gmsh.merge(bl_msh_path) 75 | 76 | # make surface loop based on the tagged surfaces of the blmesh 77 | # (expected: 0 is the geometric/wall surface, 1 is the "top cap" of the Bl mesh, but I don't think that is 100% guaranteed) 78 | # syntax: surfaceTags (vector of integers), tag (integer) 79 | eltag_surf_wall = gmsh.model.geo.addSurfaceLoop([0], 1) 80 | eltag_surf_bl_topcap = gmsh.model.geo.addSurfaceLoop([1], 2) 81 | 82 | # Create Farfield extents 83 | eltag_surf_sphere = sphere_surf(x=0, y=0, z=0, r=farfield_radius, lc=farfield_Lc, surf_tag=50, physical_group=3) 84 | 85 | # Create Farfield volume (between SPHERE farfield extent and BLMESH outer-interface) 86 | eltag_vol_farfield = gmsh.model.geo.addVolume([eltag_surf_sphere, eltag_surf_bl_topcap], 60) 87 | 88 | # have to synchronize before physical groups 89 | gmsh.model.geo.remove_all_duplicates() #not sure if necessary 90 | gmsh.model.geo.synchronize() 91 | 92 | # assign physical groups 93 | # reminder: all meshes in gmsh must have physical group or will not be written (unless you use the write_all_elements flag in gmsh) 94 | # syntax: dim (integer), tags (vector of integers for model entities), tag (integer for physical surface tag), name (string) 95 | phystag_surf_wall = gmsh.model.addPhysicalGroup(2, [0], 1) 96 | phystag_vol_bl = gmsh.model.addPhysicalGroup(3, [0], 1) # FOR THIS BL MESH VOLUME- I ARBITRARILY SET THIS TO 0 IN UGRID READER 97 | phystag_vol_farfield = gmsh.model.addPhysicalGroup(3, [60], 61) # FARFIELD MESH VOLUME 98 | 99 | # Extend size field, see extend_field.py example 100 | # Can't figure out how to get this field to act on sphere farfield. But we can just be kinda smart about 101 | # how we set DistMax and SizeMax, relative to the sphere size to get basically the same result 102 | f_extend = gmsh.model.mesh.field.add("Extend") 103 | gmsh.model.mesh.field.setNumbers(f_extend, "SurfacesList", [1]) 104 | # # gmsh.model.mesh.field.setNumbers(f, "CurvesList", [e[1] for e in gmsh.model.getEntities(1)]) 105 | gmsh.model.mesh.field.setNumber(f_extend, "DistMax", farfield_radius) 106 | gmsh.model.mesh.field.setNumber(f_extend, "SizeMax", farfield_Lc) 107 | gmsh.model.mesh.field.setNumber(f_extend, "Power", extend_power) 108 | 109 | # Collect additional size fields 110 | f_additional_size_fields = collect_size_fields(size_fields_dict) 111 | 112 | # take min across all size fields 113 | f_min_all = gmsh.model.mesh.field.add("Min") 114 | gmsh.model.mesh.field.setNumbers(f_min_all, "FieldsList", [f_extend]+f_additional_size_fields) 115 | 116 | # gmsh.model.mesh.field.setAsBackgroundMesh(f_extend) 117 | gmsh.model.mesh.field.setAsBackgroundMesh(f_min_all) 118 | 119 | # Options 120 | gmsh.option.setNumber("Mesh.Algorithm3D", 10) 121 | gmsh.option.setNumber("Mesh.MeshSizeFromPoints", 1) 122 | gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 0) 123 | gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", -2) # Need to force extend from boundary to only occur on 2D surfaces (farfield) 124 | 125 | # Generate 126 | gmsh.model.mesh.generate(3) 127 | 128 | # Postprocess 129 | gmsh.model.mesh.remove_duplicate_nodes() 130 | gmsh.model.mesh.remove_duplicate_elements() 131 | 132 | # Remove interface 133 | gmsh.model.mesh.removeElements(eltag_surf_bl_topcap, 1) 134 | 135 | # Save out 136 | # gmsh.option.setNumber("Mesh.SaveAll", 1) 137 | gmsh.option.setNumber("Mesh.MshFileVersion", 2.2) 138 | gmsh.write(volmesh_msh) 139 | gmsh.finalize() 140 | 141 | # Read back into python as UMesh 142 | VolMesh = UMesh(volmesh_msh) 143 | 144 | return VolMesh 145 | 146 | 147 | -------------------------------------------------------------------------------- /resource/rocket_stubby.csm: -------------------------------------------------------------------------------- 1 | # Engineering Sketchpad 2 | # parametric rocket geo generator 3 | # written by Elliott McKee 4 | # units m 5 | # TODO 6 | # - Make fins a udprim? 7 | 8 | 9 | 10 | 11 | ############################ 12 | # PARAMETERS 13 | ###### 14 | DESPMTR B:diam 6*0.0254 15 | DESPMTR B:len 15*0.0254 16 | 17 | DESPMTR F:Croot 12*0.0254 18 | DESPMTR F:Ctip 5*0.0254 19 | DESPMTR F:Span 6*0.0254 20 | DESPMTR F:th 0.5*0.0254 21 | DESPMTR F:Swp_LE_deg 60 22 | DESPMTR F:Cham_deg 10 23 | DESPMTR F:tip_R .003 # NEED TO COMMENT/UNCOMMENT BELOW WHEN GOING FROM SHARP/ROUND OR VV 24 | 25 | DESPMTR F:fillet .7*0.0254 26 | DESPMTR F:base_offset .75*0.0254 27 | DESPMTR F:num 4 28 | 29 | DESPMTR NOSE:rad 0.02 30 | DESPMTR NOSE:angd 10 31 | 32 | DESPMTR BOAT:len 2*0.0254 33 | DESPMTR BOAT:aft_diam 5*0.0254 34 | 35 | 36 | 37 | 38 | ############################ 39 | # SINGLE FIN 40 | ###### 41 | # Making this ruled instead, for simplicity 42 | # JK, all the grown surfaces cause issues when filleting. Going back to old way 43 | 44 | 45 | ### FIN PLANFORM 46 | SKBEG 0 0 0 47 | linseg F:Croot 0 0 48 | linseg F:Croot-F:Span*tand(F:Swp_LE_deg) F:Span 0 49 | linseg F:Croot-F:Span*tand(F:Swp_LE_deg)-F:Ctip F:Span 0 50 | linseg 0 0 0 51 | SKEND 52 | EXTRUDE 0 0 F:th 53 | TRANSLATE 0 0 -F:th/2 54 | 55 | 56 | ### LEADING EDGE - boolean for chamfers (UGH) 57 | set OFF (F:th/2/tand(F:Cham_deg)) 58 | skbeg F:Croot 0 0 59 | linseg F:Croot-OFF 0 F:th/2 60 | linseg F:Croot-OFF+0.1 0 F:th/2 61 | linseg F:Croot-OFF+0.1 0 -F:th/2 62 | linseg F:Croot-OFF 0 -F:th/2 63 | linseg F:Croot 0 0 64 | skend 65 | 66 | extrude -sind(F:Swp_LE_deg) cosd(F:Swp_LE_deg) 0 67 | subtract 68 | 69 | 70 | ### TRAILING EDGE - boolean for chamfers (UGH) 71 | skbeg 0 0 0 72 | linseg OFF 0 F:th/2 73 | linseg OFF-0.1 0 F:th/2 74 | linseg OFF-0.1 0 -F:th/2 75 | linseg OFF 0 -F:th/2 76 | #linseg -0.1 0 0 77 | linseg 0 0 0 78 | skend 79 | 80 | extrude F:Croot-F:Span*tand(F:Swp_LE_deg)-F:Ctip F:Span 0 81 | subtract 82 | 83 | 84 | ### RADII 85 | IFTHEN F:tip_R GT 0 86 | SELECT EDGE 16 3 16 7 1 87 | SELECT ADD 25 3 25 7 1 88 | fillet F:tip_R @sellist 1 89 | ENDIF 90 | 91 | STORE _FIN 92 | 93 | 94 | ### FILLET IMPRINTER 95 | set dx F:fillet/2 # axial length buffer for fillet, kinda arbitrary 96 | 97 | # draw root cross section 98 | set fx1 -dx 99 | set fx2 F:th/2/tand(F:Cham_deg) 100 | set fx3 F:Croot-fx2 101 | set fx4 F:Croot+dx 102 | 103 | set fz F:th/2+F:fillet 104 | 105 | skbeg fx1 0 0 106 | linseg fx2 0 -fz 107 | linseg fx3 0 -fz 108 | linseg fx4 0 0 109 | linseg fx3 0 fz 110 | linseg fx2 0 fz 111 | linseg fx1 0 0 112 | skend 113 | 114 | EXTRUDE 0 2*F:fillet 0 115 | 116 | # grab min/max x edges to fillet 117 | SELECT EDGE 118 | SELECT SORT $xmin 119 | DIMENSION foo 18 1 120 | SET foo @sellist 121 | SELECT EDGE foo[1] 122 | SELECT ADD foo[18] 123 | ATTRIBUTE _color $red 124 | 125 | 126 | IFTHEN F:tip_R GT 0 127 | fillet 4.0*F:tip_R @sellist 1 128 | ENDIF 129 | 130 | EXTRACT 0 131 | STORE _IMPRINTER 132 | 133 | 134 | 135 | 136 | ############################ 137 | # BODY 138 | ###### 139 | CYLINDER 0 0 0 B:len 0 0 B:diam/2 140 | SELECT FACE 141 | ATTRIBUTE tagComp $body 142 | SELECT EDGE 143 | ATTRIBUTE tagComp $body 144 | STORE _BODY 145 | 146 | 147 | 148 | 149 | ############################ 150 | # FIN ASSEMBLY 151 | ###### 152 | 153 | # workaround, since fin base will be tangent to cylinder, if we dont do the math to correct for it 154 | set OFF_FIN sqrt((B:diam/2)^2-(F:th/2)^2) 155 | set F_deg_inc 360/F:num 156 | 157 | # tried offset udprim with faces and edges, the smart way- it generated, but didnt work as intended 158 | # now imprinting manually 159 | #UDPRIM offset dist F:fillet # I think this is ill defined for things with sharp edges? I dont think we'd want this for imprinter anyways 160 | 161 | 162 | ### IMPRINT FIN 163 | RESTORE _FIN 164 | BOX -1 F:fillet -1 2 0 2 165 | SUBTRACT 166 | TRANSLATE F:base_offset OFF_FIN 0 167 | STORE _FIN_IMPRINTED 168 | 169 | 170 | ### IMPRINT BODY 171 | RESTORE _BODY 172 | RESTORE _IMPRINTER 173 | TRANSLATE F:base_offset OFF_FIN-F:fillet 0 174 | SUBTRACT 175 | STORE _BODY_IMPRINTED 176 | 177 | # Pick faces on FIN 178 | RESTORE _FIN_IMPRINTED 179 | SELECT FACE 180 | SELECT SORT $ymin 181 | DIMENSION foo 9 1 182 | SET foo @sellist 183 | SELECT FACE foo[1] 184 | SELECT ADD foo[2] 185 | SELECT ADD foo[3] 186 | SELECT ADD foo[4] 187 | SELECT ADD foo[5] 188 | SELECT ADD foo[6] 189 | SELECT ADD foo[7] 190 | SELECT ADD foo[8] # COMMENT THIS AND BELOW IF NO LE RADIUS 191 | SELECT ADD foo[9] # 192 | ATTRIBUTE _color $cyan 193 | ATTRIBUTE _flend $remove 194 | 195 | # Pick faces on BODY 196 | RESTORE _BODY_IMPRINTED 197 | SELECT FACE 198 | SELECT SORT $ymax 199 | DIMENSION bar 3 1 200 | SET bar @sellist # idk why this indexing is borked 201 | SELECT FACE bar[3] 202 | #SELECT ADD bar[2] 203 | #SELECT ADD bar[3] 204 | ATTRIBUTE _color $cyan 205 | ATTRIBUTE _flend $remove 206 | 207 | # FLEND 208 | UDPARG flend slopea 1 209 | UDPARG flend slopeb 1 210 | UDPARG flend equis 1 211 | UDPARG flend toler 0.0001 212 | UDPARG flend npnt 12 213 | UDPRIM flend method 2 214 | 215 | 216 | # LETS JUST UNION SHIT TO GET MULTIPLE FINS 217 | STORE _BODY_FIN 218 | RESTORE _BODY_FIN 219 | 220 | PATBEG idx_fin F:num 221 | RESTORE _BODY_FIN 222 | ROTATEX idx_fin*F_deg_inc 223 | UNION 0 0 1e-4 224 | PATEND 225 | 226 | STORE _BODY_FINS 227 | 228 | 229 | 230 | 231 | 232 | ############################ 233 | # BOATTAIL 234 | ###### 235 | 236 | UDPRIM supell rx B:diam/2 ry B:diam/2 n 2.0 237 | 238 | UDPRIM supell rx B:diam/2 ry B:diam/2 n 2.0 239 | TRANSLATE 0 0 -.00001 240 | 241 | UDPRIM supell rx BOAT:aft_diam/2 ry BOAT:aft_diam/2 n 2.0 242 | TRANSLATE 0 0 -BOAT:len 243 | 244 | #RULE 245 | BLEND 246 | 247 | ROTATEY 90 248 | 249 | RESTORE _BODY_FINS 250 | UNION 0 0 1e-4 251 | 252 | STORE _BODY_FINS_BOAT 253 | 254 | 255 | 256 | 257 | 258 | ############################ 259 | # NOSECONE - SPHERICALLY BLUNTED 260 | ###### 261 | # See wikipedia: https://en.wikipedia.org/wiki/Nose_cone_design 262 | SET R B:diam/2 263 | SET L R/tand(NOSE:angd) 264 | 265 | SET x_t L^2/R*sqrt(NOSE:rad^2/(R^2+L^2)) 266 | SET y_t x_t*R/L 267 | SET x_0 x_t+sqrt(NOSE:rad^2-y_t^2) 268 | SET x_a x_0-NOSE:rad 269 | 270 | 271 | SKBEG L 0 0 272 | LINSEG L R 0 273 | LINSEG x_t y_t 0 274 | CIRARC x_a 1E-6 0 x_a 0 0 275 | LINSEG L 0 0 276 | SKEND 277 | REVOLVE 0 0 0 1 0 0 180 # doing the mirror approach as getting weird things in GMSH 278 | STORE _temp 279 | RESTORE _temp 280 | MIRROR 0 0 1 281 | RESTORE _temp 282 | UNION 0 0 1e-4 283 | ROTATEZ 180 284 | TRANSLATE L 0 0 285 | TRANSLATE B:len 0 0 286 | 287 | # Attach to body 288 | RESTORE _BODY_FINS_BOAT 289 | UNION 0 0 1e-4 290 | 291 | 292 | 293 | ############################ 294 | # OUTPUT 295 | ###### 296 | #DUMP $/dump_1.brep 0 0 0 297 | DUMP $/rocket_stubby.step 0 0 0 298 | 299 | 300 | 301 | end 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://github.com/elliottmckee/cfd-meshman/blob/main/resource/images/banner_1_full.png?raw=true) 2 | 3 | # cfd-meshman 4 | 5 | Update 9/12/2025 - I am currently working on a unified tool in Rust so this workflow is less clunky. 6 | 7 | 3D unstructured mesh manufacturing/manipulation for a (currently) GMSH+NASA Mesh_Tools viscous CFD meshing stack: 8 | - [GMSH](https://gmsh.info/) for surface meshing 9 | - [NASA Mesh_Tools](https://software.nasa.gov/software/MSC-26648-1) for extruded boundary layer meshes 10 | - [GMSH](https://gmsh.info/) for farfield volume meshing 11 | 12 | cfd-meshman consists of the python tools required to get these components to talk to each other, to build combined extruded-boundary-layer + farfield volume meshes with only a handful of lines of code (see [example_simple.py](https://github.com/elliottmckee/cfd-meshman/blob/main/example_simple.py)). 13 | 14 | **The goal here is to try and create an acceptable viscous-CFD meshing workflow that is 100% free** (assuming you can access Mesh_Tools through the [NASA Software Catalog/Technology Transfer Program](https://software.nasa.gov/)). 15 | 16 | The main components/functionalities of cfd-meshman are: 17 | - **Umesh**: a "pythonic" representation of unstructured meshes, that primarily facilitates the conversion of mesh formats (GMSH v2.2 .msh <-> .ugrid) 18 | - **gen_blmesh.py**: given a surface mesh, uses NASA Mesh_Tools to extrude a boundary layer mesh 19 | - **gen_farfield.py**: given a boundary layer mesh (from above), uses GMSH to generate the farfield mesh between the boundary-layer and domain extents- and stitches everything together into a single domain. 20 | 21 | This workflow is by no means perfect. It is a WIP and thus has limitations and can be brittle; but is decent enough for my personal usage. I have tried to make it somewhat modular, so if you have a better solution for any of these steps, you can ideally just use what you need. 22 | 23 | _I welcome any help and/or feedback :)_ 24 | 25 | > [!NOTE] 26 | > LINK TO LONGER-FORM BLOG POST TO BE ADDED HERE 27 | 28 | 29 | 30 | # Limitations 31 | - cfd-meshman only supports triangular surfaces meshes right now. Going to fix shortly. All the other tools (Mesh_Tools, GMSH) should be able to handle quads just fine. 32 | - this spits out meshes in GMSH .msh and/or (FUN3D) .ugrid formats. If you need another format for your solver, would recommend trying [meshio](https://github.com/nschloe/meshio). 33 | - no support for symmetry planes yet, although Mesh_Tools, GMSH should be able to support them. 34 | - this is currently focused on external-aero workflows, and there are a good few assumptions baked-in currently for domain tagging. See Usage below. 35 | 36 | 37 | 38 | # Examples 39 | 40 | ## [example_simple.py](https://github.com/elliottmckee/cfd-meshman/blob/main/example_simple.py) 41 | Just showing the simplest implementation of extruded boundary-layer + tet farfield: 42 | | **Near** | **Detail** | **Far** | 43 | | ----------- | ----------- | ----------- | 44 | | [](https://github.com/elliottmckee/cfd-meshman/blob/main/resource/images/example_simple_1.png?raw=true) | [](https://github.com/elliottmckee/cfd-meshman/blob/main/resource/images/example_simple_2.png?raw=true) | [](https://github.com/elliottmckee/cfd-meshman/blob/main/resource/images/example_simple_3.png?raw=true) | 45 | 46 | ## [example_advanced.py](https://github.com/elliottmckee/cfd-meshman/blob/main/example_advanced.py) 47 | Showing the use of GMSH size fields to do things like refine the fins/wake or increase resolution at nose tip (note that size fields get applied congrously between the surface and volume meshes): 48 | | **Near** | 49 | | ----------- | 50 | | [](https://github.com/elliottmckee/cfd-meshman/blob/main/resource/images/example_advanced_1.png?raw=true) | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | # Installation 60 | I have only used this on Linux. If you're on Windows i'd recommend using WSL2. 61 | 62 | The main dependency here that requires instruction is NASA Mesh_Tools. I recommend getting this installed and verifying it is working before playing around with this repo (as I have somewhat outlined below). 63 | 64 | > [!NOTE] 65 | > If you are having any issues with installation things, please feel free to reach out to me ([elliottmckee](https://github.com/elliottmckee)). 66 | 67 | 68 | ## [NASA Mesh_Tools](https://software.nasa.gov/software/MSC-26648-1) 69 | 70 | > [!CAUTION] 71 | > DO **NOT** USE VERSION 1.2. USE VERSION 1.1 (all versions should be included in the ZIP from the NASA request). The "extrude" functionality of v1.2 did not work for me out of the box, and requires code modifications to make it not segfault. Just use 1.1. 72 | 73 | You have to request this from the NASA Software Catalog. See links above. 74 | 75 | See [this paper](https://ntrs.nasa.gov/api/citations/20190000067/downloads/20190000067.pdf) for more detail on Mesh_Tools itself. 76 | 77 | Unfortunately, this one is a bit complicated to install, but if you follow the README included with it, it should get you up and running. It does requires you to build an older version of [VTK](https://docs.vtk.org/en/latest/build_instructions/index.html) with certain flags enabled, which is inconvenient at best, and can be really horrifying at its worst. 78 | 79 | I recomend just using the default Anaconda install for simplicity (**This is actually required for cfd-meshman to work currently**. I used miniconda personally, but shouldn't really matter though). 80 | 81 | Make sure to add the path to the Mesh_Tools 'extrude' binary (mesh_tools-v1.1.0/bin/extrude) to the system PATH. 82 | 83 | To confirm you've installed this correctly, try running the included extrude_for_char and extrude_shock examples and see if it breaks. 84 | 85 | 86 | ## cfd-meshman 87 | 1. Clone this repo 88 | 2. (recommended) create a virtual environment 89 | 3. cd into repo, `pip install -r requirements.txt` 90 | 4. Once installed, see if the simple cfd-meshman example above works. If any of the examples don't work, you're more than welcome to bug me. 91 | 92 | > [!NOTE] 93 | > The [current implementation](https://github.com/elliottmckee/cfd-meshman/blob/main/src/gen_blmesh.py) relies on the mesh_convert.py functionality included in Mesh_Tools. This is currently invoked using ['conda run'](https://docs.conda.io/projects/conda/en/latest/commands/run.html) functionality. If you have installed Mesh_Tools using conda above, you _shouldn't_ have any issue, **assuming** that the mesh-tools conda environement is named 'mesh_tools_p3p7'. 94 | 95 | 96 | # Usage 97 | - See examples. 98 | - If you need to modify the Mesh_Tools extrusion parameters (this is likely, it can be a bit finicky about these), modify [extrude_config.py](https://github.com/elliottmckee/cfd-meshman/blob/main/src/extrude_config.py). 99 | 100 | 101 | # Tips 102 | Mesh_Tools 'extrude': 103 | - The default extrude_config.py inputs seem to work fine-ish in my experience, but often need fiddling. I don't claim to know what all of them mean, though. 104 | - This will try and extrude as far as you tell it, and maintains a fixed extrude sizing across all elements. If you have a surface mesh with both large and small faces, and you want to extrude the big ones to near-isotropy, you're going to have some very high aspect-ratio cells grown from the smaller faces. 105 | - If extrusion is failing, try: 106 | - reducing size/number of layers, and/or increasing these incrementally 107 | - decreasing null space relaxation 108 | - increasing/decreasing null space iterations (or try disabling it?) 109 | - increasing/decreasing curvature_factor 110 | 111 | > [!NOTE] 112 | > If you're using FUN3D, the wall is tagged with 1, the farfield is tagged with 3 113 | > In MAPBC file format: 114 | ``` 115 | 2 116 | 1 3000 wall 117 | 3 5050 farfield 118 | ``` 119 | 120 | 121 | # Other useful things 122 | - [meshio](https://github.com/nschloe/meshio) _can_ be useful for translating meshes into other formats. This is probably better than my own custom .msh<->.ugrid conversion functions, but I just couldn't get it to do what I needed it to at the time. 123 | - [Paraview](https://www.paraview.org/) is what I use for viewing meshes once completed (after using meshio to convert from .msh to .vtk, for example) 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /src/ugrid_tools.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | import time 4 | import numpy as np 5 | from scipy.spatial import KDTree 6 | 7 | 8 | class UMesh: 9 | ''' 10 | DOESNT HANDLE THE OPTIONAL FLAGS N THINGS 11 | 12 | TODO: Can simplify and make less redundant 13 | - Still kinda think the empty numpy business is funny 14 | - Add negative volume checking 15 | - Get ugrid writer to work with .18e? not .18f? I think mesh_tools is what is breaking when trying to read in exponential formatted ugrid 16 | - VTP writer 17 | ''' 18 | 19 | float_fmt = {'float_kind':lambda x: "%.18f" % x}; 20 | int_fmt = {'int_kind':lambda x: "%i" % x}; # Force ints to not have an extra space 21 | 22 | el_type_node_counts = {'tris':3, 'quads':4, 'tets':4, 'pyrmds':5, 'prisms':6, 'hexes':8} 23 | 24 | gmsh_tag_types = {2:'tris', 3:'quads', 4:'tets', 5:'hexes', 6:'prisms', 7:'pyrmds'} 25 | gmsh_type_tags = {v: k for k, v in gmsh_tag_types.items()} 26 | 27 | def __init__(self, filename=''): 28 | 29 | self.filename = filename 30 | _, self.file_extension = os.path.splitext(self.filename) 31 | 32 | print(f'Reading in meshfile: {self.filename}') 33 | 34 | # Initialize empty numpy arrays for supported element types 35 | self.nodes = np.empty((0, 3), dtype=np.double) 36 | for el_type, nodecount in self.el_type_node_counts.items(): 37 | temp = {'defs': np.empty((0, nodecount), dtype=np.uint32), 38 | 'tags': np.empty((0, 1), dtype=np.uint32)} 39 | setattr(self, el_type, temp) 40 | 41 | if self.file_extension == '.ugrid': 42 | self.read_ugrid() 43 | elif self.file_extension == '.msh': 44 | self.read_gmsh_v2() 45 | else: 46 | raise Exception('Unrecognized mesh file extension!') 47 | 48 | 49 | @property 50 | def num_nodes(self): return self.nodes.shape[0] 51 | @property 52 | def num_tris(self): return self.tris['defs'].shape[0] 53 | @property 54 | def num_quads(self): return self.quads['defs'].shape[0] 55 | @property 56 | def num_tets(self): return self.tets['defs'].shape[0] 57 | @property 58 | def num_pyrmds(self): return self.pyrmds['defs'].shape[0] 59 | @property 60 | def num_prisms(self): return self.prisms['defs'].shape[0] 61 | @property 62 | def num_hexes(self): return self.hexes['defs'].shape[0] 63 | 64 | @property 65 | def num_bdr_elems(self): return self.num_tris + self.num_quads 66 | @property 67 | def num_vol_elems(self): return self.num_tets + self.num_pyrmds + self.num_prisms + self.num_hexes 68 | @property 69 | def num_elements(self): return self.num_bdr_elems + self.num_vol_elems 70 | 71 | @property 72 | def iter_elem_data(self): return [self.tris, self.quads, self.tets, self.pyrmds, self.prisms, self.hexes] 73 | @property 74 | def iter_elem_counts(self): return [self.num_tris, self.num_quads, self.num_tets, self.num_pyrmds, self.num_prisms, self.num_hexes] 75 | @property 76 | def iter_elem_type_strs(self): return ['tris', 'quads', 'tets', 'pyrmds', 'prisms', 'hexes'] 77 | @property 78 | def iter_boundary_data(self): return [self.tris, self.quads] 79 | @property 80 | def iter_volume_data(self): return [self.tets, self.pyrmds, self.prisms, self.hexes] 81 | 82 | def scale(self, scaleFac): 83 | self.nodes = scaleFac * self.nodes 84 | 85 | 86 | 87 | def read_ugrid(self): 88 | ''' 89 | https://www.simcenter.msstate.edu/software/documentation/ug_io/3d_grid_file_type_ugrid.html 90 | 91 | ASSUMES VOLUME TAGS ARE 0 (not modified) 92 | ''' 93 | 94 | with open(self.filename) as ufile: 95 | # get, parse header 96 | header = ufile.readline().strip().split(' ') 97 | 98 | self.nodes = np.resize(self.nodes, (int(header[0]), 3)) 99 | 100 | # resize numpy array based on number of each geometry type 101 | for gdata, gdims, n_geoms in zip(self.iter_elem_data, self.el_type_node_counts.values(), header[1:]): 102 | gdata['defs'] = np.resize(gdata['defs'], (int(n_geoms), gdims)) 103 | gdata['tags'] = np.resize(gdata['tags'], (int(n_geoms), 1)) 104 | 105 | # Get nodes 106 | for i_pts in range(self.num_nodes): 107 | self.nodes[i_pts, :] = ufile.readline().strip().split(' ') 108 | 109 | # Get boundary defs 110 | for geom_data in self.iter_boundary_data: 111 | for i_geom in range(geom_data['defs'].shape[0]): 112 | geom_data['defs'][i_geom, :] = ufile.readline().strip().split(' ') 113 | 114 | # Get boundary tags 115 | for geom_data in self.iter_boundary_data: 116 | for i_geom in range(geom_data['defs'].shape[0]): 117 | geom_data['tags'][i_geom, :] = ufile.readline().strip().split(' ') 118 | 119 | # Get volume defs 120 | for geom_data in self.iter_volume_data: 121 | for i_geom in range(geom_data['defs'].shape[0]): 122 | geom_data['defs'][i_geom, :] = ufile.readline().strip().split(' ') 123 | 124 | if ufile.readline(): 125 | print("It looks like there is more file... additional/optional tags may exist, but aren't being read in!") 126 | 127 | 128 | 129 | def read_gmsh_v2(self): 130 | ''' 131 | https://gmsh.info/doc/texinfo/gmsh.html#MSH-file-format-version-2-_0028Legacy_0029 132 | 133 | ASSUMES 134 | - NODES START AT 1 AND COUNT UP 135 | - PHYSICAL REGION TAG (first one following geomtype tag) IS WHAT WE CARE ABOUT 136 | TODO: 137 | - Make syntax at end less bad 138 | ''' 139 | print(f'Reading gmsh v2.2 ASCII file: {self.filename} \n') 140 | 141 | nodes = [] 142 | elements = [] 143 | 144 | with open(self.filename) as mshfile: 145 | 146 | # burn first lines 147 | line = mshfile.readline() #$MeshFormat 148 | line = mshfile.readline().strip().split(' ') #2.2 0 8 149 | if line[0]!='2.2': raise Exception('Needs to be .msh v2.2 you ding dong') 150 | line = mshfile.readline() #$EndMeshFormat 151 | line = mshfile.readline() #$Nodes 152 | 153 | # read-in block data 154 | while line: 155 | if line.strip() == '$Nodes': 156 | num_nodes = int(mshfile.readline().strip()) #num nodes 157 | for i_node in range(num_nodes): 158 | line = mshfile.readline().strip().split(' ') 159 | nodes.append([float(x) for x in line[1:]]) 160 | line = mshfile.readline() #$EndNodes 161 | 162 | if line.strip() == '$Elements': 163 | num_elems = int(mshfile.readline().strip()) #num elems 164 | for i_elem in range(num_elems): 165 | line = mshfile.readline().strip().split(' ') 166 | elements.append([int(x) for x in line]) 167 | line = mshfile.readline() #$EndElements 168 | line = mshfile.readline() 169 | 170 | # Parse elements by appending to lists 171 | for data in self.iter_elem_data: 172 | data['defs'] = [] 173 | data['tags'] = [] 174 | 175 | for el in elements: 176 | if el[1] in self.gmsh_tag_types.keys(): 177 | if el[2] != 2: raise Exception('Currently assuming only 2x tags exist per element in GMSH file') 178 | data = getattr(self, self.gmsh_tag_types[el[1]]) 179 | data['defs'].append(el[5:]) 180 | data['tags'].append(el[3]) 181 | 182 | # Convert all back to numpy 183 | self.nodes = np.array(nodes, dtype=np.double) 184 | for data in self.iter_elem_data: 185 | data['defs'] = np.array(data['defs'], dtype=np.uint32) 186 | data['tags'] = np.array(data['tags'], dtype=np.uint32) 187 | 188 | 189 | def write(self, outfile): 190 | 191 | start_time = time.time() 192 | _, ext = os.path.splitext(outfile) 193 | 194 | match ext: 195 | case '.ugrid': 196 | self.write_ugrid(outfile) 197 | case '.msh': 198 | self.write_gmsh_v2(outfile) 199 | case _: 200 | raise Exception('Invalid extension specified!') 201 | 202 | print(f'Completed in {time.time()-start_time}!\n') 203 | 204 | 205 | 206 | def write_ugrid(self, outfile): 207 | ''' 208 | https://www.simcenter.msstate.edu/software/documentation/ug_io/3d_grid_file_type_ugrid.html 209 | ''' 210 | print(f'Writing ugrid to {outfile}...') 211 | 212 | with open(outfile, 'w') as outfile: 213 | 214 | # write header 215 | header = f"{self.num_nodes} {self.num_tris} {self.num_quads} {self.num_tets} {self.num_pyrmds} {self.num_prisms} {self.num_hexes}" 216 | outfile.write(header+'\n') 217 | 218 | # write nodes 219 | np.savetxt(outfile, self.nodes, fmt='%.18f') 220 | 221 | # write boundary faces 222 | for geom_data in self.iter_boundary_data: 223 | np.savetxt(outfile, geom_data['defs'], fmt='%i') 224 | 225 | # write boundary tags 226 | for geom_data in self.iter_boundary_data: 227 | np.savetxt(outfile, geom_data['tags'], fmt='%i') 228 | 229 | # write volumes 230 | for geom_data in self.iter_volume_data: 231 | np.savetxt(outfile, geom_data['defs'], fmt='%i') 232 | 233 | 234 | 235 | def write_gmsh_v2(self, outfile): 236 | ''' 237 | Making this able to write volumes, to see if I can just read everything into gmsh and not have to stitch together outside 238 | https://gmsh.info/doc/texinfo/gmsh.html#MSH-file-format-version-2-_0028Legacy_0029 239 | 240 | ASSUMING THAT, WHEN WRITING, THAT PHYSICAL AND ELEMENTARY TAGS ARE THE SAME 241 | ''' 242 | print(f'Writing gmsh v2 ASCII to: {outfile}') 243 | start_time = time.time() 244 | 245 | # pre-formatting nodes block 246 | node_block = np.concatenate((np.arange(1, self.num_nodes+1).reshape(-1,1), self.nodes), axis=1) 247 | 248 | #pre-formatting elements blocks to try and speed things up 249 | element_blocks = [] 250 | ctr_el = 1 251 | for geom_data, geomtype_str, geom_num_members in zip(self.iter_elem_data, self.iter_elem_type_strs, self.iter_elem_counts): 252 | 253 | # assuming only 2x tags (element, physical) for now 254 | gmsh_tag = self.gmsh_type_tags[geomtype_str] 255 | type_numtag_data = np.tile(np.array([gmsh_tag, 2]), (geom_num_members,1)) 256 | 257 | # assemble, append block 258 | if geom_num_members > 0: 259 | data_block = np.concatenate((np.arange(ctr_el, ctr_el+geom_num_members).reshape(-1,1), #god i fucking hate numpy 260 | type_numtag_data, geom_data['tags'], geom_data['tags'], 261 | geom_data['defs']), axis=1) 262 | element_blocks.append(data_block) 263 | 264 | #update counter 265 | ctr_el = ctr_el+geom_num_members 266 | 267 | 268 | with open(outfile, 'w') as outfile: 269 | outfile.write('$MeshFormat\n') 270 | outfile.write('2.2 0 8\n') 271 | outfile.write('$EndMeshFormat\n') 272 | 273 | outfile.write('$Nodes\n') 274 | outfile.write(f'{self.num_nodes}\n') 275 | np.savetxt(outfile, node_block, fmt=['%i','%.18e','%.18e','%.18e']) 276 | outfile.write('$EndNodes\n') 277 | 278 | outfile.write('$Elements\n') 279 | outfile.write(f'{self.num_elements}\n') 280 | for block in element_blocks: 281 | np.savetxt(outfile, block, fmt='%i') 282 | outfile.write('$EndElements\n') 283 | 284 | 285 | 286 | def extract_surface(self, bc_target): 287 | 288 | print(f'Extracting boundary faces tagged with {bc_target} to new UMesh...') 289 | OutMesh = UMesh() 290 | 291 | # get index of boundary tris, quads with given target 292 | tri_idx = np.argwhere(self.tris['tags'] == bc_target)[:,0] 293 | quad_idx = np.argwhere(self.quads['tags'] == bc_target)[:,0] 294 | 295 | # assign boundary connectivity data to new object 296 | OutMesh.tris['defs'] = self.tris['defs'][tri_idx] 297 | OutMesh.tris['tags'] = self.tris['tags'][tri_idx] 298 | OutMesh.quads['defs'] = self.quads['defs'][quad_idx] 299 | OutMesh.quads['tags'] = self.quads['tags'][quad_idx] 300 | 301 | # get node ID's on targeted boundary 302 | out_nodes_unique = set() 303 | 304 | for geom_data in OutMesh.iter_boundary_data: 305 | for i_geom in range(geom_data['defs'].shape[0]): 306 | out_nodes_unique.update(geom_data['defs'][i_geom, :]) 307 | 308 | out_nodelist_1dx = list(out_nodes_unique) 309 | out_nodelist_1dx.sort() 310 | out_nodelist_0dx = [x-1 for x in out_nodelist_1dx] 311 | 312 | # assign relevant nodes to new object 313 | OutMesh.nodes = self.nodes[out_nodelist_0dx, :] 314 | 315 | # Renumber to contiguous nodes (counting up from one) in boundary element definitions 316 | mapping = {orig: new+1 for orig, new in zip(out_nodelist_1dx, range(OutMesh.num_nodes))} 317 | 318 | for geom_data in OutMesh.iter_boundary_data: 319 | for ir, ic in np.ndindex(geom_data['defs'].shape): 320 | geom_data['defs'][ir,ic] = mapping[geom_data['defs'][ir,ic]] 321 | 322 | return OutMesh 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | --------------------------------------------------------------------------------