Mesh and Texture Generation Automation

A textured mesh is the product clients react to. It is also the only product in this section that adds no measurement accuracy whatsoever — a mesh is a resampling of the point cloud, and every step of its production discards information. Knowing that is the key to automating it well: the goal is a file that opens quickly and looks right, not one that preserves every point.

The pipeline has four stages and each has one decision that matters. Surface reconstruction turns points into triangles, and the choice of method decides whether holes are filled or left open. Decimation reduces the triangle count, and the error budget decides whether breaklines survive. UV unwrapping assigns each triangle a patch of texture, and the seam policy decides whether the result has visible joins. Export writes a format, and the format decides whether a browser can open it at all.

This page automates all four from a classified cloud produced by classifying point clouds with PDAL and Python.

Audience and prerequisites. Python 3.10+, a dense cloud with colour, and enough RAM to hold the mesh — which for a site-scale reconstruction means tiling. Meshing is the most memory-hungry stage in the whole photogrammetry chain.

Prerequisites

Library / tool Minimum version Install command Role
open3d ≥ 0.18 pip install open3d Poisson and ball-pivoting reconstruction, decimation
trimesh ≥ 4.0 pip install "trimesh[easy]" Mesh repair, format conversion, glTF export
pymeshlab ≥ 2023.12 pip install pymeshlab Quadric decimation with attribute preservation
numpy ≥ 1.24 pip install numpy Vertex arrays, error metrics
pillow ≥ 10.0 pip install pillow Texture atlas assembly
PDAL ≥ 2.5 conda install -c conda-forge pdal Tiling the cloud before meshing

Conceptual architecture

Surface reconstruction has two families and they fail in opposite directions. Poisson reconstruction fits an implicit function to oriented points and extracts an isosurface. It produces watertight, smooth meshes and always closes holes — including the ones that represent genuinely missing data, which it fills with confident, invented geometry. Ball pivoting and the Delaunay family interpolate between actual points and leave holes where there are no points, producing meshes that are honest and often ragged.

For a visual deliverable, Poisson is usually right: the client wants a solid-looking model and an invented patch of ground under a tree is not being measured. For anything a measurement might be taken from, ball pivoting is right, because its holes are the truth. Making that choice explicit in the job definition — and recording it — is what stops a viewer’s measurement tool being used on invented geometry.

Poisson and ball-pivoting reconstruction over the same gap in the points Two cross-sections through a point set containing a genuine gap where no points were reconstructed. The Poisson result spans the gap with a smooth confident surface that looks identical to the measured parts. The ball-pivoting result leaves the gap open, so the missing data is visible in the mesh itself. Below, a summary states that Poisson is correct for a visual deliverable and ball pivoting is correct whenever a measurement might be taken from the mesh, and that the decision belongs in the job definition. Poisson — watertight, fills the gap ball pivoting — honest, leaves it open invented here gap preserved the choice is about what the mesh will be used for visual deliverable, client presentation, web viewer → Poisson anything a measurement might be taken from → ball pivoting and either way, record which was used alongside the file A viewer's measure tool cannot tell invented geometry from measured geometry. The metadata can.

Figure 1 — The reconstruction choice is a policy about honesty, not a quality setting.

Step 1: Estimate normals before reconstructing anything

Poisson reconstruction needs oriented normals, and the orientation is the part that goes wrong. A normal estimated from a local neighbourhood is ambiguous in sign; if the signs are inconsistent the surface folds back on itself and the result is a mesh full of spikes.

import numpy as np
import open3d as o3d


def prepare_cloud(points: np.ndarray, colors: np.ndarray,
                  *, radius: float = 0.5, max_nn: int = 30,
                  viewpoint_height: float = 120.0) -> o3d.geometry.PointCloud:
    """Load a cloud and orient its normals consistently upward.

    Orienting toward a synthetic camera high above the site is the right
    heuristic for aerial photogrammetry: every surface the drone saw faces
    broadly upward, so a single viewpoint resolves the sign ambiguity without
    the expensive minimum-spanning-tree propagation.
    """
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    pcd.colors = o3d.utility.Vector3dVector(colors / 255.0)
    pcd.estimate_normals(
        search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=radius,
                                                          max_nn=max_nn))
    centre = points.mean(axis=0)
    pcd.orient_normals_towards_camera_location(
        np.array([centre[0], centre[1], centre[2] + viewpoint_height]))
    return pcd

The viewpoint trick matters for building facades, which face sideways and are the one place the heuristic is weak. Where facades are part of the deliverable, orient with the minimum-spanning-tree method instead and accept the runtime.

Step 2: Reconstruct, then trim by density

Poisson’s most useful output is not the mesh but the per-vertex density it reports alongside it. Low-density vertices are exactly the ones the algorithm invented, and trimming them turns Poisson from a hole-filler into something closer to honest.

import numpy as np
import open3d as o3d


def poisson_mesh(pcd, *, depth: int = 11, density_percentile: float = 4.0):
    """Poisson reconstruction with low-confidence vertices trimmed away.

    depth controls the octree resolution: each increment doubles the linear
    resolution and roughly quadruples memory. 11 suits a site at a few
    centimetres; 13 will exhaust most machines on anything site-scale.
    """
    mesh, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(
        pcd, depth=depth, width=0, scale=1.1, linear_fit=False)

    d = np.asarray(densities)
    cutoff = np.quantile(d, density_percentile / 100.0)
    mesh.remove_vertices_by_mask(d < cutoff)
    mesh.remove_degenerate_triangles()
    mesh.remove_duplicated_vertices()
    mesh.remove_non_manifold_edges()
    return mesh

Trimming at the fourth percentile removes the confident invention over large gaps while leaving the small interpolations that make the mesh pleasant. Raising it toward ten percent produces a mesh close to ball-pivoting honesty with Poisson’s smoothness.

Step 3: Decimate against an error budget, not a face count

“Reduce to two million faces” is the instruction most pipelines carry, and it is the wrong shape of instruction: it produces different geometric error on every site. The right one is “reduce until the mesh deviates from the original by more than 3 cm”, which is a statement in survey units that a reader can evaluate.

import numpy as np
import pymeshlab


def decimate_to_error(in_path: str, out_path: str, *,
                      max_error_m: float = 0.03,
                      floor_faces: int = 50_000) -> dict:
    """Binary-search the face count that just meets a geometric error budget."""
    ms = pymeshlab.MeshSet()
    ms.load_new_mesh(in_path)
    original_faces = ms.current_mesh().face_number()

    lo, hi = floor_faces, original_faces
    best = None
    while lo < hi:
        target = (lo + hi) // 2
        ms.load_new_mesh(in_path)
        ms.meshing_decimation_quadric_edge_collapse(
            targetfacenum=target, preserveboundary=True, preservenormal=True,
            preservetopology=True, planarquadric=True)
        ms.apply_filter("compute_scalar_by_distance_from_another_mesh_per_vertex",
                        measuremesh=ms.current_mesh_id(), refmesh=0)
        err = float(np.percentile(np.abs(ms.current_mesh().vertex_scalar_array()), 95))
        if err <= max_error_m:
            best = (target, err)
            hi = target
        else:
            lo = target + 1

    if best is None:
        raise ValueError(f"cannot meet {max_error_m} m even at {floor_faces} faces")
    ms.save_current_mesh(out_path)
    return {"faces": best[0], "p95_error_m": best[1],
            "reduction": 1 - best[0] / original_faces}

preserveboundary and planarquadric together are what keep breaklines — the top of a kerb, the edge of a bench — from being smoothed away. Decimation without them produces a mesh that is dimensionally correct on average and visibly wrong exactly where a viewer looks. Decimating meshes without losing breaklines covers the failure in detail.

Geometric error against face count for two decimation settings Two curves of ninety-fifth percentile geometric error against remaining face count, from twenty million faces down to fifty thousand. With boundary and planar quadric preservation enabled, the error stays below three centimetres down to about eight hundred thousand faces before rising sharply. With those options disabled, the error passes three centimetres at around four million faces, five times earlier. A horizontal line marks the three centimetre budget and vertical markers show where each curve crosses it. 3 cm budget 20 M 8 M 4 M 1.6 M 800 k 50 k remaining faces geometric error preserve on preserve off Five times fewer faces for the same fidelity — from two flags, at no runtime cost.

Figure 2 — The budget is the instruction; the face count is the answer. Reversing the two is why meshes ship at arbitrary sizes.

Step 4: Export a format the client can actually open

The format decision is usually made badly because OBJ is familiar. OBJ is a text format: a five-million-triangle mesh becomes a 600 MB file that takes a browser thirty seconds to parse. glTF binary (.glb) stores the same mesh in about 90 MB and loads in a second, with the texture embedded rather than shipped as a sidecar that gets lost.

from pathlib import Path

import numpy as np
import trimesh


def export_glb(mesh_path: str, texture_path: str, out_path: str,
               *, origin_shift: tuple[float, float, float] | None = None) -> dict:
    """Export a textured mesh as a single self-contained .glb.

    The origin shift is not cosmetic: glTF stores positions as 32-bit floats,
    and a UTM easting of 500 000 leaves about 3 cm of resolution. Shifting to
    a local origin and recording the offset preserves millimetres.
    """
    mesh = trimesh.load(mesh_path, process=False)
    if origin_shift is None:
        origin_shift = tuple(np.asarray(mesh.bounds).mean(axis=0))
    mesh.apply_translation(-np.asarray(origin_shift))

    image = trimesh.load_image = None  # placeholder: material assembled below
    material = trimesh.visual.material.SimpleMaterial(
        image=trimesh.visual.texture.Image.open(texture_path))
    mesh.visual = trimesh.visual.TextureVisuals(uv=mesh.visual.uv,
                                                material=material)
    mesh.export(out_path)
    return {"origin_shift": list(map(float, origin_shift)),
            "faces": int(len(mesh.faces)),
            "bytes": Path(out_path).stat().st_size}

The float32 problem in the comment is the one that produces “my model looks fine but jitters when I zoom in”. glTF has no double-precision position type, so a mesh exported in projected coordinates is quantised to a few centimetres before it ever reaches the viewer. Shifting to a local origin and recording the offset in the metadata is the standard fix, and exporting OBJ and glTF for web viewers covers the rest of the format’s constraints.

Step 5: Unwrap and bake the texture

Between decimation and export sits the stage that decides how the model actually looks. Texturing has two halves: assigning every triangle a region of a two-dimensional image (unwrapping), and filling that image with colour sampled from the source photographs (baking).

Unwrapping is a packing problem. The mesh is cut into patches, each patch is flattened, and the flattened patches are arranged in an atlas. Three parameters govern the outcome. Patch count trades distortion against seams: fewer, larger patches mean fewer visible joins but more stretching. Atlas resolution sets the texel density, and 8192 pixels square is the practical ceiling — many mobile viewers refuse larger textures outright. Gutter width is the padding between packed patches, and it is the parameter that prevents the most common visible artefact.

import numpy as np
import xatlas


def unwrap(vertices: np.ndarray, faces: np.ndarray,
           *, resolution: int = 8192, gutter_px: int = 4) -> dict:
    """Cut, flatten and pack the mesh into a UV atlas.

    The gutter matters more than it sounds. Mip-mapping averages neighbouring
    texels, so patches packed edge to edge bleed into each other at every
    zoom level below full resolution — which is most of the time a viewer
    spends. Four pixels of padding costs about one percent of the atlas and
    removes the artefact entirely.
    """
    atlas = xatlas.Atlas()
    atlas.add_mesh(vertices, faces)
    opts = xatlas.PackOptions()
    opts.resolution = resolution
    opts.padding = gutter_px
    opts.bruteForce = False
    atlas.generate(pack_options=opts)

    vmap, indices, uvs = atlas[0]
    return {"vertices": vertices[vmap], "faces": indices, "uv": uvs,
            "atlas_px": resolution, "charts": atlas.chart_count,
            "utilisation": float(atlas.utilization[0])}

Atlas utilisation below about sixty percent is a signal worth acting on: it means the packer could not fit the patches efficiently, usually because the mesh was cut into many small charts by a noisy surface. Smoothing lightly before unwrapping, or reducing the chart count, recovers both texture quality and file size.

Baking then samples each texel from the source imagery. The decision that matters here is which photograph to sample from where several saw the same triangle, and the naive answer — the first one — produces a patchwork of exposures. Choosing by viewing angle and blending across the boundary produces a texture that looks continuous.

import numpy as np


def choose_source_image(triangle_normal: np.ndarray,
                        camera_dirs: np.ndarray,
                        camera_distances: np.ndarray,
                        max_angle_deg: float = 60.0) -> int | None:
    """Pick the best photograph to texture one triangle from.

    Score by how squarely the camera faced the surface, penalised by
    distance. Rejecting oblique views outright avoids the smeared texture
    that comes from sampling a surface seen almost edge-on.
    """
    cos = camera_dirs @ (triangle_normal / np.linalg.norm(triangle_normal))
    ok = cos > np.cos(np.radians(max_angle_deg))
    if not ok.any():
        return None                     # no acceptable view: leave a hole to fill
    score = np.where(ok, cos / np.maximum(camera_distances, 1e-6), -np.inf)
    return int(np.argmax(score))

Returning None rather than falling back to a bad view is deliberate. A triangle with no square-on observation is better left to a hole-filling pass, which will interpolate from its neighbours, than textured from a photograph that saw it at eighty degrees and recorded four pixels of smear.

Two operational notes close the stage. Bake at the atlas resolution you will ship, not higher — downsampling a 16k atlas to 8k afterwards blurs the seam gutters back into each other. And record the atlas resolution, chart count and utilisation in the run metadata, because a mesh that looks worse than last month’s is almost always one whose packing degraded rather than one whose geometry did.

Parameter deep-dive

Parameter Type Default Valid range Effect
normal_radius float, m 0.5 0.1–2.0 Neighbourhood for normal estimation; too small is noisy, too large rounds edges
Poisson depth int 11 8–13 Octree resolution; each step quadruples memory
density_percentile float 4.0 0–15 Trims invented geometry; higher approaches ball-pivoting honesty
max_error_m float 0.03 0.005–0.20 Decimation budget in survey units
preserveboundary bool True Keeps breaklines; costs nothing
planarquadric bool True Protects flat regions from over-collapse
Texture resolution px 8192 2048–16384 Atlas size; above 8192 many mobile viewers fail
origin_shift tuple mesh centre Required for glTF; without it positions quantise to centimetres
Tile size m 200 50–500 Meshing memory scales with the square; tile before reconstructing

Verification and output inspection

Mesh quality is checkable without opening a viewer, and every check below has caught a real defect.

import numpy as np
import trimesh


def inspect_mesh(path: str, *, expect_watertight: bool) -> dict:
    """Structural checks a delivered mesh must pass."""
    m = trimesh.load(path, process=False)
    report = {
        "faces": int(len(m.faces)),
        "vertices": int(len(m.vertices)),
        "watertight": bool(m.is_watertight),
        "winding_consistent": bool(m.is_winding_consistent),
        "has_uv": m.visual.uv is not None if hasattr(m.visual, "uv") else False,
        "degenerate_faces": int(np.count_nonzero(m.area_faces < 1e-12)),
        "bounds": m.bounds.tolist(),
    }
    problems = []
    if expect_watertight and not report["watertight"]:
        problems.append("expected a watertight mesh and got an open one")
    if not report["winding_consistent"]:
        problems.append("inconsistent winding — the viewer will show black patches")
    if not report["has_uv"]:
        problems.append("no UV coordinates — the texture cannot be applied")
    if report["degenerate_faces"]:
        problems.append(f"{report['degenerate_faces']} zero-area faces")
    report["problems"] = problems
    return report

Inconsistent winding is the defect worth checking hardest, because its symptom — patches that render black from one angle and correctly from another — is easily mistaken for a texture problem and sends people down the wrong path for an afternoon.

The mesh and texture chain from point cloud to deliverable A five-stage chain. Stage one filters and thins the point cloud so the reconstruction is not driven by noise. Stage two reconstructs a surface, where the choice of method sets how the mesh behaves at boundaries and in gaps. Stage three decimates under breakline constraints to a triangle budget the destination can carry. Stage four unwraps UVs into charts and packs an atlas. Stage five bakes texture from the source imagery with adequate chart padding. A note states that a defect at any stage is usually cheapest to fix at that stage rather than downstream. 1. filter thin the cloud so noise does not drive the mesh 2. reconstruct method sets behaviour at boundaries and gaps 3. decimate to a triangle budget, breaklines constrained 4. unwrap UV charts packed into an atlas 5. bake texture from imagery, with chart padding A defect is almost always cheapest to fix at the stage that produced it.

Figure 3 — Five stages, each of which can produce a defect the next one cannot fix.

Troubleshooting

The mesh has spikes all over it. Normal orientation is inconsistent. Re-orient toward a viewpoint above the site, and check that the cloud was denoised first — a single outlier drags a spike through the surface.

Poisson produced a bubble around the whole site. The octree extended beyond the data and closed the surface at its edge. Trim by density, and crop the result to the cloud’s own extent.

The mesh looks right and the texture is grey. Either the UV coordinates were lost during decimation, or the material references a texture file that was not exported alongside it. Export .glb, which embeds it.

The model jitters or shimmers when zoomed in. Positions are in projected coordinates and have been quantised to float32. Shift to a local origin before export.

Meshing runs out of memory. Poisson at depth 12 on a site-scale cloud needs tens of gigabytes. Tile the cloud with a buffer, mesh per tile, and merge — the same discipline as tiled classification.

Black patches that move as the camera moves. Inconsistent face winding. Run a winding repair before export rather than adjusting the viewer.

Point Cloud Processing & 3D Deliverables