Point cloud to watertight surface
Two different reconstruction algorithms answer two different questions, and triwarp ships both
GPU-native: reconstruction.screened_poisson fits an
implicit surface (smooth, always watertight, doesn't interpolate the input points exactly) and
reconstruction.ball_pivoting builds an interpolating
one (its output vertices are literally a subset of the input points, but it can leave small holes
where the ball can't find three points to rest on). Both need oriented normals.
import warp as wp
import triwarp as tw
vertices, faces = tw.creation.icosphere(subdivisions=3)
mesh = tw.Trimesh(vertices, faces)
# Simulate a scanner: sample the surface into a point cloud and keep each sample's true normal
# (a real scan would estimate normals instead -- see the note below).
points, face_index = tw.sample.sample_surface(vertices, faces, 20_000, seed=0)
normals = wp.array(
mesh.face_normals.numpy()[face_index.numpy()], dtype=wp.vec3, device=vertices.device
)
poisson_vertices, poisson_faces = tw.reconstruction.screened_poisson(points, normals, depth=7)
print("screened Poisson faces:", poisson_faces.shape[0] // 3) # 127460
print("screened Poisson watertight:", tw.validation.is_watertight(poisson_vertices, poisson_faces)) # True
bpa_vertices, bpa_faces = tw.reconstruction.ball_pivoting(points, normals)
print("ball pivoting faces:", bpa_faces.shape[0] // 3, "verts:", bpa_vertices.shape[0]) # 34804, 19864
Estimating normals for a real scan
A real point cloud rarely comes with normals attached. Build a per-point neighbourhood with
neighbors.query_nearest and estimate normals by local PCA with
points.estimate_normals:
neighbor_idx, _ = tw.neighbors.query_nearest(points, points, k=16)
estimated_normals = tw.points.estimate_normals(points, neighbor_idx)
estimate_normals orients each normal consistently with its neighbours, but not globally — for a
star-shaped cloud (like the sphere above), pass orient_reference or reconstruct with
ball_pivoting's own PCA fallback (leave normals=None); for a general cloud, a global
orientation pass is a separate problem this function deliberately doesn't solve for you.
Choosing between the two
screened_poissonis the right default for a noisy or unevenly-sampled cloud: it's a global least-squares fit, always closed, and itsdepthparameter trades resolution for solve cost — see the function's own docstring for the"adaptive"method, which concentrates resolution where the input actually has detail.ball_pivotingis the right choice when the reconstruction needs to interpolate the input points exactly (e.g., because downstream code correlates reconstructed vertices back to scan samples), or when the cloud is dense and clean enough that its holes will be small.radiusdefaults to an automatic guess from the mean nearest-neighbour spacing; tune it down for a denser cloud or up to bridge a sparser one.