Meshes with Python & Blender: Rounded Cubes
Unlike the previous part, this tutorial will be lighter on math and focus more on the “Blender stuff”. We will look at adding and applying modifiers, reading a mesh from a file and managing complexity.
Tutorial Series
- Part 1: The 2D Grid
- Part 2: Cubes and Matrices
- Part 3: Icospheres
- Part 4: Rounded Cubes
- Part 5: Circles and Cylinders
Setup
This time we are doing things differently. We are going to create a JSON file for the cube mesh data, then build the mesh from it. This way we can easily replace the mesh data with any other, without having to change much in the actual code.
We won’t stop once we have the object done and linked to the scene either. We will transform the mesh, set it to smooth, add modifiers and (optionally) apply them. That’s a lot of code, but luckily it’s code that can be reused easily. As a bonus, we’ll also add randomness to the mesh transformations so we get a different cube every time the script is run. Finally, we’ll use a simple formula to make the bevel modifier somewhat consistent through different scales.
Here’s the imports. We need the OS package for the path module.
import bpy
import json
import os
from math import radians
from mathutils import Matrix
Managing complexity
Before we read the cube data from a file, we need to have a way to convert that information into a mesh and an object linked to the scene. Luckily we have been doing that since part 1, so let’s start by abstracting the object making into it’s own function. The first thing to consider is how we want data to be passed. We could accept a JSON filepath and open it here or even a JSON data string to decode, but these options will limit the function to only work with json data. What if we generated vertices by code?
Instead of this, the function will expect a single dictionary with three entries (vertices, edges, faces). Note that you don’t need to declare edges if you have a list of faces, in this case the edges list would be empty (but still needs to exist, otherwise we get a KeyError).
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_set(True)
mesh.validate(verbose=True)
return obj
The validate() function will check the mesh for invalid geometry. By default validate()
will only print to the terminal if the mesh is invalid. The verbose parameter makes it print more information, even if the mesh is valid. This is more a matter of taste, and if you’re sharing the script you might want to turn verbose off so it doesn’t print more stuff than necessary.
Before getting to JSON, let’s make another utility function. We can grab the smoothing code from the previous tutorial and separate it into it’s own function.
def set_smooth(obj):
""" Enable smooth shading on an mesh object """
for face in obj.data.polygons:
face.use_smooth = True
Reading data from a JSON file
At this point I’m sure you must be asking “JSON? Why not CSV?”
The CSV format is intended for tabular data and simple lists, but what we need to hold in these files are multiple nested lists. Each list (verts or faces) contains multiple lists of values (three or four, respectively).
JSON works quite well for this since it maps one to one with Python’s data structures. It’s also very compact (unlike XML) and Python includes a nice package for encoding/decoding these files. Other possible options could be YAML and OBJ, but you would need a third party package for those. And of course if you really want to, you can also make your own format.
With that said let’s start by creating a json file for the cube first.
{
"verts": [[1.0, 1.0, -1.0],
[1.0, -1.0, -1.0],
[-1.0, -1.0, -1.0],
[-1.0, 1.0, -1.0],
[1.0, 1.0, 1.0],
[1.0, -1.0, 1.0],
[-1.0, -1.0, 1.0],
[-1.0, 1.0, 1.0]
],
"edges": [],
"faces": [[0, 1, 2, 3],
[4, 7, 6, 5],
[0, 4, 5, 1],
[1, 5, 6, 2],
[2, 6, 7, 3],
[4, 0, 3, 7]
]
}
To create this I put the verts and faces lists in a dictionary and ran json.dumps(). Then I pasted the result in a new file. Lazy, but effective. For something this simple you could also write it manually. Save this as cube.json
in the same folder as the blend file where you are running the script
Before we can read the file, we must be able to get a correct and absolute path for the json file. Since it’s saved in the same place as the blend file, we can mix Blender’s path utilities with os.path to make a function that makes this path.
def get_filename(filepath):
""" Return an absolute path for a filename relative to the blend's path """
base = os.path.dirname(bpy.context.blend_data.filepath)
return os.path.join(base, filepath)
Using os.path.join
ensures it will work cross-platform. Now we can read the file like any old file in Python and pass it to object_from_data()
with open(get_filename('cube.json'), 'r') as jsonfile:
mesh_data = json.load(jsonfile)
scene = bpy.context.scene
obj = object_from_data(mesh_data, 'Cubert 2', scene)
Remember that verbose parameter in validate()
? You will see a message printed in the terminal like this:
BKE_mesh_validate_arrays: verts(8), edges(12), loops(24), polygons(6)
BKE_mesh_validate_arrays: finished
And obviously you will see a familiar cube sitting in the middle of the scene.
Matrix transformation
The process for reading mesh data into objects is complete, but it’s nothing fancy. Let’s bring back matrix transformations from part 2 to spice things up. But instead of directly applying matrices on objects, we will do it through a function. This new function will take care of generating the matrices from more simple parameters and apply them whether we pass a mesh datablock or an object.
The parameters will be very simple:
- The object or mesh to transform
- Position as a tuple of three values
- Scale as a tuple of scaling for each axis
- Rotation as a tuple, where the first value is the rotation in degrees and the second is a string representing the axis to rotate. This must be a string that
Matrix.Rotation()
accepts.
The last three are optional and if they are omitted the matrix will be multiplied by one (the same as multiplying by an identity matrix). How do we detect what the first parameter is? We use the EAFP principle (“Easier to ask for forgiveness, than permission”). We know that objects have a matrix_world
property and meshes a transform()
method. We can try to use the first, and if we fail then try to use the second. If we still have failed, then the parameter is not transformable and we can raise an error.
This exception can be caught higher in the stack when we call this function later.
def transform(obj, position=None, scale=None, rotation=None):
""" Apply transformation matrices to an object or mesh """
position_mat = 1 if not position else Matrix.Translation(position)
if scale:
scale_x = Matrix.Scale(scale[0], 4, (1, 0, 0))
scale_y = Matrix.Scale(scale[1], 4, (0, 1, 0))
scale_z = Matrix.Scale(scale[2], 4, (0, 0, 1))
scale_mat = scale_x @ scale_y @ scale_z
else:
scale_mat = 1
if rotation:
rotation_mat = Matrix.Rotation(radians(rotation[0]), 4, rotation[1])
else:
rotation_mat = 1
try:
obj.matrix_world @= position_mat @ rotation_mat @ scale_mat
return
except AttributeError:
# I used return/pass here to avoid nesting try/except blocks
pass
try:
obj.transform(position_mat @ rotation_mat @ scale_mat)
except AttributeError:
raise TypeError('First parameter must be an object or mesh')
Putting it all together, error control
Now that we got our tools, let’s make something with them.
You might have noticed there are no module-level variables this time. We will put the main code inside a function. There are two good reasons to do this: we can catch errors and stop the script without exiting Blender, and we can reuse and adapt this code (put it in a loop for instance).
def make_object(datafile, name):
""" Make a cube object """
subdivisions = 0
roundness = 2.5
position = (0, 0, 1)
scale = (1, 1, 1)
rotation = (20, 'X')
with open(datafile, 'r') as jsonfile:
mesh_data = json.load(jsonfile)
scene = bpy.context.scene
obj = object_from_data(mesh_data, name, scene)
transform(obj, position, scale, rotation)
set_smooth(obj)
The roundness
and subdivisions
variables control the bevel and subdivision modifiers that we’ll add in the next section. Notice that we are also taking the path to the json file in the new function, that way we can run the entire thing on different meshes.
We can now add some basic error handling by calling this function inside a try block.
# -----------------------------------------------------------------------------
# Main code and error control
try:
make_object(get_filename('cube.json'), 'Cubert 2')
except FileNotFoundError as e:
print('[!] JSON file not found. {0}'.format(e))
except PermissionError as e:
print('[!] Could not open JSON file {0}'.format(e))
except KeyError as e:
print('[!] Mesh data error. {0}'.format(e))
except RuntimeError as e:
print('[!] from_pydata() failed. {0}'.format(e))
except TypeError as e:
print('[!] Passed the wrong type of object to transform. {0}'.format(e))
You can print errors, log them or if you’re calling this from an operator’s execute()
you can also show a popup. The interesting bit is that since the entire main code is in make_object()
we can stop the script any time we find an error. In fact, we can stop the script at any arbitrary point by simply returning from this function. If we had put the code at module-level instead of a function (like we did in previous parts) we would have no elegant way of stopping the script. While Python offers sys.exit()
and raise SystemExit()
to stop execution, these also kill Blender.
There’s enough to talk about error handling to make an entire separate tutorial but hopefully this gives you some ideas.
Adding and applying modifiers
This is a tutorial about ROUNDED cubes isn’t it? Let’s round them then! You could do this by manually changing the vertices’ coordinates. Catlike coding has an excellent tutorial on this (for Unity). But this is Blender, and we have an artillery of modifiers at our disposal. Let’s be lazy-smart and use the bevel modifier to round the cubes.
bevel = obj.modifiers.new('Bevel', 'BEVEL')
bevel.segments = 10
bevel.width = roundness / 10
Adding a modifier is that easy. The first parameter is a name that can be anything you want, the second is the type of modifier. You can find a list of the modifier type strings in the API documentation We can also add some refinement to the cube using subdivision. This is makes higher levels of roundness look better (at least more spherical). Subdivisions aren’t always necessary though. We can make it optional by simply making sure the subdivisions
parameter is larger than zero.
if subdivisions > 0:
subdiv = obj.modifiers.new('Subdivision', 'SUBSURF')
subdiv.levels = subdivisions
subdiv.render_levels = subdivisions
For another nice touch, we can apply the modifiers. There is no automatic way of doing this (except calling the operator). What we have to do is take the derived mesh (the resulting mesh from all the modifiers) and replace the actual mesh data with it. Then we can remove all modifiers.
This is something that you might want to do in many places, so let’s also make a function for it.
def apply_modifiers(obj):
""" Apply all modifiers on an object """
bm = bmesh.new()
dg = bpy.context.evaluated_depsgraph_get()
bm.from_object(obj, dg)
bm.to_mesh(obj.data)
bm.free()
obj.modifiers.clear()
The settings type parameter expects one of two strings: 'PREVIEW'
and 'RENDER'
. These control which modifiers and which settings in the modifiers is applied, depending on them being enabled for the viewport or render. For instance, the subsurf modifier has two levels (viewport and render). If you wanted to apply the viewport level you would pass PREVIEW
.
Adding some randomness
To make our code a little more fancy, why not randomize some parts? We can use Python’s random module. There are many ways we can add randomness. For instance, we can create a random value in a range using uniform()
or do some math with a random value (usually multiplication). random()
is great for this because it returns values in the range 0.0−1.0. Try chaning these lines and you will get a different cube every time you run the script
from random import random, uniform
# [...]
position = (uniform(-5,5), uniform(-5,5), uniform(-5,5))
scale = (5 * random(), 5 * random(), 5 * random())
There’s no scientific reason to use 5 by the way, it just looks good to me. Now that we are changing the scale of the object, wouldn’t it be nice if we could also calculate the bevel size to keep the roundness consistent between all the cubes? This is as simple as dividing by the scale average.
mod.width = (roundness / 10) / (sum(scale) / 3)
There’s a very small chance that you get a division by zero error from this line. If you want to guard against that you can put it in a try block like this:
try:
mod.width = (roundness / 10) / (sum(scale) / 3)
except DivisionByZeroError:
mod.width = roundness / 10
Smash the run script button a few times and watch the cubes happen.
Final Code
import bpy
import json
import os
from math import radians
from random import random, uniform
from mathutils import Matrix
# -----------------------------------------------------------------------------
# Functions
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_set(True)
mesh.validate(verbose=True)
return obj
def transform(obj, position=None, scale=None, rotation=None):
""" Apply transformation matrices to an object or mesh """
position_mat = 1 if not position else Matrix.Translation(position)
if scale:
scale_x = Matrix.Scale(scale[0], 4, (1, 0, 0))
scale_y = Matrix.Scale(scale[1], 4, (0, 1, 0))
scale_z = Matrix.Scale(scale[2], 4, (0, 0, 1))
scale_mat = scale_x @ scale_y @ scale_z
else:
scale_mat = 1
if rotation:
rotation_mat = Matrix.Rotation(radians(rotation[0]), 4, rotation[1])
else:
rotation_mat = 1
try:
obj.matrix_world @= position_mat @ rotation_mat @ scale_mat
return
except AttributeError:
# I used return/pass here to avoid nesting try/except blocks
pass
try:
obj.transform(position_mat @ rotation_mat @ scale_mat)
except AttributeError:
raise TypeError('First parameter must be an object or mesh')
def apply_modifiers(obj):
""" Apply all modifiers on an object """
bm = bmesh.new()
dg = bpy.context.evaluated_depsgraph_get()
bm.from_object(obj, dg)
bm.to_mesh(obj.data)
bm.free()
obj.modifiers.clear()
def set_smooth(obj):
""" Enable smooth shading on an mesh object """
for face in obj.data.polygons:
face.use_smooth = True
def get_filename(filepath):
""" Return an absolute path for a filename relative to the blend's path """
base = os.path.dirname(bpy.context.blend_data.filepath)
return os.path.join(base, filepath)
# -----------------------------------------------------------------------------
# Using the functions together
def make_object(datafile, name):
""" Make a cube object """
subdivisions = 0
roundness = 2.5
position = (uniform(-5,5), uniform(-5,5), uniform(-5,5))
scale = (5 * random(), 5 * random(), 5 * random())
rotation = (20, 'X')
with open(datafile, 'r') as jsonfile:
mesh_data = json.load(jsonfile)
scene = bpy.context.scene
obj = object_from_data(mesh_data, name, scene)
transform(obj, position, scale, rotation)
set_smooth(obj)
mod = obj.modifiers.new('Bevel', 'BEVEL')
mod.segments = 10
mod.width = (roundness / 10) / (sum(scale) / 3)
if subdivisions > 0:
mod = obj.modifiers.new('Subdivision', 'SUBSURF')
mod.levels = subdivisions
mod.render_levels = subdivisions
#apply_modifiers(obj)
return obj
# -----------------------------------------------------------------------------
# Main code and error control
try:
make_object(get_filename('cube.json'), 'Rounded Cube')
except FileNotFoundError as e:
print('[!] JSON file not found. {0}'.format(e))
except PermissionError as e:
print('[!] Could not open JSON file {0}'.format(e))
except KeyError as e:
print('[!] Mesh data error. {0}'.format(e))
except RuntimeError as e:
print('[!] from_pydata() failed. {0}'.format(e))
except TypeError as e:
print('[!] Passed the wrong type of object to transform. {0}'.format(e))
Wrap up
That was a lot, but hopefully you can see how writing modular and simple code can help keep complexity under control. Unreadable code is unmaintainable code, don’t understimate the importante of keeping things clean and modular. Remember someone will have to read and make sense of that code in the future. And that someone will probably be you.
Things you can for yourself:
- Put this in a class or an operator
- Add some randomness to the vertices location before plugging them into from_pydata().
- Calculate the amount of segments in the bevel modifier in relation to the subdivisons value, so that the resulting geometry is more even.
- Try making JSON files for other meshes. All you have to do is put verts, edges and faces in a dictionary, call one of the json encoding functions and save it to a file.
In the last tutorial in the series we get back to making shapes. Next up: Circles and Cylinders