RasmahRasmah

Surface meshing

Overview

A surface mesh approximates the boundary of a shape with a flat triangle for every small patch of surface. This chapter is about how those triangles are chosen: the exact icosphere for a sphere, marching cubes and dual contouring for general shapes, and the adaptive octree that spends triangles only where the surface is interesting.

The key trade-off is accuracy against cost. Every mesh is an approximation of a smooth surface, and the question is always how much error you accept for how many triangles. Rasmah lets you pick that trade-off explicitly.

The icosphere

For the one shape that is perfectly round, Rasmah uses a mesh that is perfectly regular. An icosphere starts from an icosahedron (12 vertices, 20 triangles) and subdivides each triangle into four, projecting every new vertex back onto the sphere:

Rasmah.icosphereFunction
icosphere(n) -> TriangleMesh

Return a unit icosphere (a TriangleMesh of radius 1, centred at the origin), built by subdividing an icosahedron n times.

Every vertex lies exactly on the unit sphere (midpoints are renormalized), so an icosphere is an exact discretization of the sphere's radius — the flat triangles only shorten the chords, which is why its area slightly underestimates $4\pi$ while its vertex positions are exact.

Arguments

  • n: the number of subdivision levels (each level splits every triangle into four).

Returns

A TriangleMesh with $20 \cdot 4^{n}$ faces.

Example

julia> using Rasmah

julia> round(surface_area(icosphere(2)); digits = 2)
12.33 m^2
source
vtk(icosphere(2))

Every vertex of an icosphere sits exactly on the sphere, so it is the exact surface sampled at a finite set of points — more accurate and more uniform than a marching-cubes sphere of the same cost.

The main entry point

surface_mesh is the single function that builds a surface mesh for any shape, choosing the best kernel for you:

Rasmah.surface_meshFunction
surface_mesh(g; n=nothing, adaptive=false, min_depth=0, max_depth=6,
             method=:marching, curvature=false, curvature_tol=0.1) -> TriangleMesh

Triangle-mesh the surface of an implicit geometry (ImplicitGeometry) or a Feature. This is the main surface-meshing entry point.

Theory

An implicit surface is the zero level set $\{\mathbf{x} : s(\mathbf{x}) = 0\}$ of a signed distance field $s$ (see the "Geometry & modeling" chapter). A TriangleMesh approximates that surface with flat triangles. The SDF is exact, so refining the mesh only moves the approximation closer to the true surface — the surface itself is the limit as the resolution grows.

Algorithm

With the default n = nothing the standard primitives resolve to a lightweight analytic mesh — an exact icosphere for a sphere, a 12-triangle box_mesh, a ruled cylinder/cone — which is both lighter and more accurate than marching cubes. Passing an explicit n scales the sphere's icosphere level and, for shapes with no analytic resolution, falls back to marching_cubes at n. adaptive = true selects the adaptive-octree surface-nets kernel (adaptive_surface_mesh), and method = :dual selects QEF dual_contouring, which preserves sharp edges and corners.

Keyword options

  • n = nothing — grid resolution. nothing picks the analytic meshes above; an integer enables the resolution-driven kernels.
  • adaptive = false — use the adaptive octree (refines near the surface only).
  • min_depth = 0, max_depth = 6 — octree depth bounds (adaptive only).
  • method = :marching:marching (marching cubes) or :dual (QEF dual contouring).
  • curvature = false — additionally coarsen flat surface cells (adaptive only).
  • curvature_tol = 0.1 — normal-turn threshold in radians (adaptive only).

Example

julia> m = surface_mesh(sphere(2.0));

julia> size(m.vertices, 2)
642

The default n = nothing resolves a sphere to an exact icosphere, so m has 642 vertices with every vertex exactly on the surface.

See also

tetrahedralize for the volume counterpart, and adaptive_surface_mesh / dual_contouring for the advanced kernels.

source
surface_mesh(b::BRep; n = nothing, parallel = :auto) -> TriangleMesh

Tessellate b into an orientation-consistent, watertight triangle mesh with outward normals. Adjacent faces share welded boundary vertices.

Keyword arguments

  • n: mesh resolution; nothing uses the default, an integer scales it.
  • parallel: threading mode (:auto, :none, :all, true, false).

Returns

A welded TriangleMesh whose signed volume equals the solid's volume.

source
vtk(surface_mesh(difference(box(2.0, 2.0, 2.0), cylinder(0.5, 2.2))))

Marching cubes

For a general shape there is no exact vertex placement, so Rasmah samples the SDF on a grid and extracts the $s = 0$ level set cell by cell. This is marching cubes — the workhorse isosurface algorithm:

Rasmah.marching_cubesFunction
marching_cubes(f, lo, hi, n; parallel = true) -> TriangleMesh

Watertight marching-cubes iso-surface of the SDF f at the zero level over the box [lo, hi] at resolution n.

source
g = to_sdf(translate(sphere(1.0), 0.3, 0.0, 0.0))
lo, hi = bounds(g)
vtk(marching_cubes(x -> g(x), lo, hi, 24))

Dual contouring

Marching cubes rounds sharp corners, because it places vertices only along grid edges. Dual contouring instead places one vertex per cell at the minimizer of a quadratic error function, so a box's edges and corners survive:

Rasmah.dual_contouringFunction
dual_contouring(f, lo, hi, n) -> TriangleMesh

Extract an isosurface of the SDF-like callable f over the box [lo, hi] by QEF dual contouring on an n×n×n grid. One vertex per surface cell is placed at the minimizer of the cell's quadratic error function, preserving sharp edges and corners; the vertices are stitched into fans around each crossing grid edge.

source
vtk(dual_contouring(x -> to_sdf(box(1.0, 1.0, 1.0))(x), [-1.5, -1.5, -1.5], [1.5, 1.5, 1.5], 24))

Adaptive surface meshing

A uniform grid wastes triangles on flat, featureless regions. The adaptive octree refines only where the surface is curved, producing a graded mesh that is fine near details and coarse elsewhere — all while staying crack-free:

Rasmah.adaptive_surface_meshFunction
adaptive_surface_mesh(f, lo, hi; min_depth=0, max_depth=6, qef=false, curvature=false, curvature_tol=0.1) -> TriangleMesh

Extract a crack-free surface TriangleMesh of the implicit surface f(x) = 0 over [lo, hi] by adaptive octree refinement and dual contouring (surface nets, or QEF dual contouring with qef = true). With curvature = true a surface cell is refined further only while the field is non-linear across it, so flat regions settle coarser than curved ones.

source
g = to_sdf(difference(box(2.0, 2.0, 2.0), sphere(1.2)))
lo, hi = bounds(g)
vtk(adaptive_surface_mesh(x -> g(x), lo, hi; max_depth=6))

Measuring a surface

Once you have a triangle mesh you can ask how big it is:

Rasmah.surface_areaFunction
surface_area(x) -> Quantity

The surface area of a TriangleMesh x, as an SI Quantity of area dimension (D_AREA; see the Units chapter).

The area is the sum of the triangle areas — half the norm of each face's edge cross product. Use area for a sketch, and surface_area(surface_mesh(g)) to measure a feature or implicit geometry.

Example

julia> using Rasmah

julia> round(surface_area(icosphere(2)); digits = 2)
12.33 m^2
source
round(surface_area(surface_mesh(sphere(1.0))); digits=4)
12.5065 m^2

The mesh reports $\approx 12.5065$ where the exact sphere has $4\pi \approx 12.5664$ — the icosphere slightly underestimates the smooth surface. The gap closes as you raise the resolution, which is the whole accuracy-vs-cost story.

Simplification and smoothing

The same mesh can be made lighter (fewer triangles) or smoother (less noise):

Rasmah.decimateFunction
decimate(m::TriangleMesh, ratio; preserve_boundary=true, feature_angle=nothing)

Quadric-error-metric surface simplification (Garland & Heckbert 1997): collapse edges in increasing quadric-error cost until the face count reaches ratio of the original. preserve_boundary=true pins boundary vertices; feature_angle (a dihedral-angle threshold in degrees) protects sharp-crest edges and corners from collapse.

source
m = surface_mesh(sphere(1.0), n=48)
vtk(decimate(m, 0.25))
Rasmah.smooth_surfaceFunction
smooth_surface(m::TriangleMesh; iterations=5, λ=0.5, μ=-0.8) -> TriangleMesh

Taubin λ|μ smoothing: alternating umbrella (graph-Laplacian) passes with a positive and a negative scale factor, denoising the surface without the shrinkage of plain Laplacian smoothing.

source
noisy = icosphere(3)
noisy.vertices .+= 0.03 .* randn(size(noisy.vertices))
vtk(smooth_surface(noisy; iterations=4))

Next steps

With the boundary covered, move to the interior: Volume meshing.