Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Remesh example: Stanford bunny

The Unit sphere page uses an analytic sphere, whose constant curvature makes uniform and adaptive sizing behave almost identically. Here, we work a second, more realistic example: the Stanford bunny, a scanned surface with sharply varying curvature (ears, folds, paws) and, like most raw scans, a few holes. It shows how every remesh parameter behaves on a real mesh.

The Stanford bunny is a classic computer-graphics test model, originally scanned by Greg Turk and Marc Levoy at Stanford University in 1994.

Download

The mesh used here is the cleaned bunny from Alec Jacobson's community common-3d-test-models repository, mirrored on OneDrive for convenience. Each checksum below shows the first 12 hex characters of the sha256.

remesh reads STL (ASCII or binary), not OBJ, so convert the downloaded file once with the obj_to_binary_stl.py helper (shown at the end of this page), producing stanford_bunny.stl. All commands on this page use that converted file.

python obj_to_binary_stl.py stanford-bunny.obj stanford_bunny.stl

Alternatively, skip the conversion step and download the same file pre-converted:

  • stanford_bunny.stl (binary STL, pre-converted, sha256 909348a1c93e...) — hosted at OneDrive/automesh/data/stanford_bunny.stl

This is the exact output of the command above; either path produces the same stanford_bunny.stl used throughout the rest of this page.

The input mesh

stanford_bunny.png

quantitysymbolvalue
facets (triangles)69,451
points (vertices)34,834
edges104,288
boundary loops (holes)5

The triangle edge lengths of the scan cluster near a mean of ≈ 0.0015:

bunny_edge_histogram.png

Unlike the sphere, the bunny is not watertight. It is a single connected, manifold, genus-0 surface, but it is open: the base has five boundary loops (holes) left by the scanner. Consequently the closed-surface identities from the sphere example do not hold here:

  • is odd, whereas a closed triangular mesh requires and therefore an even facet count.
  • The Euler characteristic is (equivalently with genus and holes), rather than the of a closed sphere.

Remeshing handles the open surface without trouble; the boundary loops are preserved through remeshing.

Uniform sizing (--size)

The target edge length sets the triangle size. A smaller --size produces more, smaller triangles; a larger --size produces fewer, larger triangles.

automesh remesh -i stanford_bunny.stl -o bunny_uniform_fine.stl   uniform -s 0.004 -n 20
automesh remesh -i stanford_bunny.stl -o bunny_uniform_coarse.stl uniform -s 0.006 -n 20
fine, -s 0.004 (7,715 facets)coarse, -s 0.006 (3,528 facets)
bunny_uniform_fine.pngbunny_uniform_coarse.png

Iterations and coarsening (--iterations)

The bunny highlights an effect the sphere did not: coarsening a fine mesh to a large target edge length is iteration-limited. Each pass can only collapse edges so much, so reaching a coarse target from a dense input takes several passes. At the same target -s 0.006, five iterations barely coarsen the 69,451-triangle input, while twenty iterations reach the target:

automesh remesh -i stanford_bunny.stl -o bunny_iter_n5.stl        uniform -s 0.006 -n 5
automesh remesh -i stanford_bunny.stl -o bunny_uniform_coarse.stl uniform -s 0.006 -n 20
-n 5 (29,826 facets)-n 20 (3,528 facets)
bunny_iter_n5.pngbunny_uniform_coarse.png

This is the opposite regime from the sphere, where the input was already near the target and five iterations sufficed. When coarsening a dense scan, increase --iterations.

Uniform vs. adaptive

This is where the bunny differs most from the sphere. Because the bunny's curvature varies, curvature-adaptive sizing produces a visibly different mesh from uniform sizing at the same triangle budget: adaptive keeps small triangles on high-curvature features (ears, head, paws) and enlarges them on the smooth flanks.

A wide edge-length spread (--minimum 0.002 --maximum 0.040) with a low --tolerance accentuates this: only the highest-curvature regions are refined to the minimum, while everything smooth relaxes toward the maximum.

automesh remesh -i stanford_bunny.stl -o bunny_compare_uniform.stl uniform  -s 0.0036 -n 20
automesh remesh -i stanford_bunny.stl -o bunny_adaptive.stl        adaptive --minimum 0.002 --maximum 0.040 -n 25 -t 0.02
uniform (9,541 facets)adaptive (9,698 facets)
bunny_compare_uniform.pngbunny_adaptive.png

Both meshes use a similar number of facets, but adaptive spends them where the surface bends most: the ears, head, and paws are finely triangulated while the smooth flanks and haunches are left coarse.

Adaptive curvature tolerance (--tolerance)

The tolerance sets the target edge length through the Dunyach sizing formula1, as implemented in the conspire Rust library2 on which automesh is built:

where is the tolerance and is the local surface curvature (flat regions, , take the maximum edge length).

Its effect on the facet count is not monotonic — and this is the part that is easy to get backwards. Sweeping the tolerance at fixed --minimum 0.002 --maximum 0.040 -n 25 gives a U-shaped curve:

automesh remesh -i stanford_bunny.stl -o bunny_tol_tight.stl adaptive --minimum 0.002 --maximum 0.040 -n 25 -t 0.0002
automesh remesh -i stanford_bunny.stl -o bunny_tol_mid.stl   adaptive --minimum 0.002 --maximum 0.040 -n 25 -t 0.002
automesh remesh -i stanford_bunny.stl -o bunny_tol_loose.stl adaptive --minimum 0.002 --maximum 0.040 -n 25 -t 0.02

bunny_tolerance.png

Both very small and very large tolerances refine the mesh; the coarsest result is in between (near here):

tight, -t 0.0002 (7,133 facets)moderate, -t 0.002 (1,452 facets)loose, -t 0.02 (9,698 facets)
bunny_tol_tight.pngbunny_tol_mid.pngbunny_tol_loose.png

Why the U shape? The tolerance acts as a curvature cutoff : regions sharper than clamp to the minimum edge length, while flatter regions follow the formula above.

  • Small (large cutoff): few regions reach the cutoff, but the formula itself returns short edges wherever curvature is nonzero, so the mesh is fine.
  • Large (small cutoff): the term drives the argument negative over more of the surface, so more regions clamp to the minimum — the mesh is fine again.
  • In between, most of the surface sits in the formula regime at moderate edge lengths, giving the coarsest mesh.

In practice, sweep the tolerance for your surface (as above), pick a value near the coarse minimum, and set --minimum/--maximum for the resolution you want. The sweep is produced by the remesh_bunny_tolerance.py script.

Adaptive size gradation (--gradation)

The gradation limits how quickly the target edge length may change between neighboring triangles. A small gradation forces a slow, smooth transition, so the fine triangles near features spread outward across the surface (many more facets); a large gradation allows a rapid transition, keeping the refinement localized to the features (fewer facets). Using the same baseline as above (--minimum 0.002 --maximum 0.040 -n 25 -t 0.02):

automesh remesh -i stanford_bunny.stl -o bunny_adapt_grad_lo.stl adaptive --minimum 0.002 --maximum 0.040 -n 25 -t 0.02 -g 0.1
automesh remesh -i stanford_bunny.stl -o bunny_adapt_grad_hi.stl adaptive --minimum 0.002 --maximum 0.040 -n 25 -t 0.02 -g 0.9
-g 0.1 — gradual (22,086 facets)-g 0.9 — sharp (7,016 facets)
bunny_adapt_grad_lo.pngbunny_adapt_grad_hi.png

Parameters at a glance

parametermodeeffect
--sizeuniformtarget edge length; smaller → more, smaller triangles
--iterationsbothnumber of passes; more passes are needed to reach a coarse target from a dense input
--minimum / --maximumadaptivebounds on edge length across the surface
--toleranceadaptivecurvature cutoff in the Dunyach formula; facet count is non-monotonic (coarsest at a mid-range value)
--gradationadaptiverate the edge length may change between neighbors; smaller → more gradual (more facets), larger → sharper (fewer facets)

Figure script

The figures on this page are produced by the following script, which reads each STL surface and renders it with a matched camera (remapping the bunny's +y up-axis so it stands upright).

r"""This module, remesh_bunny_figures.py, renders the Stanford bunny surface
triangulations used in the Stanford bunny remeshing example.  It reads the input
bunny and each remeshed output (produced by `automesh remesh`) and saves a
matched-camera PNG of every mesh.

The bunny's up-axis is +y; the renderer remaps model coordinates (x, y, z) to
plot coordinates (x, z, y) so the bunny stands upright.

Example
-------
source ~/autotwin/automesh/.venv/bin/activate
cd ~/autotwin/automesh/book/examples/remesh
python remesh_bunny_figures.py

Output
------
The `bunny_*.png` visualization files, written next to this script.
"""

import struct
from pathlib import Path
from typing import Final

import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import numpy as np
from numpy.typing import NDArray

# Shared "hero" view so only the triangulation changes between figures.
ELEV: Final[float] = 18.0
AZIM: Final[float] = 55.0
FACECOLOR: Final[str] = "lightblue"
EDGECOLOR: Final[str] = "navy"


def read_stl(path: Path) -> NDArray[np.float64]:
    """Reads triangular facets from a binary STL file, shape (n_facets, 3, 3)."""
    data = path.read_bytes()
    (n_facets,) = struct.unpack_from("<I", data, 80)
    facets = np.empty((n_facets, 3, 3), dtype=np.float64)
    offset = 84
    for i in range(n_facets):
        values = struct.unpack_from("<12f", data, offset)
        facets[i] = np.array(values[3:12]).reshape(3, 3)
        offset += 50
    # Remap (x, y, z) -> (x, z, y) so the bunny's +y up-axis points up in the plot.
    return facets[:, :, [0, 2, 1]]


def edge_lengths(facets: NDArray[np.float64]) -> NDArray[np.float64]:
    """Returns the length of every unique undirected edge in the mesh."""
    keyed = np.round(facets.reshape(-1, 3), 8)
    _, inverse = np.unique(keyed, axis=0, return_inverse=True)
    ids = inverse.reshape(len(facets), 3)
    seen = set()
    lengths = []
    for tri, (a, b, c) in zip(facets, ids):
        for (u, v), (p, q) in (((a, b), (0, 1)), ((b, c), (1, 2)), ((c, a), (2, 0))):
            key = (int(min(u, v)), int(max(u, v)))
            if key not in seen:
                seen.add(key)
                lengths.append(float(np.linalg.norm(tri[p] - tri[q])))
    return np.array(lengths)


def render_histogram(stl: Path, out_name: str) -> None:
    """Saves a histogram of the triangle edge lengths of the given mesh."""
    lengths = edge_lengths(read_stl(stl))
    fig, ax = plt.subplots(figsize=(6, 4))
    ax.hist(lengths, bins=40, color=FACECOLOR, edgecolor=EDGECOLOR)
    ax.axvline(
        lengths.mean(),
        color="crimson",
        linestyle="--",
        linewidth=1.5,
        label=f"mean = {lengths.mean():.4f}",
    )
    ax.set_xlabel("triangle edge length")
    ax.set_ylabel("count")
    ax.set_title(f"{stl.stem}: edge length distribution")
    ax.legend()
    png = stl.with_name(out_name)
    fig.savefig(png, dpi=150, bbox_inches="tight")
    plt.close(fig)
    print(f"wrote {png.name} ({len(lengths):,} edges)")


def render(stl: Path, title: str) -> None:
    """Renders a single bunny STL to a PNG next to it.  Dense meshes are drawn
    without edges (a shaded surface); coarse meshes show their triangle edges."""
    facets = read_stl(stl)
    n = len(facets)
    # Show triangle edges for all but the very dense input scan, which is drawn
    # as a shaded surface.  Thin the lines as the facet count grows.
    show_edges = n <= 40000
    linewidth = 0.25 if n <= 10000 else 0.12
    fig = plt.figure(figsize=(5, 5))
    ax = fig.add_subplot(111, projection="3d")
    surface = Poly3DCollection(
        facets,
        facecolor=FACECOLOR,
        edgecolor=EDGECOLOR if show_edges else "none",
        linewidths=linewidth if show_edges else 0.0,
        rasterized=True,
    )
    surface.set_alpha(1.0)
    ax.add_collection3d(surface)

    pts = facets.reshape(-1, 3)
    lo, hi = pts.min(0), pts.max(0)
    center = (lo + hi) / 2
    radius = (hi - lo).max() / 2
    for setter, c in zip((ax.set_xlim, ax.set_ylim, ax.set_zlim), center):
        setter(c - radius, c + radius)
    ax.set_box_aspect((1, 1, 1))
    ax.view_init(elev=ELEV, azim=AZIM)
    ax.set_axis_off()
    ax.set_title(f"{title}\n{n:,} facets", fontsize=11)
    png = stl.with_suffix(".png")
    fig.savefig(png, dpi=150, bbox_inches="tight")
    plt.close(fig)
    print(f"wrote {png.name} ({n:,} facets)")


def main() -> None:
    here = Path(__file__).resolve().parent
    figures = {
        "stanford_bunny": "input scan",
        "bunny_uniform_fine": "uniform, size 0.004",
        "bunny_uniform_coarse": "uniform, size 0.006",
        "bunny_iter_n5": "uniform 0.006, 5 iterations",
        "bunny_compare_uniform": "uniform, size 0.0036",
        "bunny_adaptive": "adaptive, 0.002-0.040",
        "bunny_tol_tight": "tolerance 0.0002",
        "bunny_tol_mid": "tolerance 0.002",
        "bunny_tol_loose": "tolerance 0.02",
        "bunny_adapt_grad_lo": "adaptive, gradation 0.1",
        "bunny_adapt_grad_hi": "adaptive, gradation 0.9",
    }
    for stem, title in figures.items():
        stl = here / f"{stem}.stl"
        if stl.exists():
            render(stl, title)
        else:
            print(f"skipping {stl.name} (not found)")

    # Edge-length histogram of the input scan.
    base = here / "stanford_bunny.stl"
    if base.exists():
        render_histogram(base, "bunny_edge_histogram.png")


if __name__ == "__main__":
    main()

Tolerance study script

The tolerance sweep plot is produced by the following script, which remeshes the bunny at several --tolerance values and records the facet count:

r"""This module, remesh_bunny_tolerance.py, studies how the adaptive
`--tolerance` affects the facet count of the remeshed Stanford bunny.

The adaptive edge length follows the Dunyach formula
``L = sqrt(6 * tolerance / curvature - 3 * tolerance**2)`` clamped to
``[minimum, maximum]``.  Because of the ``- 3 * tolerance**2`` term, the facet
count is *non-monotonic* in the tolerance: very small and very large tolerances
both refine the mesh, with the coarsest result in between.  This script sweeps
the tolerance at fixed ``--minimum``/``--maximum``/``--iterations`` and plots the
resulting facet count.

Example
-------
source ~/autotwin/automesh/.venv/bin/activate
cd ~/autotwin/automesh/book/examples/remesh
# `automesh` must be on the PATH (e.g. target/release)
python remesh_bunny_tolerance.py

Output
------
The `bunny_tolerance.png` plot, written next to this script, and a summary table
printed to the terminal.
"""

import os
import shutil
import struct
import subprocess
import tempfile
from pathlib import Path

import matplotlib.pyplot as plt

TOLERANCES = [0.0002, 0.0005, 0.001, 0.002, 0.004, 0.008, 0.02, 0.05]
MINIMUM, MAXIMUM, ITERATIONS = 0.002, 0.040, 25
FACECOLOR = "lightblue"
EDGECOLOR = "navy"


def automesh_binary() -> str:
    """Locates the `automesh` executable: the AUTOMESH environment variable, then
    the PATH, then the repository's target/release build."""
    candidate = os.environ.get("AUTOMESH") or shutil.which("automesh")
    if candidate:
        return candidate
    fallback = Path(__file__).resolve().parents[2] / "target" / "release" / "automesh"
    if fallback.exists():
        return str(fallback)
    raise FileNotFoundError(
        "could not find `automesh`; set AUTOMESH or add it to the PATH"
    )


def facet_count(stl: Path) -> int:
    """Reads the facet count from a binary STL header (bytes 80-84)."""
    with stl.open("rb") as file:
        file.seek(80)
        return struct.unpack("<I", file.read(4))[0]


def main() -> None:
    here = Path(__file__).resolve().parent
    source = here / "stanford_bunny.stl"
    automesh = automesh_binary()
    counts = []
    print(f"{'tolerance':>10} {'facets':>8}")
    with tempfile.TemporaryDirectory() as tmp:
        for tol in TOLERANCES:
            out = Path(tmp) / "t.stl"
            subprocess.run(
                [automesh, "remesh", "-i", str(source), "-o", str(out),
                 "adaptive", "--minimum", str(MINIMUM), "--maximum", str(MAXIMUM),
                 "-n", str(ITERATIONS), "-t", str(tol)],
                check=True, capture_output=True,
            )
            n = facet_count(out)
            counts.append(n)
            print(f"{tol:>10} {n:>8}")

    coarsest = TOLERANCES[counts.index(min(counts))]
    fig, ax = plt.subplots(figsize=(6, 4))
    ax.plot(TOLERANCES, counts, "o-", color=EDGECOLOR, markerfacecolor=FACECOLOR)
    ax.set_xscale("log")
    ax.set_yscale("log")
    ax.set_xlabel("--tolerance")
    ax.set_ylabel("facets")
    ax.set_title("Bunny facet count vs. adaptive tolerance")
    ax.axvline(
        coarsest, color="crimson", linestyle="--", linewidth=1.0,
        label=f"coarsest near {coarsest}",
    )
    ax.legend()
    ax.grid(True, which="both", alpha=0.3)
    png = here / "bunny_tolerance.png"
    fig.savefig(png, dpi=150, bbox_inches="tight")
    plt.close(fig)
    print(f"wrote {png.name}")


if __name__ == "__main__":
    main()

Helper: OBJ to binary STL

The Stanford bunny is distributed as an OBJ, but remesh reads STL, not OBJ. This helper converts the downloaded OBJ to the stanford_bunny.stl used above:

r"""This module, obj_to_binary_stl.py, converts a triangular OBJ mesh into a
binary STL file.  `automesh remesh` reads STL, not OBJ, so the Stanford
bunny OBJ from Alec Jacobson's repository must be converted before use.

Example
-------
source ~/autotwin/automesh/.venv/bin/activate
cd ~/autotwin/automesh/book/examples/remesh
python obj_to_binary_stl.py stanford-bunny.obj stanford_bunny.stl
"""

import struct
import sys
from pathlib import Path

import numpy as np


def obj_to_binary_stl(source: Path, target: Path) -> None:
    """Reads a triangular OBJ and writes an equivalent binary STL."""
    verts: list[tuple[float, float, float]] = []
    faces: list[tuple[int, int, int]] = []
    for line in source.read_text().splitlines():
        tokens = line.split()
        if not tokens:
            continue
        if tokens[0] == "v":
            verts.append(tuple(float(x) for x in tokens[1:4]))
        elif tokens[0] == "f":
            # OBJ is 1-indexed; entries may be v, v/vt, or v/vt/vn. Fan-triangulate.
            idx = [int(p.split("/")[0]) - 1 for p in tokens[1:]]
            for k in range(1, len(idx) - 1):
                faces.append((idx[0], idx[k], idx[k + 1]))

    coords = np.array(verts, dtype=np.float64)
    with target.open("wb") as out:
        out.write(b"\0" * 80)  # 80-byte header (ignored)
        out.write(struct.pack("<I", len(faces)))  # facet count
        for a, b, c in faces:
            p, q, r = coords[a], coords[b], coords[c]
            normal = np.cross(q - p, r - p)
            length = np.linalg.norm(normal)
            normal = normal / length if length > 0 else normal
            out.write(struct.pack("<12fH", *normal, *p, *q, *r, 0))
    print(f"wrote {len(faces)} facets to {target}")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit("usage: python obj_to_binary_stl.py <mesh.obj> <mesh.stl>")
    obj_to_binary_stl(Path(sys.argv[1]), Path(sys.argv[2]))

References


  1. Dunyach M, Vanderhaeghe D, Barthe L, Botsch M. Adaptive remeshing for real-time mesh deformation. In Eurographics 2013 Short Papers. 2013. paper · DOI

  2. Buche MR. conspire — a Rust library for computational continuum mechanics, version 0.7.3. repository · crate