Meshes with Python & Blender : Circles and Cylinders
In the last part of this series we’ll look at making circles and cylinders. They are a lot trickier than it seems! We’ll be building on everything from the previous parts, as well as doing some Bmesh to fix normals.
Tutorial Series
- Part 1: The 2D Grid
- Part 2: Cubes and Matrices
- Part 3: Icospheres
- Part 4: A Rounded Cube
- Part 5: Tubes and Cylinders
Setup
Let’s start importing the usual packages and Bmesh. We will be using it to fix normals at the end. Also bring back good old object_from_data()
and set_smooth()
from the previous part
import bpy
import bmesh
import math
# ------------------------------------------------------------------------------
# Utility Functions
def set_smooth(obj):
""" Enable smooth shading on an mesh object """
for face in obj.data.polygons:
face.use_smooth = True
def object_from_data(data, name, scene, select=True):
""" Create a mesh object and link it to a scene """
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(data['verts'], data['edges'], data['faces'])
obj = bpy.data.objects.new(name, mesh)
scene.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select = True
mesh.validate(verbose=True)
return obj
The first step to making a cylinder is making a 2D circle.
Making a circle
First, we need to put vertices in a circle around a point. We’ll calculate the the angle at which each vertex is. This angle is actually a polar coordinate, so to get X, Y coordinates we can use we’ll also to convert it to Cartesian. You can read more about the math on mathisfun.com and on Wikipedia Of course we are passing the value of Z, so we don’t need to calculate that. For the initial circle Z will be zero.
def vertex_circle(segments, z):
""" Return a ring of vertices """
verts = []
for i in range(segments):
angle = (math.pi*2) * i / segments
verts.append((math.cos(angle), math.sin(angle), z))
return verts
We can use this right away to make a circle but it would only be a circle of vertices. Let’s also add edges too. Adding edges to a circle is pretty simple. Just loop around each vertex and connect the current index plus the next one, then add the final edge which connects the last index with the first (zero). Following the lessons of part 4, let’s make a function to make circles.
def make_circle(name, segments=32):
""" Make a circle """
data = {
'verts': vertex_circle(segments, 0),
'edges': [],
'faces': [],
}
data['edges'] = [(i, i+1) for i in range(segments)]
data['edges'].append((segments - 1, 0))
scene = bpy.context.scene
return object_from_data(data, name, scene)
make_circle('Some Circle', 64)
It doesn’t seem like much, but it’s a solid first step.
You might see some messages from validate()
complaining about the normals being zero. That’s because there are no faces to calculate normals. We will come back and add a way to fill this circle later on, reusing the code that adds caps to the cylinder
Circles all the way up
What is a cylinder if not a circle extruded in 3D space? To extrude the circle in the Z axis we can loop through vertex_circle()
giving it increasing values of Z.
Time to make_cylinder()
def make_cylinder(name, segments=64, rows=4):
""" Make a cylinder """
data = { 'verts': [], 'edges': [], 'faces': [] }
for z in range(rows):
data['verts'].extend(vertex_circle(segments, z))
scene = bpy.context.scene
obj = object_from_data(data, name, scene)
return obj
Notice vertex_circle()
returns a list of vertices, so we need to use expand instead of append to keep the same list structure. Also, we’re not adding edges here. We will fleshing out this mesh with actual faces, so there’s no need for edges. Making faces follows the logic from the grids in part 1. Except this time, it goes around in a ring.
def face(segments, i, row):
""" Return a face on a cylinder """
ring_start = segments * row
base = segments * (row + 1)
return (base - 1, ring_start, base, (base + segments) - 1)
Just like in grids, the best way to figure out is to turn on debugging and inspecting vertex indices. After all, this function only needs to return the right numbers. To make all faces across all rings and rows, we need to loop through both.
for i in range(segments):
for row in range(0, rows - 1):
data['faces'].append(face(segments, i, row))
You might notice we are missing the last face in each ring. The last face is a special case because the indices “rewind”.
We need to add a special case for that last face with a different formula. On one side we need to grab vertices near the end of both rings, on the other we need to grab the beginning verts. It’s similar to the last edge in the circle.
def face(segments, i, row):
""" Return a face on a cylinder """
if i == segments - 1:
ring_start = segments * row
base = segments * (row + 1)
return (base - 1, ring_start, base, (base + segments) - 1)
else:
base = (segments * row) + i
return (base, base + 1, base + segments + 1, base + segments)
Now we have one good looking cylinder.
We’re just missing caps to complete the mesh.
Capping the cylinder
There are two ways we can cap this cylinder:
- Ngons. All vertices in the ring connected as a single face.
- A triangle fan. A series of triangles connecting to a central vertex.
To make a triangle fan we need to put a vertex in the middle of the ring, and then go loop the ring connecting vertices to it. On the other hand, to make an Ngon only we just loop through the vertices’ indices and put them all in a single tuple. This is clearer to see in the bottom cap code.
def bottom_cap(verts, faces, segments, cap='NGON'):
""" Build bottom caps as triangle fans """
if cap == 'TRI':
verts.append((0, 0, 0))
center_vert = len(verts) - 1
[faces.append((i, i+1, center_vert)) for i in range(segments - 1)]
faces.append((segments - 1, 0, center_vert))
elif cap == 'NGON':
faces.append([i for i in range(segments)])
else:
print('[!] Passed wrong type to bottom cap')
In the case of a triangles fan we also need to fill the last face separately. The code for top_cap()
is similar, but we have to offset the number of indices to get the indices in the top row.
def top_cap(verts, faces, segments, rows, cap='NGON'):
""" Build top caps as triangle fans """
if cap == 'TRI':
verts.append((0, 0, rows - 1))
center_vert = len(verts) - 1
base = segments * (rows - 1)
[faces.append((base+i, base+i+1, center_vert))
for i in range(segments - 1)]
faces.append((segments * rows - 1, base, center_vert))
elif cap == 'NGON':
base = (rows - 1) * segments
faces.append([i + base for i in range(segments)])
else:
print('[!] Passed wrong type to top cap')
Now we can call them in the cylinder function. Since we already passing the cap type, we can make it a parameter in make_cylinder()
and let the caller pass None
to disable caps.
if cap:
bottom_cap(data['verts'], data['faces'], segments, cap)
top_cap(data['verts'], data['faces'], segments, rows, cap)
Filling the circle
A cylinder is an extruded circle, remember? Therefore the bottom ring is the same as the first circle we made and we can now use the bottom_cap()
function to fill it. Note that if we make faces for the circle, we don’t need to setup edges.
def make_circle(name, segments=32, fill=None):
""" Make a circle """
data = {
'verts': vertex_circle(segments, 0),
'edges': [],
'faces': [],
}
if fill:
bottom_cap(data['verts'], data['faces'], segments, fill)
else:
data['edges'] = [(i, i+1) for i in range(segments)]
data['edges'].append((segments - 1, 0))
scene = bpy.context.scene
return object_from_data(data, name, scene)
Smooth it up
Now that we have completed the mesh, let’s polish it. We can add a call to set_smooth()
to enable it smooth shading for it. We can also add some modifiers like we did in the last part. We’ll add some bevel and an edge split to fix the shading. We only want to add a bevel modifier if we actually have caps though.
set_smooth(obj)
if cap:
bevel = obj.modifiers.new('Bevel', 'BEVEL')
bevel.limit_method = 'ANGLE'
obj.modifiers.new('Edge Split', 'EDGE_SPLIT')
If you try this you will find there’s something wrong with the bottom cap. The bevel modifier is making it look really weird and dented. This is usually a sign of messed up normals. Jump into edit mode and enable normals from the Display panel.
As you can see, the normals of the bottom cap are pointing up, when they should actually be pointing down. We need to fix that to get a good bevel. You can do that by going into edit mode, and pressing CTRL+N
. But there’s also a way to it in code.
Fixing normals
In order to fix the normals we’ll use one of bmesh operators. These are different from the regular Blender operators as they don’t depend on context. They will work as long as you give them a valid bmesh object.
Bmesh is a special Blender API that gives you very close access to the internal mesh editing API. It’s quite faster than other methods and more flexible. However, when it comes to creating meshes from scratch Bmesh doesn’t offer anything too different from the other way. That’s why this series hasn’t touched on Bmesh until this point. In order to use bmesh we first create an bmesh object, then fill it with data (in this case using from_mesh()
). Once we are finished with it, we write the new data to the mesh and free the bmesh object from memory.
I won’t get too deep in Bmesh now since that would take an entire separate tutorial. You can read more about Bmesh in the docs. For now, let’s make a function that takes a mesh and fixes it’s normals.
def recalculate_normals(mesh):
""" Make normals consistent for mesh """
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(mesh)
bm.free()
Adding a call to this function in make_cylinder()
yields the final, awesome looking cylinder.
Final Code
import bpy
import bmesh
import math
# ------------------------------------------------------------------------------
# Utility Functions
def set_smooth(obj):
""" Enable smooth shading on an mesh object """
for face in obj.data.polygons:
face.use_smooth = True
def object_from_data(data, name, scene, select=True):
""" Create a mesh object and link it to a scene """
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(data['verts'], data['edges'], data['faces'])
obj = bpy.data.objects.new(name, mesh)
scene.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select = True
mesh.update(calc_edges=True)
mesh.validate(verbose=True)
return obj
def recalculate_normals(mesh):
""" Make normals consistent for mesh """
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(mesh)
bm.free()
# ------------------------------------------------------------------------------
# Geometry functions
def vertex_circle(segments, z):
""" Return a ring of vertices """
verts = []
for i in range(segments):
angle = (math.pi*2) * i / segments
verts.append((math.cos(angle), math.sin(angle), z))
return verts
def face(segments, i, row):
""" Return a face on a cylinder """
if i == segments - 1:
ring_start = segments * row
base = segments * (row + 1)
return (base - 1, ring_start, base, (base + segments) - 1)
else:
base = (segments * row) + i
return (base, base + 1, base + segments + 1, base + segments)
def bottom_cap(verts, faces, segments, cap='NGON'):
""" Build bottom caps as triangle fans """
if cap == 'TRI':
verts.append((0, 0, 0))
center_vert = len(verts) - 1
[faces.append((i, i+1, center_vert)) for i in range(segments - 1)]
faces.append((segments - 1, 0, center_vert))
elif cap == 'NGON':
faces.append([i for i in range(segments)])
else:
print('[!] Passed wrong type to bottom cap')
def top_cap(verts, faces, segments, rows, cap='NGON'):
""" Build top caps as triangle fans """
if cap == 'TRI':
verts.append((0, 0, rows - 1))
center_vert = len(verts) - 1
base = segments * (rows - 1)
[faces.append((base+i, base+i+1, center_vert))
for i in range(segments - 1)]
faces.append((segments * rows - 1, base, center_vert))
elif cap == 'NGON':
base = (rows - 1) * segments
faces.append([i + base for i in range(segments)])
else:
print('[!] Passed wrong type to top cap')
# ------------------------------------------------------------------------------
# Main Functions
def make_circle(name, segments=32, fill=None):
""" Make a circle """
data = {
'verts': vertex_circle(segments, 0),
'edges': [],
'faces': [],
}
if fill:
bottom_cap(data['verts'], data['faces'], segments, fill)
else:
data['edges'] = [(i, i+1) for i in range(segments)]
data['edges'].append((segments - 1, 0))
scene = bpy.context.scene
return object_from_data(data, name, scene)
def make_cylinder(name, segments=64, rows=4, cap=None):
""" Make a cylinder """
data = { 'verts': [], 'edges': [], 'faces': [] }
for z in range(rows):
data['verts'].extend(vertex_circle(segments, z))
for i in range(segments):
for row in range(0, rows - 1):
data['faces'].append(face(segments, i, row))
if cap:
bottom_cap(data['verts'], data['faces'], segments, cap)
top_cap(data['verts'], data['faces'], segments, rows, cap)
scene = bpy.context.scene
obj = object_from_data(data, name, scene)
recalculate_normals(obj.data)
set_smooth(obj)
bevel = obj.modifiers.new('Bevel', 'BEVEL')
bevel.limit_method = 'ANGLE'
obj.modifiers.new('Edge Split', 'EDGE_SPLIT')
return obj
# ------------------------------------------------------------------------------
# Main Code
#make_circle('Circle', 64)
make_cylinder('Cylinder', 128, 4, 'TRI')
Wrap up
That’s it for this tutorial, and the whole series! I hope these tutorials have been useful for you, or at least learned a new trick or two. We have talked about making 2D grids, cubes, icospheres, circles and cylinders. We’ve also gone into debugging, managing complexity, setting smooth shading, adding and applying modifiers, recalculating normals and reading data from files. It’s quite a lot, but it’s only the beginning. There are plenty more things to explore and learn in Blender!
Things you can do for yourself:
- Use Bmesh instead of
from_pydata()
to build the mesh - Make a new
vertex_circle()
that makes spirals instead of circles - Refactor the cap functions into a single one, that can make both caps, one or none.
Got any questions, or suggestions for new tutorials? Leave a comment below!