Hole Filling
The first step in repairing a damaged 3D model is identifying the holes. Using the Manifold rules from the previous chapter, we simply scan the geometry for any edge that belongs to exactly one triangle. These are called Naked Edges or Border Edges.
Naked Edge Rule:
1# Border Edge Detection2def find_border_edges(faces):3 """Finds edges that are only shared by one face."""4 edge_counts = {}56 # Count occurrences of each edge7 for face in faces:8 edges = [(face[0], face[1]), (face[1], face[2]), (face[2], face[0])]9 for e in edges:10 # Sort to ensure (1,2) and (2,1) count as the same edge11 sorted_e = tuple(sorted(e))12 edge_counts[sorted_e] = edge_counts.get(sorted_e, 0) + 11314 # Return edges with count == 115 borders = [e for e, count in edge_counts.items() if count == 1]16 return borders
After finding all Naked Edges, the algorithm chains them together end-to-end to form closed loops. However, a mesh might have an outer boundary (like the edge of a flat sheet) as well as internal holes. We must separate the holes we want to fill from the boundaries we want to keep.
Loop Filtering:
1# Filtering Internal vs External Loops2def identify_holes(loops, bounding_box):3 """Sorts boundary loops into 'outer bounds' vs 'internal holes'."""4 holes = []5 outer_boundary = None67 # A simple heuristic: The loop with the largest bounding box8 # or the longest total perimeter is usually the outer boundary.9 max_length = 01011 for loop in loops:12 length = calculate_perimeter(loop)13 if length > max_length:14 max_length = length15 # If we already had a max, the old max was actually a hole16 if outer_boundary is not None:17 holes.append(outer_boundary)18 outer_boundary = loop19 else:20 holes.append(loop)2122 return holes, outer_boundary
Triangulating an arbitrary 3D hole is mathematically complex because the vertices might be warped in 3D space. The standard approach is to project the 3D hole onto a 2D "Best Fit Plane", perform standard 2D triangulation there, and then map the resulting triangles back to 3D.
Projection Mapping:
1# Finding the Best Fitting Plane2def project_hole_to_2d(loop_vertices):3 """Projects a 3D hole onto a 2D plane for triangulation."""45 # 1. Calculate the Centroid6 centroid = sum(loop_vertices) / len(loop_vertices)78 # 2. Calculate the average Normal (using Newell's Method or PCA)9 normal = calculate_best_fit_normal(loop_vertices)1011 # 3. Create a 2D coordinate system on that plane12 u_axis, v_axis = get_plane_axes(normal)1314 # 4. Project all 3D points onto this 2D plane15 points_2d = []16 for p in loop_vertices:17 vec = p - centroid18 u = vec.dot(u_axis)19 v = vec.dot(v_axis)20 points_2d.append(Point2D(u, v))2122 return points_2d, plane_matrix
With our hole safely flattened onto a 2D plane, it is treated as a simple 2D polygon. The Ear Clipping algorithm traverses the perimeter looking for "ears" — convex corners that can be sliced off to form a triangle without trapping any other vertices inside.
Ear Clipping:
1# Ear Clipping Algorithm2def triangulate_2d_polygon(vertices_2d):3 """Cuts 'ears' off a 2D polygon until only a triangle remains."""4 triangles = []5 points = list(vertices_2d)67 while len(points) > 3:8 for i in range(len(points)):9 prev_pt = points[i - 1]10 curr_pt = points[i]11 next_pt = points[(i + 1) % len(points)]1213 # Check if this corner forms an "Ear"14 if is_convex_angle(prev_pt, curr_pt, next_pt):15 # Ensure no other points are inside this triangle16 if not any_point_in_triangle(points, prev_pt, curr_pt, next_pt):17 # It's an ear! Clip it.18 triangles.append([prev_pt, curr_pt, next_pt])19 points.pop(i)20 break # Restart search2122 # Add final remaining triangle23 triangles.append(points)24 return triangles
The triangulation gives us a set of 2D faces. We map those faces back to the original 3D indices of the hole's perimeter. Because the patch uses the exact same vertex indices as the boundary, stitching it into the main mesh simply means appending the new faces to the master list.
Welding Process:
1# Stitching the Patch into the Main Mesh2def append_patch_to_mesh(main_mesh, patch_faces, hole_vertices_3d):3 """Adds the new faces to the mesh and updates topology."""45 # 1. We don't need to add new vertices, because the patch6 # uses the existing boundary vertex indices!78 # 2. Add the new triangulated faces to the main face list9 for face in patch_faces:10 # face contains indices pointing to main_mesh.vertices11 main_mesh.faces.append(face)1213 # 3. Re-build the Half-Edge graph14 # The naked edges will now find their twins in the new patch!15 main_mesh.rebuild_topology()1617 return main_mesh
A planar patch will often look flat and artificial when stitched into a curved, organic surface. To make the repair invisible, we apply Laplacian Smoothing (or Fairing) specifically to the new vertices generated inside the hole, while keeping the boundary vertices firmly locked in place.
Laplacian Smoothing:
1# Laplacian Smoothing of the Patch2def smooth_patch_vertices(mesh, patch_vertices, iterations=5):3 """Relaxes the new vertices so they blend seamlessly."""45 # We only move the vertices that were inside the hole.6 # The boundary vertices must stay locked to preserve the original shape.78 for _ in range(iterations):9 for v in patch_vertices:10 if v.is_boundary:11 continue1213 # Find all neighboring vertices connected by an edge14 neighbors = get_connected_neighbors(mesh, v)1516 # Move the vertex towards the average position of its neighbors17 avg_pos = sum(neighbors) / len(neighbors)18 v.position = v.position * 0.5 + avg_pos * 0.5
In production environments (like preparing medical scans or 3D scanning data for 3D printing), models often contain hundreds of small micro-holes.
Automated Repair:
1# Automated Hole Filling Workflow2def repair_mesh(mesh):3 """Executes the full automated repair pipeline."""45 # 1. Build Topology6 edges = build_half_edges(mesh.faces)78 # 2. Extract Holes9 loops = extract_boundary_loops(edges)10 holes, outer = identify_holes(loops)1112 # 3. Process Each Hole13 for hole_vertices in holes:14 # Flatten15 v2d, p_mat = project_hole_to_2d(hole_vertices)1617 # Triangulate18 faces_2d = triangulate_2d_polygon(v2d)1920 # Stitch21 append_patch_to_mesh(mesh, faces_2d, hole_vertices)2223 # Fairing24 smooth_patch_vertices(mesh, new_internal_vertices)2526 return mesh