Decimating Meshes Without Losing Breaklines

The twenty-million-triangle mesh looked superb. The two-million-triangle version delivered to the client has rounded kerbs, a quarry bench edge that curves where it should be sharp, and a building parapet that has become a gentle ramp. The average geometric error is 1.4 cm — well inside budget — and the model is visibly wrong in every place a viewer looks first.

This is not a bug in the decimator. Quadric edge collapse minimises a sum of squared distances, and the cheapest way to reduce that sum is to collapse edges in regions where many triangles describe a simple shape. A crisp break in the surface is, mathematically, an expensive feature holding a lot of triangles; removing it buys a large face reduction for a small average error. The optimiser is doing exactly what it was asked.

Why the average is the wrong objective

Consider a flat bench with a sharp 1.2 m drop. In the full mesh the drop is described by a few thousand triangles packed along the edge. Collapsing them moves each affected vertex by a few centimetres — small individually — while removing thousands of faces. Elsewhere, on a gently curving slope, collapsing a similar number of faces moves vertices by comparable amounts but saves the same count. The optimiser sees two equally attractive trades.

To a human they are not equal at all. A few centimetres of error spread over a smooth slope is invisible. The same few centimetres applied at a break turns a discontinuity into a bevel, and the eye detects that instantly because it changes the shape class of the feature rather than its position.

The fix is to tell the decimator that some edges are not ordinary. There are three mechanisms, and using all three together costs nothing.

The same geometric error applied to a smooth slope and to a break Two cross-sections. On the left a gentle slope is shown before and after decimation; the two profiles differ by three centimetres and are visually identical. On the right a sharp bench edge is shown before and after; the same three centimetre displacement converts a right-angled break into a visible bevel. Annotations note that the average error is identical in both cases while only one is acceptable, and that the decimator has no way to tell them apart without being told. smooth slope — 3 cm error invisible break — 3 cm error obvious the two profiles overlap at any zoom a client uses a right angle has become a ramp identical average error, opposite outcomes The optimiser cannot see the difference. It has to be encoded in the cost function.

Figure 1 — Why a global error budget is necessary but not sufficient.

Minimal reproducible solution

Three settings, applied together, change the outcome completely.

import pymeshlab


def decimate_preserving_breaks(in_path: str, out_path: str,
                               target_faces: int,
                               *, quality_threshold: float = 0.35,
                               boundary_weight: float = 2.0) -> dict:
    """Quadric decimation with the three break-preserving constraints enabled.

    preservenormal keeps collapses from flipping a face's orientation, which
    is what turns a crease into a fold. planarquadric adds a term that makes
    collapses inside flat regions cheaper than collapses across a change of
    plane — which is precisely the discrimination the plain algorithm lacks.
    preserveboundary protects open edges, including the outer rim of a tile.
    """
    ms = pymeshlab.MeshSet()
    ms.load_new_mesh(in_path)
    before = ms.current_mesh().face_number()

    ms.meshing_decimation_quadric_edge_collapse(
        targetfacenum=target_faces,
        qualitythr=quality_threshold,     # reject collapses making sliver faces
        preserveboundary=True,
        boundaryweight=boundary_weight,   # how expensive a boundary collapse is
        preservenormal=True,
        preservetopology=True,
        planarquadric=True,               # the key term for flat-vs-break
        autoclean=True)

    ms.save_current_mesh(out_path)
    after = ms.current_mesh().face_number()
    return {"before": before, "after": after, "reduction": 1 - after / before}

planarquadric is the setting that does the heavy lifting and the one most often left off. It adds a penalty proportional to how far the collapse departs from the local plane, which makes collapses within a flat face nearly free and collapses across a change of plane expensive. That is exactly the distinction the human eye is making.

Where the breaks are known in advance — a kerb line digitised from the orthomosaic, a bench crest from a mine plan — they can be enforced rather than inferred:

import numpy as np
import trimesh


def mark_breakline_vertices(mesh_path: str, lines_xy: list[np.ndarray],
                            tolerance: float = 0.15) -> np.ndarray:
    """Vertices within `tolerance` of a supplied breakline, to be locked.

    Locking beats weighting when the geometry is known: a vertex that must
    not move is a constraint, not a preference, and the decimator will find
    its face reduction somewhere else.
    """
    mesh = trimesh.load(mesh_path, process=False)
    v = mesh.vertices[:, :2]
    locked = np.zeros(len(mesh.vertices), dtype=bool)
    for line in lines_xy:
        for i in range(len(line) - 1):
            a, b = line[i], line[i + 1]
            ab = b - a
            t = np.clip(((v - a) @ ab) / max(ab @ ab, 1e-12), 0, 1)
            proj = a + t[:, None] * ab
            locked |= np.linalg.norm(v - proj, axis=1) < tolerance
    return locked
What decimation is allowed to remove and what it is not Three categories are listed. Freely removable are triangles on planar or gently curved surfaces where many small faces describe something a few large ones would describe as well. Removable with care are triangles on smoothly curved surfaces, where aggressive decimation produces visible faceting under a specular material even though the geometry error stays small. Protected are triangles along breaklines — kerbs, crests, toe lines, structure edges — because those are the features a consumer will measure or trace against, and a decimator with no edge constraint removes them first, since they cost the least in average geometric error. freely removable planar and gently curved surfaces — many small faces for little information removable with care smooth curvature — aggressive decimation facets visibly under specular light protected breaklines: kerbs, crests, toes, structure edges — what a consumer traces against An unconstrained decimator removes breaklines first, because they cost the least average error.

Figure 3 — The features worth keeping are the ones a plain error metric discards first.

Edge-case matrix

Mesh feature Plain decimation With the three settings
Kerb, 120 mm rise Rounded into a ramp Retained to within a centimetre
Quarry bench crest Bevelled over ~1 m Sharp
Building parapet Merged into the roof plane Retained
Gentle terrain slope Correctly simplified Correctly simplified
Tile boundary edge Collapsed, leaving a visible seam Protected by preserveboundary
Vegetation canopy Aggressively simplified (fine) Slightly less reduction
Thin fence panel Collapsed to nothing Survives if preservetopology is on
Ground under a gap Nothing to preserve Unchanged

The vegetation row is the trade-off. Break-preserving settings reduce the achievable face count on organic geometry by ten to twenty percent, because canopy has breaks everywhere. On a mixed site this is a small price; on a purely woodland model the settings can be relaxed.

Verification snippet

Verify against a metric that reflects the failure. A ninety-fifth-percentile error catches gross damage; the useful test measures how much edge sharpness was lost.

import numpy as np
import trimesh


def sharpness_retention(original_path: str, decimated_path: str,
                        sharp_angle_deg: float = 40.0) -> dict:
    """Share of sharp dihedral edges in the original that survive decimation.

    A dihedral angle above the threshold is a break. Measuring the total
    length of such edges before and after gives a single number that tracks
    exactly the defect the average error misses.
    """
    def sharp_edge_length(path: str) -> float:
        m = trimesh.load(path, process=False)
        angles = m.face_adjacency_angles
        sharp = angles > np.radians(sharp_angle_deg)
        edges = m.face_adjacency_edges[sharp]
        if len(edges) == 0:
            return 0.0
        v = m.vertices
        return float(np.linalg.norm(v[edges[:, 0]] - v[edges[:, 1]], axis=1).sum())

    before = sharp_edge_length(original_path)
    after = sharp_edge_length(decimated_path)
    ratio = after / before if before else float("nan")
    return {"sharp_length_before_m": before, "sharp_length_after_m": after,
            "retention": ratio,
            "verdict": "acceptable" if ratio > 0.75 else "breaklines lost"}

A retention figure above about 0.75 corresponds to a model that reads correctly; below 0.5 the bevelling is obvious to anyone who looks. Tracking this number across releases is far more informative than tracking the face count, and it takes seconds.

Sharp-edge retention against reduction ratio, with and without the settings Two curves of sharp-edge length retention against how far the mesh was reduced, from no reduction to ninety-nine percent. With break-preserving settings the retention stays above seventy-five percent until about ninety-five percent reduction. Without them it falls below seventy-five percent by sixty percent reduction and below half by eighty percent. A horizontal band marks the acceptable region above seventy-five percent retention. acceptable 0 % 40 % 60 % 80 % 95 % 99 % face reduction sharp-edge retention settings on settings off Same decimator, same target, one metric that tracks what people actually notice.

Figure 2 — Retention, not face count, is the number worth putting in a release note.

Choosing where the detail goes

Once retention is being measured, a better strategy than uniform decimation becomes available: spend the face budget where it matters. A site model does not need the same triangle density on a car park as on a retaining wall, and most viewers handle a mesh with varying density perfectly well.

The practical approach is a two-pass decimation driven by a region mask. Areas of interest — structures, edges of excavation, anything the client named in the brief — are decimated to a tight error budget; everything else is decimated hard. The combined mesh is smaller than a uniform one at the same perceived quality, often by half.

import numpy as np
import pymeshlab


def decimate_by_region(in_path: str, out_path: str,
                       detail_error_m: float = 0.015,
                       bulk_error_m: float = 0.08) -> dict:
    """Two-tier decimation: tight budget inside the detail mask, loose outside.

    The masks are applied as vertex quality, which pymeshlab's decimator
    reads as a per-vertex weight. A vertex with high quality is expensive to
    collapse, so the reduction happens preferentially in the bulk regions.
    """
    ms = pymeshlab.MeshSet()
    ms.load_new_mesh(in_path)
    ratio = bulk_error_m / detail_error_m
    ms.compute_scalar_by_function_per_vertex(
        q=f"(q > 0.5) ? {ratio} : 1.0")          # detail vertices weighted up
    ms.meshing_decimation_quadric_edge_collapse(
        targetfacenum=ms.current_mesh().face_number() // 8,
        qualityweight=True, preserveboundary=True,
        preservenormal=True, planarquadric=True)
    ms.save_current_mesh(out_path)
    return {"faces": ms.current_mesh().face_number()}

Two things make this worth the extra step on a regular delivery. The face budget is usually set by the client’s viewer rather than by the geometry, so it is fixed — and within a fixed budget, uniform decimation is strictly worse than weighted decimation whenever the site has areas of differing importance, which is every site. And the mask itself is cheap to produce: a buffer around the structures already classified in separating buildings from vegetation in point clouds is usually good enough, and where the client named specific features, a digitised polygon is better still.

Record the mask alongside the mesh. A model that is sharp in the wrong places is a question about the brief, and answering it a month later is far easier when the region definition shipped with the file.

When to escalate

  • Retention is poor even with the settings on. The breaks may not exist in the source mesh — a Poisson reconstruction at too low an octree depth rounds them before decimation ever runs. Check the full-resolution mesh first.
  • The client needs measurable edges, not just visible ones. A mesh is the wrong deliverable. Extract the breaklines as vectors from the point cloud and deliver them alongside a coarser mesh.
  • The target face count cannot be met with retention above 0.75. The site has more genuine detail than the budget allows. Either raise the budget or split the model into a coarse whole-site mesh and high-detail tiles for the areas that matter.

Mesh and Texture Generation Automation