Motmot

A sophisticated mesh class for analysing colourless Polygon meshes such as an STL file providing a seamless abstraction between raw vectors meshes or the more efficient vertices + faces (a.k.a vertices + polygons) form.

Usage

The basic API for motmot is modelled off that of numpy-stl with a few alterations.

Initialisation

Meshes can be :

  1. Constructed from scratch using a single vectors array. This array should be 3D with shape (n, k, 3) where:

    • n is the number of polygons in the mesh,

    • k is the number of corners each polygon has,

    • 3 corresponds to having 3 axes. i.e. x, y and z.

    # vectors is a (n, 3, 3) numpy array.
    triangle_mesh = Mesh(vectors)
    
    # vectors is a (n, 4, 3) numpy array.
    square_mesh = Mesh(vectors)
    
  2. Or using the more memory efficient vertices + faces form.

    # `vertices` is an array of points. It should contain no duplicates.
    # `faces` is an integer array indicating which vertices are used to construct
    # each polygon.
    mesh = Mesh(vertices, faces)
    
  3. Read from an STL file. This uses numpy-stl under the hood. Currently, STL is the only file format that motmot will read implicitly:

    from motmot import Mesh
    mesh = Mesh("some-file.stl")
    
  4. Read from an lzma, gzip or bzip2 compressed STL file:

    from motmot import Mesh
    
    # An lzma compressed STL file. Create using `xz some-file.stl` in bash.
    mesh = Mesh("some-file.stl.xz")
    # A gzip compressed STL file. Create using `gzip some-file.stl` in bash.
    mesh = Mesh("some-file.stl.gz")
    # A bzip2 compressed STL file. Create using `bzip2 some-file.stl` in bash.
    mesh = Mesh("some-file.stl.bz2")
    
  5. Stream from any subclass of io.RawIOBase. From here you can read from arbitrary sources such as embedded files, streams, archives or other pseudo files. For example, the following reads an STL directly from a web request:

    from urllib import request
    from motmot import Mesh
    
    # Pull an STL file from the internet and load it without an intermediate
    # temporary file.
    url = "https://raw.githubusercontent.com/bwoodsend/vtkplotlib/master/" \
          "vtkplotlib/data/models/rabbit/rabbit.stl"
    
    with request.urlopen(url) as req:
        mesh = Mesh(req)
    

Vertices + Faces meshes vs Vectors meshes

There are two forms of mesh.

  1. A vectors mesh is essentially a list of polygons where each polygon is a list of points (its corners) and each point is an (x, y, z) triplet. This form is simple but wasteful because points which appear in multiple polygons are written multiple times which wastes memory and rendering time.

  2. A vertices+faces mesh takes all the unique points from a vectors mesh, calling them the vertices, then replaces each point in vectors with its index from vertices, calling this faces. Note that faces is often also known as facets or polygons.

Motmot makes the two forms interchangeable. Each of vectors, vertices and faces are all available as attributes on all meshes but, depending on how a mesh is constructed, vectors may be internally derived from vertices and faces or vice-versa.

import numpy as np
from motmot import Mesh

# Define the 8 vertices in a cuboid.
vertices = np.array([
    [0., 0., 0.],
    [3., 0., 0.],
    [0., 5., 0.],
    [3., 5., 0.],
    [0., 0., 9.],
    [3., 0., 9.],
    [0., 5., 9.],
    [3., 5., 9.],
])

# Define the 6 sides of a cube or cuboid.
faces = np.array([
    # Draw a square using vertices[6], vertices[2], vertices[0] and vertices[4]
    [6, 2, 0, 4],
    # Draw a square using vertices[0], vertices[1], vertices[5] and vertices[4]
    [0, 1, 5, 4],
    # And so on...
    [0, 2, 3, 1],
    [5, 1, 3, 7],
    [3, 2, 6, 7],
    [4, 5, 7, 6],
])

# Construct a vertices+faces mesh.
mesh = Mesh(vertices, faces)
# This attribute is set to True to signify that this was originally a faces mesh.
mesh.is_faces_mesh
# Although `vectors` can still be derived automatically.
mesh.vectors

# Construct a vectors mesh.
mesh = Mesh(vertices[faces])
# This attribute is set to False to signify that this was originally a vectors
# mesh.
mesh.is_faces_mesh
# But `vertices` and `faces` can still be derived automatically.
mesh.vertices, mesh.faces

Mesh properties

This is just a brief summary of what is available. See the corresponding entry in the the API reference for more information on each property.

# Outward normal to each polygon:
>>> mesh.normals
array([[-45.,   0.,   0.],
       [ -0., -27.,  -0.],
       [ -0.,  -0., -15.],
       [ 45.,   0.,  -0.],
       [ -0.,  27.,   0.],
       [  0.,   0.,  15.]])

# Normalised (magnitude of 1.0) outward normal to each polygon:
>>> mesh.units
array([[-1.,  0.,  0.],
       [ 0., -1.,  0.],
       [ 0.,  0., -1.],
       [ 1.,  0.,  0.],
       [ 0.,  1.,  0.],
       [ 0.,  0.,  1.]])

# Area of each polygon.
>>> mesh.areas
array([45., 27., 15., 45., 27., 15.])

# Total surface area (just a sum of mesh.areas).
>>> mesh.area
174.0

# The number of times each vertex is used (which admittedly
# isn't particularly interesting for a cuboid):
>>> mesh.vertex_counts
array([3, 3, 3, 3, 3, 3, 3, 3], dtype=int32)

# A mapping of which other vertices each vertex is directly connect to.
>>> mesh.vertex_map
RaggedArray.from_nested([
    [1, 7, 3],  # vertices[0] connects to vertices[[1, 7, 3]].
    [2, 6, 0],  # vertices[1] connects to vertices[[2, 6, 0]].
    [4, 1, 3],  # and so on...
    [5, 0, 2],
    [5, 6, 2],
    [4, 7, 3],
    [1, 4, 7],
    [0, 5, 6],
])

# Because this mesh's vertices appear the same number of times,
# this example slightly trivialises the problem. Consider instead
# a mesh with only the first three faces. Not all vertices have
# the same number of neighbours.
>>> mesh[:3].vertex_map
RaggedArray.from_nested([
    [1, 3],
    [2, 6, 0],
    [4, 1, 3],
    [0, 2, 5],
    [5, 2, 6],
    [3, 4],
    [4, 1],
])

# If you prefer to use raw vertices rather than vertex IDs then
# use the connected_vertices() method.
>>> mesh.connected_vertices(mesh.vertices[0])
array([[0., 5., 0.],
       [3., 5., 9.],
       [0., 0., 9.]])

# Similarly, `polygon_map` maps every polygon to each of its neighbours.
# Read the first line of the following as *polygon 0 shares an edge each with
# polygons 4, 2, 1 and 5*.
>>> mesh.polygon_map
array([[4, 2, 1, 5],
       [2, 3, 5, 0],
       [0, 4, 3, 1],
       [1, 2, 4, 5],
       [2, 0, 5, 3],
       [1, 3, 4, 0]])

Vertex Lookup

motmot leverages two libraries for looking up vertices.

Exact lookup

It is easy to convert vertex IDs to real vertices. Simply pass them as indices to mesh.vertices.

>>> ids = [0, 4, 5, 2]
>>> points = mesh.vertices[ids]
>>> points
array([[0., 0., 0.],
       [0., 0., 9.],
       [3., 0., 9.],
       [0., 5., 0.]])

Go the other way by indexing the vertex_table attribute.

>>> mesh.vertex_table[points]
array([0, 4, 5, 2], dtype=int64)

Some things to be aware of:

  • The dtype of the points queried must match mesh.dtype.

  • As with regular floats in a regular Python dict, even the smallest deviation will cause lookup to fail.

    >>> mesh.vertex_table[[3., 0., 9.]]
    5
    >>> mesh.vertex_table[[3., 0, 9.00000000001]]
    KeyError: 'key = array([3., 0., 9.]) is not in this table.'
    

Approximate lookup

To find nearest points, motmot uses a KDTree. The API here is very shallow and it is quite likely that you may wish to create and use KDTrees directly rather than use motmot‘s methods.

A KDTree, fitted to mesh.centers (the middle of each polygon), is found at the mesh.kdtree attribute.

Given a set of points defined as:

points = np.array([[2., 3.5, 4.2], [2.3, 4.2, 1.1]], mesh.dtype)

Find the nearest point on the mesh surface to each point:

>>> mesh.closest_point(points)
array([[3. , 3.5, 4.2],
       [2.3, 4.2, 0. ]])

Or to restrict the output to only mesh.centers without interpolating between them:

>>> mesh.closest_point(points, interpolate=False)
array([[3. , 2.5, 4.5],
       [1.5, 2.5, 0. ]])

For anything else, use mesh.kdtree directly.

Laziness

A motmot.Mesh lazy loads its properties using a backport of @functools.cached_property. This allows them to be calculated when only you need them so that no time is ever wasted calculating something which you do not use. Take for example, mesh.normals. Nothing is calculated on mesh = Mesh(vertices, faces) so that if the normals are never used then they are never calculated. Accessing the attribute mesh.normals initialises and returns them making mesh.normals look like a regular attribute on the outside. The value is cached so that the calculation never runs more than once. i.e. mesh.normals is mesh.normals.

Caches should be reset after a mesh is modified. Most of this is done automatically. Mesh modifier methods such as rotate(), translate() or crop(in_place=True) will all invalidate affected caches themselves. Similarly, setting any of the vertices, faces or vectors attributes will reset all caches. Writing in place to those arrays (e.g. mesh.vectors[:] = x) however is undetectable to motmot. Call mesh.reset() after doing an in place modification.

Indices and tables