Mesh scripting

Mesh Scripting #

This curve has three parts. In the first part, we will create some contact planes, by just adding, rotating, and translating mesh plane objects. In the second, we will build a small mesh by hand, to understand the format. In the third, we will warp Blender’s default monkey. There is also sample code at the end for creating tube plots. Like the Curve Scripting tutorial, this tutorial assumes some familiarity with Python. This tutorial does not assume you have gone through the Curve Scripting tutorial, but is a little terser than that one.

Running through the tutorial takes about half an hour, though understanding the code might take a little longer.

Contact planes #

In this part, we will create a sketch of the standard contact structure $ker(dz-ydx)$ on $\mathbb{R}^3$:

The standard contact structure

Creating the image #

Create a new file and delete the default cube. Create a new collection called something like “Planes”, and select it. Then switch to the Scripting tab and from the menu select Text:New.

Creating a new text

Type or paste the following code into the text file:

import bpy
from math import atan
from mathutils import Vector

for i in range(7):
    for j in range(7):
        for k in range(3):
            bpy.ops.mesh.primitive_plane_add()
            plane = bpy.context.active_object
            newloc = Vector((i,j,1.5*k))
            plane.location = newloc
            y = (1.0*j-4)/2
            angle = atan(y)
            plane.rotation_euler[1] = angle
            plane.scale = Vector((.2,.35,.35))

Press the play button to run the script.

Running the script

Switch back to the Layout tab. Pan and rotate the viewport until you feel like you have a nice view of the contact planes and then select View:Align View:Align Active Camera to View.

Aligning the view

If necessary, adjust the camera Focal Length so you get all of the planes in the picture.

Adjusting the view focal length

(Repeat the previous two steps until you’re happy with the view. You might also be tempted to try an Orthographic camera, but I didn’t manage to get that to look good.)

Select the collection with all the planes and select Add:Grease Pencil:Collection Line Art.

Adding the line art

Select the new line art object, select File:Export:Grease Pencil as SVG, and clean up the result in your favorite vector graphics program.

Understanding the code #

To save us some scrolling, here is the block of code again:

import bpy
from math import atan
from mathutils import Vector

for i in range(7):
    for j in range(7):
        for k in range(3):
            bpy.ops.mesh.primitive_plane_add()
            plane = bpy.context.active_object
            newloc = Vector((i,j,1.5*k))
            plane.location = newloc
            y = (1.0*j-4)/2
            angle = atan(y)
            plane.rotation_euler[1] = angle
            plane.scale = Vector((.2,.35,.35))

Taking this in order:

  • bpy, which must stand for Blender Python, is the main library for interacting with Blender. mathutils.Vector seems to be the right class for working with vectors in Blender (or, at least, a class that works).
  • ops.mesh.primitive_plane_add adds a plane, to the currently selected collection. (We will see another way to create a mesh in the next part of the tutorial.) The new plane becomes the active object, so the next line assigns the plane to a variable.
  • plane.location, plane.scale, and plane.rotation_euler are its location vector, scale vector, and Euler angle rotation vector. (There are lots of other options for rotation methods, of course.)

As in the Curve Scripting tutorial, tab completion is an extremely useful way to figure out Blender Python. For example, to see what other mesh primitives one can add, move your cursor to the console and type

bpy.ops.mesh.primitive_

and hit the tab key. To find out what properties a plane has, add a plane in the console by typing:

bpy.ops.mesh.primitive_plane_add()
plane = bpy.context.active_object    

and then type

plane.

and hit the tab key. For example, you might notice plane.rotation_mode (currently XYZ for (x,y,z) Euler), plane.rotation_axis_angle, if one wants to use a sensible way or rotating, and so on.

Tab completion Tab completion

There are a number of artistic choices (in a weak sense) embedded in the code – e.g., the number of planes in each direction, the size of the planes (because of foreshortening, it looked better to me if they were narrower in the x-direction), the maximum and minimum slopes. I played with these a little to get a scene that looks acceptable. (It would probably look better if I only included planes with $y\geq 0$, but that felt misleading.) With some more experimentation (or better taste), one could surely get the output to look better.

Building a mesh by hand #

In this part of the tutorial, we will create a cone on a tetrahedron, minus the faces of the tetrahedron itself, from scratch:

A cone on a tetrahedron

Creating the image #

As usual, results first. Create a new file, delete the cube, switch to the Scripting tab, create a new text, and paste:

import bpy
from math import sqrt

vertices = [(0,0,0),(1,0,-1/sqrt(2)),(-1,0,-1/sqrt(2)),(0,1,1/sqrt(2)),(0,-1,1/sqrt(2))]
edges = [(0,1),(0,2),(0,3),(0,4),(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)]
faces = [(0,1,2),(0,1,3),(0,1,4),(0,2,3),(0,2,4),(0,3,4)]

tetra_mesh = bpy.data.meshes.new("tetra_mesh")
tetra_mesh.from_pydata(vertices, edges, faces)
tetra_mesh.update()

tetra_object = bpy.data.objects.new("tetra", tetra_mesh)
mesh_collection = bpy.data.collections.new('My Meshes')
bpy.context.scene.collection.children.link(mesh_collection)
mesh_collection.objects.link(tetra_object)        

Click the play button to run the script.

Running the script

Go back to the Layout tab, find a nice camera alignment, select the new mesh, and Add:Grease Pencil:Object Line Art. Depending on your view position, you may need to turn on Overlapping Edges As Contours to get some of the edges to show up properly. (I don’t know why.)

Adding the line art

Add a second Object Line Art, change the Occlusion settings so that it shows hidden lines, and change the style so the hidden lines are visually different, so you can easily select them later. Again, you may need to turn on Overlapping Edges As Contours.

Adding hidden lines

Export the result, then adjust and color if desired in a vector graphics program.

Understanding the code #

After the input statements, the code had three parts. First:

vertices = [(0,0,0),(1,0,-1/sqrt(2)),(-1,0,-1/sqrt(2)),(0,1,1/sqrt(2)),(0,-1,1/sqrt(2))]
edges = [(0,1),(0,2),(0,3),(0,4),(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)]
faces = [(0,1,2),(0,1,3),(0,1,4),(0,2,3),(0,2,4),(0,3,4)]

Blender stores meshes as:

  • A list of points, which are the vertices of the mesh. (Each vertex has extra data attached to it, related to shading and so on, so internally they are not stored exactly as vertices. Once the mesh is created, a vertex v’s vector (coordinates) is v.co.)

  • A list of edges, which are pairs of (indices of) vertices. So, for example, in the edge list above, (0,2) means there is an edge between vertex 0 and vertex 2 (in this example, $(0,0,0)$ and $(-1,0,-1/\sqrt{2})$).

  • A list of tuples of vertices that bound faces. So, for example, the (0,1,2) means there is a face connecting vertex 0, vertex 1, and vertex 2 (in the example, $(0,0,0)$, $(11,0,-1/\sqrt{2})$, and $(-1,0,-1/\sqrt{2})$). In this example, all of the faces were triangles, but that is not required. The vertices for a face do not even have to lie in a plane, though if the face is far from a plane the curved face Blender interprets it as can look a bit odd.

    tetra_mesh = bpy.data.meshes.new(“tetra_mesh”) tetra_mesh.from_pydata(vertices, edges, faces) tetra_mesh.update()

This creates the mesh, and names it “tetra_mesh”. Note that this is different from how we created a mesh object before: instead of using bpy.ops (which tells the Blender interface to do something), it uses bpy.data, which creates a data structure. The analogue for curves is bpy.data.curves.new.

If you run just the code to this point, you won’t see anything: Blender does not display meshes, it displays mesh objects. Further, all objects must be linked to the scene collection. So, the next four lines:

tetra_object = bpy.data.objects.new("tetra", tetra_mesh)
mesh_collection = bpy.data.collections.new('My Meshes')
bpy.context.scene.collection.children.link(mesh_collection)
mesh_collection.objects.link(tetra_object)        

These create a mesh object from tetra_mesh, and call the new object “tetra”. Then it creates a new collection for that object to be a member of. Then the new collection has to be linked to the scene collection and the mesh object to the new collection.

Like most 3D formats, Blender stores the structure of the scene as a tree. The scene collection is the root, and everything that will be displayed is a child of that root. We’ve “My Meshes” as a child of the scene collection and “tetra” as a child of the “My Meshes” collection. (Actually, it’s not always a tree: the same object can be in multiple collections. So, a directed acyclic graph.)

An inverted monkey #

So far, we have created new meshes from primitives (quick, limited) and directly from scratch (more work, totally general). In the last piece of the tutorial, we’ll modify an existing mesh. Specifically, we’ll illustrate a sphere eversion by turning Blender’s monkey (Suzanne) inside out:

The standard contact structure

Creating the image #

Create a new file and delete the basic cube. Add a monkey (Add:Mesh:Monkey) and move it slightly out of the way. It will be our reference monkey. Then add a second monkey.

Two monkeys

Switch to the Scripting tab, create a new text, and paste:

import bpy
suzy = bpy.context.active_object
for v in suzy.data.vertices:
    l = v.co.length
    v.co = v.co / (l*l)

Run the script. The second monkey should now be inside out. (If you run the script again, it should go back to its original state, of course.)

Inverting the monkey

Go back to the Layout tab and position the camera as you like it (and adjust the focal length if needed). Add a Scene Line Art object. To get it to show all of the simplices, change its Crease Threshold close to $180^\circ$; I chose 179.

Adjusting crease threshold

Add a second Scene Line Art for the hidden lines, adjust its Crease Threshold, Occlusion, and line style, and export the two Line Art objects. Make final adjustments (e.g., dashed lines, line thickness) in your vector graphics editor.

Understanding the code #

I think this one is essentially self-explanatory. Note that the vertices are not vectors—they have extra data. The coordinates of a vertex are v.co.

More sample code #

Because either the code its building blocks might be useful, here is an extended tube plot function. An example of how to invoke it is at the end. It assumes the input is a smooth curve (in the form of a function that takes a float and gives a Vector). It also takes a slice in the normal direction to the curve and wraps that slice around the curve (as a bevel) using a numerically-computed TNB frame. (I partly wrote this to help explain TNB frames to a calculus class.) Here is what the code produces, plotting a pentagon around a torus knot:

A tube plot of a torus knot

import bpy
from math import cos, sin
from random import TWOPI

def tubePlot(f, tmin=0, tmax=1, num_pts=20, bevel_closed=True, bevel_scale=.05, bevel_curve = None, extra_twists=0, closed_curve=False):
    "Return a parametric plot of f. f should take a float as an input and give a Vector as an output."
    
    if not bevel_curve:
        bevel_curve = [(1,1),(1,-1),(-1,-1),(-1,1)]
    lbc = len(bevel_curve)
    
    vertices = list()
    edges = list()
    faces = list()

    for i in range(num_pts):
        t = 1.0*i/num_pts*(tmax-tmin)+tmin 
        p = f(t)
        tvec = unit_tangent(f,t)
        nvec = unit_normal(f,t)
        bvec = tvec.cross(nvec)
        num_existing_verts = len(vertices)
        argt = TWOPI*i*extra_twists/num_pts
        twis_bev = [(x*cos(argt)-y*sin(argt),y*cos(argt)+x*sin(argt)) for (x,y) in bevel_curve]
        new_verts = [p+bevel_scale*bev[0]*nvec+bevel_scale*bev[1]*bvec for bev in twis_bev]
        vertices.extend(new_verts)
        edges.extend([(i,i+1) for i in range(num_existing_verts, num_existing_verts+len(new_verts)-1)])
        if bevel_closed:
            edges.append((num_existing_verts+len(new_verts)-1,num_existing_verts))
        if num_existing_verts>=len(new_verts):
            assert lbc == len(new_verts)
            for j in range(len(new_verts)):
                edges.append((num_existing_verts-len(new_verts)+j,num_existing_verts+j))
            for j in range(len(new_verts)-1):
                faces.append((num_existing_verts-len(new_verts)+j,num_existing_verts+j,num_existing_verts+j+1,num_existing_verts-len(new_verts)+j+1))
            if bevel_closed:
                faces.append((num_existing_verts-len(new_verts),num_existing_verts-1, num_existing_verts+len(new_verts)-1,num_existing_verts))    
    nv = len(vertices)
    if closed_curve:
        for j in range(lbc-1):
            faces.append((nv-lbc+j,j,j+1,nv-lbc+j+1))
        if bevel_closed: #This version gets the lat quad right in doubly-closed case
            faces.append((nv-lbc,nv-1,lbc-1,0))    
    plot_mesh = bpy.data.meshes.new("MyMesh")
    plot_mesh.from_pydata(vertices, edges, faces)
    plot_mesh.update()
    plot_object = bpy.data.objects.new("MyMesh", plot_mesh)
    mesh_collection = bpy.data.collections.new('MeshPlots')
    bpy.context.scene.collection.children.link(mesh_collection)
    mesh_collection.objects.link(plot_object)        
    return plot_object

def velocity(f,t,epsilon=.01):
    "Estimate f'(t) as a difference quotient. Returns a vector."
    return (1.0/(2*epsilon))*(f(t+epsilon)-f(t-epsilon))

def acceleration(f,t,epsilon=.01):
    "Estimate f''(t) as a difference quotient. Returns a vector."
    def g(t):
        return velocity(f,t,epsilon)
    return (1.0/(2*epsilon))*(g(t+epsilon)-g(t-epsilon))

def unit_tangent(f,t,epsilon=.01):
    v = velocity(f,t,epsilon)
    return 1.0/v.length*v

def unit_normal(f,t,epsilon=.01):
    def tgt(t):
        return unit_tangent(f,t,epsilon)
    normal = velocity(tgt,t,epsilon)
    return 1.0/normal.length*normal



#End of general code. The rest is an example.

import cmath
from mathutils import Vector

#Define the function we will plot, using stereographic projection from the unit sphere in C^2
def stereo(z,w):
    "Return the stereographic projection of a point (z,w) in C^2"
    return (z.real / (1-w.imag), z.imag / (1-w.imag), w.real / (1-w.imag))

z0 = complex(0.707106781,0)
w0 = complex(0.707106781,0)
j = complex(0,1)

def f(t):
    newz = z0*cmath.exp(3.0*t *j) #RL working here
    neww = w0*cmath.exp(7.0*t *j)
    (x,y,z) = stereo(newz,neww)
    return Vector((x,y,z))

#Create the curve to use as a bevel
zeta_5 = cos(TWOPI/5)+j*sin(TWOPI/5)
cx_circle = list()
for i in range(5):
    cx_circle.append(zeta_5**(i))
re_circle = [(z.real,z.imag) for z in cx_circle]

#Draw the curve
mesh = tubePlot(f,tmax=TWOPI, num_pts=500, bevel_curve=re_circle, bevel_scale=.1, bevel_closed=True, extra_twists=0, closed_curve = True)