11import numpy
2+ import os
3+ import tempfile
4+ import multiprocessing as mp
25import pyvista
36import pymeshfix
47from tqdm import trange , tqdm
58from scipy .interpolate import splprep , splev
6- from svv .utils .remeshing .remesh import remesh_surface
9+ from scipy .spatial import cKDTree
10+ from svv .utils .remeshing .remesh import remesh_surface , write_medit_sol
711from svv .domain .routines .boolean import boolean
812
913
@@ -455,7 +459,7 @@ def generate_polylines(xyz, r, num=1000):
455459 return polylines
456460
457461
458- def generate_tube (polyline , hsize = None ):
462+ def generate_tube (polyline , hsize = None , radius_based = False ):
459463 """
460464 This function generates a tube from a given polyline representing a single vessel of a
461465 vascular object (tree or forest).
@@ -465,7 +469,12 @@ def generate_tube(polyline, hsize=None):
465469 polyline : pyvista.PolyData
466470 A polyline object representing the centerline of a vessel.
467471 hsize : float
468- The mesh element size for the surface mesh of the vessel.
472+ The mesh element size for the surface mesh of the vessel. When
473+ radius_based is True, this acts as the target size at the minimum
474+ centerline radius and scales proportionally elsewhere.
475+ radius_based : bool
476+ If True, writes a per-vertex sizing function (in.sol) proportional to
477+ the local centerline radius so MMG adapts edge sizes accordingly.
469478
470479 Returns
471480 -------
@@ -482,7 +491,41 @@ def generate_tube(polyline, hsize=None):
482491 tube = tube .compute_normals (auto_orient_normals = True )
483492 if isinstance (hsize , type (None )):
484493 hsize = (min (polyline ['radius' ])* 2 * numpy .pi )/ 25
485- tube = remesh_surface (tube , hsiz = hsize )
494+ if radius_based :
495+ # Per-vertex sizing based on local centerline radius using k-NN from KDTree.
496+ # Scale so hsize matches the size at the minimum radius; elsewhere scales proportionally.
497+ poly_pts = numpy .asarray (polyline .points , dtype = float )
498+ poly_rad = numpy .asarray (polyline ['radius' ], dtype = float ).reshape (- 1 )
499+ rmin = float (poly_rad .min ()) if poly_rad .size else 1.0
500+ scale = float (hsize ) / rmin if rmin > 0 else float (hsize )
501+
502+ surf_pts = numpy .asarray (tube .points , dtype = float )
503+ n_poly = poly_pts .shape [0 ]
504+ k_nn = min (4 , n_poly ) if n_poly > 0 else 1
505+ if n_poly == 0 :
506+ sizes = numpy .full (surf_pts .shape [0 ], float (hsize ), dtype = float )
507+ else :
508+ tree = cKDTree (poly_pts )
509+ d , idx = tree .query (surf_pts , k = k_nn )
510+ # Handle k=1 return shape
511+ if k_nn == 1 :
512+ r_local = poly_rad [numpy .asarray (idx ).reshape (- 1 )].astype (float )
513+ else :
514+ d = numpy .asarray (d , dtype = float )
515+ idx = numpy .asarray (idx , dtype = int )
516+ # Inverse-distance weights; protect against zero distances
517+ w = 1.0 / (d + 1e-12 )
518+ w /= w .sum (axis = 1 , keepdims = True )
519+ r_neighbors = poly_rad [idx ]
520+ r_local = (w * r_neighbors ).sum (axis = 1 )
521+ sizes = scale * r_local
522+ # Ensure strictly positive sizes for MMG
523+ sizes = numpy .maximum (sizes , numpy .finfo (float ).eps )
524+ tube .point_data ['MeshSizingFunction' ] = sizes
525+ # Write MMG sizing file the remesher will pick up in this temp directory
526+ write_medit_sol (tube , 'in.sol' , array_name = 'MeshSizingFunction' , scale = 1 , default_size = hsize )
527+ # If using a sizing function, let MMG drive sizes solely from in.sol (omit -hsiz)
528+ tube = remesh_surface (tube , hsiz = (None if radius_based else hsize ), verbosity = 0 )
486529 tube = tube .compute_normals (auto_orient_normals = True )
487530 fix = pymeshfix .MeshFix (tube )
488531 fix .repair ()
@@ -515,6 +558,103 @@ def generate_tubes(polylines, hsize=None):
515558 return tubes
516559
517560
561+ def _tube_worker (args ):
562+ """Worker that builds a single tube in an isolated temp directory.
563+
564+ Args
565+ ----
566+ args : tuple
567+ (idx, points, radius, hsize, radius_based)
568+
569+ Returns
570+ -------
571+ tuple
572+ (idx, points, faces) for reconstructed PolyData.
573+ """
574+ idx , pts , rad , hsize , radius_based = args
575+ old_cwd = os .getcwd ()
576+ # Isolate MMG temp files to avoid collisions between processes
577+ with tempfile .TemporaryDirectory (prefix = "svv_remesh_" ) as tmpdir :
578+ try :
579+ os .chdir (tmpdir )
580+ poly = polyline_from_points (numpy .asarray (pts ), numpy .asarray (rad ))
581+ tube = generate_tube (poly , hsize = hsize , radius_based = radius_based )
582+ # Return minimal geometry to avoid pickling VTK objects
583+ return idx , numpy .asarray (tube .points ), numpy .asarray (tube .faces )
584+ finally :
585+ os .chdir (old_cwd )
586+
587+
588+ def generate_tubes_parallel (polylines , hsize = None , processes = None , chunksize = 1 , start_method = None , show_progress = True , radius_based = False ):
589+ """
590+ Parallel tube generation using multiprocessing. Each tube is built in a
591+ separate process and in a per-process temporary directory to avoid MMG
592+ temp-file collisions.
593+
594+ Parameters
595+ ----------
596+ polylines : list[pyvista.PolyData]
597+ Centerline polylines with point-data array 'radius'.
598+ hsize : float, optional
599+ Target surface edge size for remeshing (forwarded to generate_tube).
600+ processes : int, optional
601+ Number of worker processes. Defaults to `os.cpu_count()`.
602+ chunksize : int, optional
603+ Chunk size for Pool.imap. Default 1.
604+ start_method : {"spawn","fork","forkserver"}, optional
605+ Multiprocessing start method. Defaults to 'spawn' when available.
606+ show_progress : bool, optional
607+ If True, display a tqdm progress bar.
608+ radius_based : bool, optional
609+ If True, build a per-vertex MMG sizing function based on the local
610+ centerline radius and pass it via in.sol, yielding radius-proportional
611+ edge sizes. Default False.
612+
613+ Returns
614+ -------
615+ list[pyvista.PolyData]
616+ Reconstructed tube meshes in the same order as input polylines.
617+ """
618+ n = len (polylines )
619+ if n == 0 :
620+ return []
621+
622+ # Serialize inputs (avoid sending VTK objects across processes)
623+ tasks = []
624+ for i , pl in enumerate (polylines ):
625+ pts = numpy .asarray (pl .points )
626+ if 'radius' not in pl .point_data :
627+ raise KeyError ("Each polyline must have a 'radius' point-data array" )
628+ rad = numpy .asarray (pl ['radius' ]).reshape (- 1 )
629+ tasks .append ((i , pts , rad , hsize , radius_based ))
630+
631+ # Choose a safe start method to avoid forking VTK state
632+ if start_method is None :
633+ try :
634+ ctx = mp .get_context ('spawn' )
635+ except ValueError :
636+ ctx = mp .get_context ()
637+ else :
638+ ctx = mp .get_context (start_method )
639+
640+ tubes_arrays = [None ] * n
641+ with ctx .Pool (processes = processes ) as pool :
642+ iterator = pool .imap (_tube_worker , tasks , chunksize )
643+ if show_progress :
644+ iterator = tqdm (iterator , total = n , desc = 'Generate tubes ' , unit = 'tube' , leave = False )
645+ for idx , pts , faces in iterator :
646+ tubes_arrays [idx ] = (pts , faces )
647+
648+ # Reconstruct PolyData objects in parent process
649+ tubes = []
650+ for i in range (n ):
651+ pts , faces = tubes_arrays [i ]
652+ tube = pyvista .PolyData (pts , faces )
653+ # Ensure normals are available
654+ tube = tube .compute_normals (auto_orient_normals = True )
655+ tubes .append (tube )
656+ return tubes
657+
518658def union_tubes (tubes , lines , cap_resolution = 40 ):
519659 """
520660 This function performs iterative boolean union operations on a list of Pyvista polydata surface
@@ -564,10 +704,10 @@ def build_watertight_solid(tree, cap_resolution=40):
564704 """
565705 xyz , r , _ , _ , branches , _ = get_interpolated_sv_data (tree .data )
566706 lines = generate_polylines (xyz , r )
567- tubes = generate_tubes (lines )
568- model = union_tubes (tubes , lines , cap_resolution = cap_resolution )
707+ tubes = generate_tubes_parallel (lines , radius_based = True )
708+ model = union_tubes_balanced (tubes , lines , cap_resolution = cap_resolution )
569709 # Remove poor quality elements and repair the mesh.
570- # cell_quality = model.compute_cell_quality(quality_measure='scaled_jacobian')
710+ cell_quality = model .compute_cell_quality (quality_measure = 'scaled_jacobian' )
571711 keep = cell_quality .cell_data ["CellQuality" ] > 0.1
572712 if not numpy .all (keep ):
573713 print ("Removing poor quality elements from the mesh." )
@@ -586,7 +726,7 @@ def build_watertight_solid(tree, cap_resolution=40):
586726
587727def union_tubes_balanced (tubes , lines , cap_resolution = 40 , method = 'centerline' , engine = 'manifold' , fix_mesh = True ):
588728 """
589- Perform topology-aware, size -balanced boolean unions over a set of vessel tubes.
729+ Perform topology-aware, compute -balanced boolean unions over a set of vessel tubes.
590730
591731 The function:
592732 - Detects unionable parent/child pairs using `find_unionable_pairs` (child's
@@ -707,7 +847,7 @@ def union(a, b):
707847 if comp_count > 0 :
708848 total_merges += max (comp_count - 1 , 0 )
709849
710- pbar = tqdm (total = total_merges , desc = 'Union tubes (balanced) ' , unit = 'union' , leave = False )
850+ pbar = tqdm (total = total_merges , desc = 'Union tubes ' , unit = 'union' , leave = False )
711851
712852 # Iteratively union smallest available pairs until components are reduced
713853 while heap :
@@ -765,9 +905,12 @@ def union(a, b):
765905 # Fallback heuristic if radii missing
766906 hsize = None
767907
768- if hsize is not None and hsize > 0 :
769- model = remesh_surface (model , hsiz = hsize )
908+ # if hsize is not None and hsize > 0:
909+ # model = remesh_surface(model, hsiz=hsize, verbosity=0 )
770910 model = model .compute_normals (auto_orient_normals = True )
911+ #fix = pymeshfix.MeshFix(model)
912+ #fix.repair()
913+ #model = fix.mesh
771914 if hsize is not None :
772915 model .cell_data ['hsize' ] = 0
773916 model .cell_data ['hsize' ][0 ] = hsize
0 commit comments