@@ -95,6 +95,16 @@ def extract_faces(surface, mesh, crease_angle: float = 60, verbose: bool = False
9595 for i in new_idx :
9696 new_faces .append (faces [i ])
9797 faces = new_faces
98+ # Precompute boundary-loop KD-trees for all faces (used for robust matching)
99+ all_boundary_trees = []
100+ for i in range (len (faces )):
101+ f = surface .extract_cells (faces [i ]).extract_surface ()
102+ loops = f .extract_feature_edges (boundary_edges = True ,
103+ manifold_edges = False ,
104+ feature_edges = False ,
105+ non_manifold_edges = False )
106+ splits = loops .split_bodies ()
107+ all_boundary_trees .append ([cKDTree (splits [j ].points ) for j in range (splits .n_blocks )])
98108 iscap = []
99109 wall_faces = []
100110 cap_faces = []
@@ -348,13 +358,62 @@ def compute_circularity(loop_polydata):
348358 wall_boundary_trees .append (tmp_wall_boundary_trees )
349359 iscap .append (0 )
350360 wall_faces .append (faces [i ])
351- # Do not reclassify single-loop planar faces: these are caps by definition.
352- # A cap’s boundary loop will naturally coincide with a wall boundary; this is expected
353- # and should not trigger reclassification.
361+
362+ # Post-classification validation: a CAP must have a single boundary loop that
363+ # matches exactly one LUMEN boundary and no other face boundaries.
364+ # If a cap loop is shared among multiple faces or not shared with any lumen,
365+ # demote it to a wall.
366+ def boundaries_match (tree_a , tree_b , tol = 1e-9 ):
367+ dists , _ = tree_a .query (tree_b .data )
368+ return numpy .all (numpy .isclose (dists , 0.0 , atol = tol ))
369+
370+ for i in range (len (faces )):
371+ if iscap [i ] != 1 :
372+ continue
373+ # Require exactly one boundary loop on the cap face
374+ cap_loops = all_boundary_trees [i ]
375+ if len (cap_loops ) != 1 :
376+ iscap [i ] = 0
377+ continue
378+ cap_loop_tree = cap_loops [0 ]
379+ lumen_matches = 0
380+ other_matches = 0
381+ for j in range (len (faces )):
382+ if j == i :
383+ continue
384+ for other_loop in all_boundary_trees [j ]:
385+ if boundaries_match (cap_loop_tree , other_loop ):
386+ if iscap [j ] == 2 :
387+ lumen_matches += 1
388+ else :
389+ other_matches += 1
390+ # Early exit if already invalid
391+ if lumen_matches > 1 or other_matches > 0 :
392+ break
393+ # Enforce: shared only with a single lumen loop
394+ if not (lumen_matches == 1 and other_matches == 0 ):
395+ iscap [i ] = 0 # demote to wall
396+ # Rebuild type-specific face lists and boundary trees after cap validation
397+ wall_faces = []
398+ cap_faces = []
399+ lumen_faces = []
400+ wall_boundary_trees = []
401+ cap_boundary_trees = []
402+ lumen_boundary_trees = []
403+ for i in range (len (faces )):
404+ if iscap [i ] == 0 :
405+ wall_faces .append (faces [i ])
406+ wall_boundary_trees .append (all_boundary_trees [i ])
407+ elif iscap [i ] == 1 :
408+ cap_faces .append (faces [i ])
409+ cap_boundary_trees .append (all_boundary_trees [i ])
410+ elif iscap [i ] == 2 :
411+ lumen_faces .append (faces [i ])
412+ lumen_boundary_trees .append (all_boundary_trees [i ])
354413
355414 # IMPORTANT: Caps and lumens should NEVER be merged with walls or each other
356- # Only walls can be combined if they share boundaries
357- # Combine cap and lumen boundary trees to prevent walls from merging with them
415+ # Only walls can be combined if they share boundaries. Prevent merges across
416+ # cap and lumen boundaries by providing them as non-wall constraints.
358417 all_non_wall_boundary_trees = cap_boundary_trees + lumen_boundary_trees
359418
360419 if combine_walls and len (wall_faces ) > 0 :
0 commit comments