88import pymeshfix
99import meshio
1010import numpy
11+ import numpy as np
1112from copy import deepcopy
1213import svv
14+ from typing import Sequence , Optional
1315
1416filepath = os .path .abspath (__file__ )
1517dirpath = os .path .dirname (filepath )
@@ -778,3 +780,257 @@ def clean_medit(filename):
778780 new_lines .extend (lines [o :keywords_index [i + 1 ]])
779781 new_file .writelines (new_lines )
780782 new_file .close ()
783+
784+ def write_medit_sol (mesh : pv .PolyData , path : str , array_name = "MeshSizingFunction" ,
785+ scale = 1 , default_size = None ):
786+ npts = mesh .n_points
787+ vals = None
788+ if array_name in mesh .point_data :
789+ vals = np .asarray (mesh .point_data [array_name ], dtype = float ).reshape (- 1 )
790+ if vals .size != npts :
791+ raise RuntimeError (f"Array '{ array_name } ' length ({ vals .size } ) "
792+ f"!= number of points ({ npts } )" )
793+ # Replace non-positive entries if default provided
794+ if default_size is not None :
795+ vals = np .where (vals > 0.0 , vals , float (default_size ))
796+ else :
797+ if default_size is None :
798+ raise RuntimeError (f"Point-data array '{ array_name } ' not found and no default_size provided." )
799+ vals = np .full (npts , float (default_size ), dtype = float )
800+
801+ vals = scale * vals # SV typically scales by ~0.8 before MMG
802+
803+ with open (path , "w" ) as f :
804+ f .write ("MeshVersionFormatted 2\n " )
805+ f .write ("Dimension 3\n \n " )
806+ f .write ("SolAtVertices\n " )
807+ f .write (f"{ npts } \n " )
808+ f .write ("1 1\n " ) # one scalar per vertex
809+ for v in vals :
810+ f .write (f"{ v :.15g} \n " )
811+ f .write ("\n End\n " )
812+
813+ def sphere_refinement (
814+ mesh : pv .PolyData ,
815+ radius : float ,
816+ center : Sequence [float ],
817+ local_edge_size : float ,
818+ global_edge_size : float ,
819+ array_name : str = "MeshSizingFunction" ,
820+ refine_id_name : Optional [str ] = None ,
821+ refine_id_value : int = 1 ,
822+ inplace : bool = True ,
823+ ar = None ,
824+ hausd = None ,
825+ hgrad = None ,
826+ verbosity = 1 ,
827+ hmax = None ,
828+ hmin = None ,
829+ hsiz = None ,
830+ noinsert = None ,
831+ nomove = None ,
832+ nosurf = None ,
833+ noswap = None ,
834+ nr = None ,
835+ optim = False ,
836+ rn = None ,
837+ required_triangles = None
838+ ) -> pv .PolyData :
839+ """
840+ Set local mesh edge size for points inside a sphere.
841+
842+ Args:
843+
844+ mesh: pyvista.PolyData surface mesh (triangulated or not).
845+
846+ radius: Sphere radius (> 0).
847+
848+ center: Sphere center [cx, cy, cz].
849+
850+ local_edge_size: Target edge size to assign inside the sphere (> 0).
851+
852+ array_name: Point-data array name to write (default: 'MeshSizingFunction').
853+
854+ global_edge_size: If provided and the array is missing, initialize all points
855+ to this value. If not provided and the array is missing, initialize with zeros.
856+ Points outside the sphere are left unchanged.
857+
858+ refine_id_name: Optional point-data int array to tag refined points
859+ (e.g., 'RefineID'). If provided, sets tag = refine_id_value inside the sphere,
860+ leaves others as-is (initializes to 0 if array missing).
861+
862+ refine_id_value: Tag value to set in refine_id_name for points in the sphere.
863+
864+ inplace: If False, process a deep copy and return it.
865+
866+ ar : float, optional
867+ Anisotropy ratio. See MMG3D documentation for details.
868+
869+ hausd : float, optional
870+ Control on Hausdorff distance. See MMG3D documentation.
871+
872+ hgrad : float, optional
873+ Gradation parameter. See MMG3D documentation.
874+
875+ verbosity : int, optional
876+ Verbosity level for MMG output. Default is 1.
877+
878+ hmax : float, optional
879+ Maximum edge size. See MMG3D documentation.
880+
881+ hmin : float, optional
882+ Minimum edge size. See MMG3D documentation.
883+
884+ hsiz : float, optional
885+ Size parameter for remeshing. See MMG3D documentation.
886+
887+ noinsert : bool, optional
888+ If True, prohibits node insertion. See MMG3D documentation.
889+
890+ nomove : bool, optional
891+ If True, prohibits node movement. See MMG3D documentation.
892+
893+ nosurf : bool, optional
894+ If True, prohibits surface modifications. See MMG3D documentation.
895+
896+ noswap : bool, optional
897+ If True, prohibits edge swapping. See MMG3D documentation.
898+
899+ nr : bool, optional
900+ Disables reorientation of the mesh. See MMG3D documentation.
901+
902+ optim : bool, optional
903+ Optimization parameter. See MMG3D documentation.
904+
905+ rn : bool, optional
906+ Removes nonmanifold elements. See MMG3D documentation.
907+ Returns:
908+ pv.PolyData: The updated mesh (same object if inplace=True).
909+ """
910+ if not isinstance (mesh , pv .PolyData ):
911+ raise TypeError ("mesh must be a pyvista.PolyData" )
912+ if radius <= 0 :
913+ raise ValueError ("radius must be > 0" )
914+ if local_edge_size <= 0 :
915+ raise ValueError ("local_edge_size must be > 0" )
916+ if global_edge_size <= 0 :
917+ raise ValueError ("global_edge_size must be > 0" )
918+
919+ out = mesh if inplace else mesh .copy (deep = True )
920+
921+ pts = out .points .astype (float )
922+ ctr = np .asarray (center , dtype = float ).reshape (3 )
923+ if ctr .shape != (3 ,):
924+ raise ValueError ("center must be a sequence of three floats" )
925+
926+ # Compute mask of points inside the sphere (vectorized).
927+ d2 = np .einsum ("ij,ij->i" , pts - ctr , pts - ctr ) # squared distance
928+ mask = d2 <= float (radius ) ** 2
929+
930+ # Prepare or fetch the sizing array.
931+ n = pts .shape [0 ]
932+ if array_name in out .point_data :
933+ sizes = np .asarray (out .point_data [array_name ], dtype = float ).copy ()
934+ if sizes .shape [0 ] != n :
935+ raise RuntimeError (f"Existing array '{ array_name } ' length { sizes .shape [0 ]} != n_points { n } " )
936+ else :
937+ if global_edge_size is None :
938+ sizes = np .zeros (n , dtype = float )
939+ else :
940+ sizes = np .full (n , float (global_edge_size ), dtype = float )
941+
942+ # Apply refinement.
943+ sizes [mask ] = float (local_edge_size )
944+ out .point_data [array_name ] = sizes
945+
946+ # Optional: tag refined points (like SimVascular's RefineID).
947+ if refine_id_name :
948+ if refine_id_name in out .point_data :
949+ rid = np .asarray (out .point_data [refine_id_name ], dtype = np .int32 ).copy ()
950+ if rid .shape [0 ] != n :
951+ raise RuntimeError (f"Existing array '{ refine_id_name } ' length { rid .shape [0 ]} != n_points { n } " )
952+ else :
953+ rid = np .zeros (n , dtype = np .int32 )
954+ rid [mask ] = int (refine_id_value )
955+ out .point_data [refine_id_name ] = rid
956+ write_medit_sol (out , "in.sol" , array_name = "MeshSizingFunction" ,scale = 1 , default_size = global_edge_size )
957+ pv .save_meshio ("tmp.mesh" , out )
958+ if not isinstance (required_triangles , type (None )):
959+ add_required ("tmp.mesh" , required_triangles )
960+ if platform .system () == 'Windows' :
961+ if os .path .exists (modulepath + os .sep + "mmgs_O3.exe" ):
962+ _EXE_ = modulepath + os .sep + "mmgs_O3.exe"
963+ else :
964+ _EXE_ = dirpath + os .sep + "Windows" + os .sep + "mmgs_O3.exe"
965+ elif platform .system () == "Linux" :
966+ if os .path .exists (modulepath + os .sep + "mmgs_O3" ):
967+ _EXE_ = modulepath + os .sep + "mmgs_O3"
968+ else :
969+ _EXE_ = dirpath + os .sep + "Linux" + os .sep + "mmgs_O3"
970+ elif platform .system () == "Darwin" :
971+ if os .path .exists (modulepath + os .sep + "mmgs_O3" ):
972+ _EXE_ = modulepath + os .sep + "mmgs_O3"
973+ else :
974+ _EXE_ = dirpath + os .sep + "Mac" + os .sep + "mmgs_O3"
975+ else :
976+ raise NotImplementedError ("Operating system not supported." )
977+ devnull = open (os .devnull , 'w' )
978+ executable_list = [_EXE_ , "tmp.mesh" , "-sol" , "in.sol" ]
979+ if ar is not None :
980+ executable_list .extend (["-ar" , str (ar )])
981+ if hausd is not None :
982+ executable_list .extend (["-hausd" , str (hausd )])
983+ if hgrad is not None :
984+ executable_list .extend (["-hgrad" , str (hgrad )])
985+ if verbosity is not None :
986+ executable_list .extend (["-v" , str (verbosity )])
987+ if hmax is not None :
988+ executable_list .extend (["-hmax" , str (hmax )])
989+ if hmin is not None :
990+ executable_list .extend (["-hmin" , str (hmin )])
991+ if hsiz is not None :
992+ executable_list .extend (["-hsiz" , str (hsiz )])
993+ if noinsert is not None :
994+ executable_list .extend (["-noinsert" ])
995+ if nomove is not None :
996+ executable_list .extend (["-nomove" ])
997+ if nosurf is not None :
998+ executable_list .extend (["-nosurf" ])
999+ if noswap is not None :
1000+ executable_list .extend (["-noswap" ])
1001+ if nr is not None :
1002+ executable_list .extend (["-nr" ])
1003+ if optim :
1004+ executable_list .extend (["-optim" ])
1005+ if rn is not None :
1006+ executable_list .extend (["-rn" , str (rn )])
1007+ if verbosity == 0 :
1008+ try :
1009+ subprocess .check_call (executable_list , stdout = devnull , stderr = devnull )
1010+ except :
1011+ os .chmod (_EXE_ , stat .S_IRUSR | stat .S_IWUSR | stat .S_IXUSR )
1012+ subprocess .check_call (executable_list , stdout = devnull , stderr = devnull )
1013+ else :
1014+ try :
1015+ subprocess .check_call (executable_list )
1016+ except :
1017+ os .chmod (_EXE_ , stat .S_IRUSR | stat .S_IWUSR | stat .S_IXUSR )
1018+ subprocess .check_call (executable_list )
1019+ clean_medit ("tmp.o.mesh" )
1020+ remesh_data = meshio .read ("tmp.o.mesh" )
1021+ vertices = remesh_data .points
1022+ has_triangles = False
1023+ for cell_block in remesh_data .cells :
1024+ if cell_block .type == "triangle" :
1025+ faces = cell_block .data
1026+ has_triangles = True
1027+ break
1028+ if not has_triangles :
1029+ raise NotImplementedError ("Only triangular surfaces are supported." )
1030+ faces = numpy .hstack ([numpy .full ((faces .shape [0 ], 1 ), 3 ), faces ])
1031+ remeshed_surface = pv .PolyData (vertices , faces .flatten ())
1032+ os .remove ("tmp.mesh" )
1033+ os .remove ("tmp.o.sol" )
1034+ os .remove ("tmp.o.mesh" )
1035+ os .remove ("in.sol" )
1036+ return remeshed_surface
0 commit comments