Skip to content

Mesh Geometry

Meshes are collections of simple polygons, typically triangles or quads, used for representing surfaces and solids. They are are motivated by their capability for accelerated processing via GPUs and for overcoming the limitations of mathematical surfaces which require well-behaved functions in the sense of continuity and derivatives.

Representations

Triangle Lists

The simplest way to define a mesh is via a collection of 3D points, every three thereof describing a triangle. A table ( n, 3 ) of points M = [[a1, b1, c1], ... [an, bn, cn]] is often used, however, it is also common to use a ( n, 3, 3 ) tensors in numpy and pytorch or flat lists of ( n * 9 ) coordinates when uploading the data to the GPU.

class TriangleList:
    def __init__( self, triangles ):
        self.Triangles = triangles

    def ToFloats( self ):
        coordinates = []
        for triangle in self.Triangles:
            for point in triangle:
                coordinates.append( point.X )
                coordinates.append( point.Y )
                coordinates.append( point.Z )
        return coordinates

    def ToNumPy( self ):
        array = np.zeros( ( len( self.Triangles ), 3, 3 ) )
        for i, triangle in enumerate( self.Triangles ):
            for i, point in enumerate( triangle ):
                array[i, j, 0] = point.X
                array[i, j, 1] = point.Y
                array[i, j, 2] = point.Z
        return array

mesh = TriangleList( [
    [Point3d( 0.0, 0.0, 0.0 ),
     Point3d( 1.0, 0.0, 0.0 ),
     Point3d( 1.0, 1.0, 0.0 )],

    [Point3d( 1.0, 1.0, 0.0 ),
     Point3d( 0.0, 1.0, 0.0 ),
     Point3d( 0.0, 0.0, 0.0 )]] )

This representation is the most unstructured way to describe meshes also known as a soup of triangles. It is used when each point is associated with a unique color [r, g, b, a] or a texture coordinate [u, v]. Otherwise, it is considered wasteful because often triangles share points which are duplicated here. For instance a cube has 8 vertices and 6 * 2 = 12 triangles, but with this representation we need 12 * 3 = 36 vertices! Note that this definition may be trivially extended to include quads or polygons instead of only triangles.

Indexed Meshes

The common way to represent meshes is using a list of points P = [P0, P1, ..., Pn-1], also known as vertices, and a list of indices F = [F0, F1, ..., Fn-1], also known as faces. A face F[i] is a just list of integers [a, b, c], where a, b and c describe which vertices to select from the vertex-list, by index, to form a triangle, namely P[a], P[b] and P[c]. For example, the face [4, 1, 6] represents a triangle with points P[4], P[1], and P[6]. The motivation for this representation is primarily for saving memory. A cube only requires 8 vertices as opposed to 36 with the equivalent triangle soup representation.

class IndexedMesh:
    def __init__( self, vertices, faces ):
        self.Vertices = vertices
        self.Faces    = indices

    ''' Face Index -> Points
    '''
    def TriangleAt( self, face ):
        ia, ib, ic = self.Faces[face]

        pa = self.Vertices[ia]
        pb = self.Vertices[ib]
        pc = self.Vertices[ic]

        return [pa, pb, pc]

mesh = IndexedMesh(
    [Point3d( 0.0, 0.0, 0.0 ),
     Point3d( 1.0, 0.0, 0.0 ),
     Point3d( 1.0, 1.0, 0.0 ),
     Point3d( 0.0, 1.0, 0.0 )],

    [[0, 1, 2], [2, 3, 0]] )

Rhino's meshes are based on the latter representation. The collection of points is accessed via the Vertices property and the triangulation indices via the Faces property. Note that Rhino supports mixed meshes containing not only triangles but also quads simultaneously. Newer versions also allow meshes with polygonal faces.

""" Mesh Construction
"""
mesh = Mesh( )

a = mesh.Vertices.Add( 0.0, 0.0, 0.0 )
b = mesh.Vertices.Add( 1.0, 0.0, 0.0 )
c = mesh.Vertices.Add( 1.0, 1.0, 0.0 )
d = mesh.Vertices.Add( 0.0, 1.0, 0.0 )

mesh.Faces.AddFace( a, b, c )
mesh.Faces.AddFace( c, d, a )

''' add quadrilateral '''

mesh.Faces.Add( a, b, c, d )

Note that mesh points in Rhino are represented by Point3f using single precision floating point numbers instead of Point3d which use doubles. This is because graphics cards work faster with lower precision values. Nevertheless, Point3f does not implement the range of methods Point3d does, thus for performing computations conversion is required.

""" Retrieve Mesh Vertex
"""
vertex = mesh.Vertices[0]

""" Convert to Point3d
"""
point = Point3d( vertex )

""" Perform Computation
"""
point += Vector3d.ZAxis

""" Convert to Point3f
"""
mesh.Vertices[0] = Point3f( point.X, point.Y, point.Z )

''' equivalent '''

mesh.Vertices.SetVertex( 0, point )

Conversions

Converting between indexed meshes and triangle lists is straight forward application of the indexing logic to select the appropriate vertices and package them into a list.

class IndexedMesh:
    def __init__( self, vertices, faces ):
        self.Vertices = vertices
        self.Faces    = indices

    def ToTriangleList( self ):
        triangles = [self.TriangleAt( index )
            for index in range( len( self.Faces ) )]
        return TriangleList( triangles )

The opposite conversion, from a triangle list to an indexed mesh, requires flattening all triangles into a list of vertices and serially numbering the faces [0, 1, 2], [3, 4, 5] .... Note that this process will not merge duplicate vertices and provide a compact the triangle soup.

class TriangleList:
    def __init__( self, triangles ):
        self.Triangles = triangles

    def ToIndexedMesh( self ):
        vertices, faces = [], []
        for triangle in self.Triangles:
            index = len( vertices )
            faces.append( [index + 0,
                           index + 1,
                           index + 2] )
            for point in triangle:
                vertices.append( point )
        return IndexedMesh( vertices, faces )

Mesh Normals

The concept of mesh normals is associated with ideas presented in the analytical curves and surfaces section. Mesh normals are defined per mesh point and express the locally tangent plane. Therefore, the number of normals is the same with the number of points. Mesh normals can be assigned arbitrarily or computed in the approximate sense, since there are no surface derivatives for meshes as they are fundamentally non-differentiable.

This approximation is performed by averaging the plane normals from the triangles connected with each point. There are two common methods for computing mesh normals, namely weighted and unweighted. The difference is in accumulating unnormalized or normalized normals for each triangle. In other words, using the triangle's area as a weight encoded in the cross product of its sides or not. Weighted normals is motivated by the potential presence of tiny or sliver triangles which may affect the normal direction adversely.

class IndexedMesh:
    def __init__( self, vertices, faces, normals ):
        self.Vertices = vertices
        self.Faces    = faces
        self.Normals  = normals

        if( normals is None ):
            self.ComputeNormals( )

    def ComputeNormals( self, weighted = False ):
        self.Normals = [Vector3d.Zero
            for _ in self.Vertices]

        for a, b, c in self.Faces:
            normal = Vector3d.CrossProduct(
                self.Vertices[b] - self.Vertices[a],
                self.Vertices[c] - self.Vertices[b] )
            if( not weighted ): normal.Unitize( )

            self.Normals[a] += normal
            self.Normals[b] += normal
            self.Normals[c] += normal

        for normal in self.Normals:
            normal.Unitize( )
        return self

In Rhino mesh normals have the same semantics. They can be assigned manually or computed automatically. Note that mesh normals are Vector3f instead of Vector3d, so conversions may be required.

""" Mesh Construction
"""
mesh = Mesh( )

a = mesh.Vertices.Add( 0.0, 0.0, 0.0 )
b = mesh.Vertices.Add( 1.0, 0.0, 0.0 )
c = mesh.Vertices.Add( 1.0, 1.0, 0.0 )
d = mesh.Vertices.Add( 0.0, 1.0, 0.0 )

mesh.Faces.AddFace( a, b, c )
mesh.Faces.AddFace( c, d, a )

''' manually '''
for _ in range( mesh.Vertices.Count ):
    mesh.Normals.Add( Vector3d.ZAxis )

''' computed '''

mesh.Normals.ComputeNormals( )

Face Winding

The order by which triangle points are used to define their normals is important because the cross product is not commutative. This implies that a triangle [a, b, c] will have its normal flipped if we use [a, c, b]. Meshes with inconsistent triangle winding can cause problems from visualization to 3d printing because mesh normals are used for lighting, hidden surface removal and solid closure determination.

""" Inconsistent Winding
"""
mesh = IndexedMesh(
    [Point3d( 0.0, 0.0, 0.0 ),
     Point3d( 1.0, 0.0, 0.0 ),
     Point3d( 1.0, 1.0, 0.0 ),
     Point3d( 0.0, 1.0, 0.0 )],

    [[0, 1, 2], [0, 2, 3]] ) #-- vs. [2, 3, 0]

Flat Shading

Mesh normals are used for smoothly shading triangles against light sources. However, a box expressed as an indexed mesh with 8 vertices and 12 triangles arguably looks weird. This is because the normals are averaged across faces with sharp angles.

To avoid this artifact and/or in general to force a mesh to appear with each triangle flat shaded the following approach can be used. The method below copies every triangle but the points are duplicated or unshared such that the normals will not wrap around corners.

class IndexedMesh:
    def __init__( self, vertices, faces, normals ):
        self.Vertices  = vertices
        self.Faces     = faces
        self.Normals   = normals
        if( normals is None ):
            self.ComputeNormals( )

    def ToFlatMesh( self ):
        points = []
        planes = []
        for index, ( a, b, c ) in enumerate( self.Faces ):
            points.append( self.Vertices[a] )
            points.append( self.Vertices[b] )
            points.append( self.Vertices[c] )
            planes.append( [index * 3 + 0,
                            index * 3 + 1,
                            index * 3 + 2] )
        return IndexedMesh( points, planes )

Mesh Colors

Assigning colors to mesh vertices is common from old-school video games to 3d printing and scientific visualization. Supporting per vertex colors is usually implemented with a list containing as many colors as the number of points. Colors may be encoded as either RGB [r, g, b] or RGBA [r, g, b, a] values, where the color components r, g, b and a, are either expressed as bytes [0, 255] or floats in [0.0, 1.0].

class IndexedMesh:
    def __init__( self, vertices, faces, normals ):
        self.Vertices  = vertices
        self.Faces     = faces
        self.Normals   = normals
        self.Colors    = colors

    def PaintNormals( self ):
        self.Colors = []
        for normal in self.Normals:
            ''' Float RGB values [0, 1]
            '''
            r = ( 1.0 + normal.X ) / 2.0
            g = ( 1.0 + normal.Y ) / 2.0
            b = ( 1.0 + normal.Z ) / 2.0

            ''' Byte RGB values [0, 255]
            '''
            r = int( 255 * r ) & 0xFF
            g = int( 255 * g ) & 0xFF
            b = int( 255 * b ) & 0xFF

            self.Colors.append( [r, g, b] )
        return self

    def PaintZ( self ):
        ''' Min and Max Z-coordinates
        '''
        zmin, zmax = 1e10, -1e10
        for point in self.Vertices:
            zmin = min( zmin, point.Z )
            zmax = max( zmax, point.Z )
        dz = zmax - zmin
        dz = 0.0 if( dz == 0.0 ) else 1.0 / dz

        self.Colors = []
        for point in self.Vertices:
            ''' Float Gray Tone
            '''
            gray = ( point.Z - zmin ) * dz

            ''' Byte Gray Tone
            '''
            gray = int( 255 * gray ) & 0xFF
            self.Colors.append( [gray, gray, gray] )
        return self

Rhino uses the color format representation of .NET / Windows where alpha, red, green and blue are packed into a single 32-bit integer.

from System.Drawing import Color

mesh = Mesh( )

a = mesh.Vertices.Add( 0.0, 0.0, 0.0 )
b = mesh.Vertices.Add( 1.0, 0.0, 0.0 )
c = mesh.Vertices.Add( 1.0, 1.0, 0.0 )
d = mesh.Vertices.Add( 0.0, 1.0, 0.0 )

mesh.VertexColors.Add( Color.FromArbg( 0xFF, 0xFF, 0xFF       ) ) #-- R, G, B
mesh.VertexColors.Add( Color.FromArbg( 0xFF0000               ) ) #-- RGB
mesh.VertexColors.Add( Color.FromArbg( 0x80, 0x00, 0xFF, 0x00 ) ) #-- A, R, G, B
mesh.VertexColors.Add( Color.FromArbg( 0x800000FF             ) ) #-- ARGB

mesh.Faces.AddFace( a, b, c )
mesh.Faces.AddFace( c, d, a )

Mesh Textures

Associating colors per vertex and letting the GPU smoothly bend them in the interior of triangles can only go as far as painting meshes with gradients. Texture mapping enables applying raster images with much more complex patterns. This is performed by associating each vertex with a texture coordinate, that is the pixel location in the image space.

Texture coordinates u and v for the horizontal and vertical image direction, respectively, are not expressed in pixels but in the unit range [0, 1]. When the letters s and t are used instead of u and v, the vertical direction of the image is flipped, that is u and s are always left-to-right but u is bottom-to-top while t is top-to-bottom, that is v = 1 - t.

class IndexedMesh:
    def __init__( self, vertices, faces, textures ):
        self.Vertices  = vertices
        self.Faces     = faces
        self.Textures  = textures

mesh = IndexedMesh(
    [Point3d( 0.0, 0.0, 0.0 ),
     Point3d( 1.0, 0.0, 0.0 ),
     Point3d( 1.0, 1.0, 0.0 ),
     Point3d( 0.0, 1.0, 0.0 )],

    [[0, 1, 2], [2, 3, 0]],

    [[0.0, 0.0],
     [1.0, 0.0],
     [1.0, 1.0],
     [0.0, 1.0]] )

Rhino uses the same semantics via the TextureCoordinates property. Note that texture coordinates use the s and t convention and they are single precision 2D points Point2f.

""" Texture Coordinates
"""
mesh = Mesh( )

a = mesh.Vertices.Add( 0.0, 0.0, 0.0 )
b = mesh.Vertices.Add( 1.0, 0.0, 0.0 )
c = mesh.Vertices.Add( 1.0, 1.0, 0.0 )
d = mesh.Vertices.Add( 0.0, 1.0, 0.0 )

mesh.TextureCoordinates.Add( 0.0, 0.0 )
mesh.TextureCoordinates.Add( 1.0, 0.0 )
mesh.TextureCoordinates.Add( 1.0, 1.0 )
mesh.TextureCoordinates.Add( 0.0, 1.0 )

mesh.Faces.AddFace( a, b, c )
mesh.Faces.AddFace( c, d, a )

Barycentrics

Vertex normals and colors are interpolated by the GPU for shading the interior pixels of triangles in visually smooth way. This process is known as barycentric interpolation and aims to smoothly blend between three quantities, which may be vertices, normals, colors etc.

A point p within a triangle [a, b, c], where a, b and c are its vertices, may be expressed as the ratio of areas of the triangles formed by a, b, c and p. The areas are computed using the convention A = |p, b, c|, B = |p, c, a| and C = |p, a, b|, where the area for vertex a involves point p and the opposite side of the triangle bc. The sum of these areas A + B + C equals the area of the triangle T = |a, b, c|, that is A + B + C = T. By dividing with the area of the triangle produces A/T + B/T + C/T = 1.

The terms wa = A/T, wb = B/T and wc = C/T are the barycentric coordinates or the weights of the interpolation. This because we can address every point p in the interior of the triangle by the weighted sum of the vertices p = a * wa + b * wb + c * wc. The values of the weights are in the unit range [0, 1]. When the weights are [1, 0, 0], [0, 1, 0]and [0, 0, 1], then the interpolated points are on the vertices a, b and c, respectively. Moreover, when the weights are [1/3, 1/3, 1/3] the interpolated point is at the centroid of the triangle ( a + b + c ) / 3, hence the name barycentric.

Barycentric interpolation enables blending normals and colors by replacing the triangles' vertices with the associated vectors and RGB values. This technique produces smooth gradients and light shading in 3D graphics.

""" Barycentric Interpolation
"""
def ToBarycentric( triangle, point ):
    a, b, c = triangle
    area = Vector3d.CrossProduct( b - a, c - b ).Length
    return Point3d(
        Vector3d.CrossProduct( b - point, c - b ).Length / area,
        Vector3d.CrossProduct( c - point, a - c ).Length / area,
        Vector3d.CrossProduct( a - point, b - a ).Length / area )

def FromBarycentric( triangle, point ):
    a, b, c = triangle
    return ( a * point.X +
             b * point.Y +
             c * point.Z )

Operations

Surface Area

The surface area of a mesh is computed by the sum of the areas of its triangles. The area of triangles can be computed using the cross product.

class IndexedMesh:
    def __init__( self, vertices, faces ):
        self.Vertices = vertices
        self.Faces    = faces

    @property
    def Triangles( self ):
        for ia, ib, ic in self.Faces:
            pa = self.Vertices[ia]
            pb = self.Vertices[ib]
            pc = self.Vertices[ic]
            yield [pa, pb, pc]

    @property
    def Area( self ):
        area = 0.0
        for pa, pb, pc in self.Triangles:
            area += Vector3d.CrossProduct(
                pb - pa, pc - pb ).Length
        return area / 2

In Rhino the AreaMassProperties class may be used for computing mesh volumes.

""" Computing Mesh Areas
"""
props = AreaMassProperties.Create( mesh )

print( mesh.Area )

Mesh Volume

Mesh volume is computed as the sum of signed volumes of tetrahedra formed by each triangle and the world origin, see detailed derivation. The volume associated with a triangle [a, b, c] and the world origin o = [0, 0, 0] is surprisingly easy to compute using a × b · c. Note that only properly closed meshes have meaningful volume.

class IndexedMesh:
    def __init__( self, vertices, faces ):
        self.Vertices = vertices
        self.Faces    = faces

    @property
    def Volume( self ):
        volume = 0.0
        for pa, pb, pc in self.Triangles:
            volume += Vector3d.CrossProduct( pa, pb ) * pc
        return abs( volume / 6 )

In Rhino the VolumeMassProperties class may be used for computing mesh volumes.

""" Computing Mesh Volumes
"""
props = VolumeMassProperties.Create( mesh )

print( mesh.Volume )

Deduplication

Removing duplicate mesh vertices or converting a triangle soup to an indexed mesh with shared vertices across faces is fairly complicated. This is because it requires finding all the unique or in other words shared vertices.

A simple approach is presented below, where an initially empty list of unique vertices and a list of faces is created. For each vertex from each triangle, first we look up whether the vertex exists or not in the unique vertex list. If the vertex exists, then we know its index, otherwise we append it at the end of the list and record the index as the length of unique vertices before insertion.

This algorithm is slow so it should not be used for large meshes.

''' Checks if a point existing in a list of points
    Returns the index if present, or -1 otherwise.
'''
def FindPoint( target, points, epsilon = 1e-5 ):
    for index, source in enumerate( points ):
        if( source.DistanceTo( target ) <= epsilon ):
            return index
    return -1

class IndexedMesh:
    def __init__( self, vertices, faces ):
        self.Vertices = vertices
        self.Faces    = faces

    ''' Removes duplicate vertices and returns a
        new compacted mesh.
    '''
    def RemoveDuplicateVertices( self, epsilon = 1e-5 ):
        vertices = [] #-- Unique vertices list
        faces    = [] #-- Reindexed faces list
        for triangle in self.Triangles:
            face = []
            for vertex in triangle:
                index = FindPoint( vertex, vertices )
                if( index < 0 ):
                    index = len( vertices )
                    vertices.append( vertex )
                face.append( index )
            faces.append( face )
        return IndexedMesh( vertices, faces )