Meshes with Python & Blender: Rounded Cubes

Unlike the pre­vi­ous part, this tuto­r­i­al will be lighter on math and focus more on the “Blender stuff”. We will look at adding and apply­ing mod­i­fiers, read­ing a mesh from a file and man­ag­ing complexity.

Tutorial Series

Setup

This time we are doing things dif­fer­ent­ly. We are going to cre­ate a JSON file for the cube mesh data, then build the mesh from it. This way we can eas­i­ly replace the mesh data with any oth­er, with­out hav­ing to change much in the actu­al code.

We won’t stop once we have the object done and linked to the scene either. We will trans­form the mesh, set it to smooth, add mod­i­fiers and (option­al­ly) apply them. That’s a lot of code, but luck­i­ly it’s code that can be reused eas­i­ly. As a bonus, we’ll also add ran­dom­ness to the mesh trans­for­ma­tions so we get a dif­fer­ent cube every time the script is run. Finally, we’ll use a sim­ple for­mu­la to make the bev­el mod­i­fi­er some­what con­sis­tent through dif­fer­ent scales.

Here’s the imports. We need the OS pack­age 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 con­vert that infor­ma­tion into a mesh and an object linked to the scene. Luckily we have been doing that since part 1, so let’s start by abstract­ing the object mak­ing into it’s own func­tion. The first thing to con­sid­er 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 lim­it the func­tion to only work with json data. What if we gen­er­at­ed ver­tices by code?

Instead of this, the func­tion will expect a sin­gle dic­tio­nary with three entries (ver­tices, 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 emp­ty (but still needs to exist, oth­er­wise 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 val­i­date() func­tion will check the mesh for invalid geom­e­try. By default validate() will only print to the ter­mi­nal if the mesh is invalid. The ver­bose para­me­ter makes it print more infor­ma­tion, even if the mesh is valid. This is more a mat­ter of taste, and if you’re shar­ing the script you might want to turn ver­bose off so it doesn’t print more stuff than necessary.

Before get­ting to JSON, let’s make anoth­er util­i­ty func­tion. We can grab the smooth­ing code from the pre­vi­ous tuto­r­i­al and sep­a­rate 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 ask­ing JSON? Why not CSV?”

The CSV for­mat is intend­ed for tab­u­lar data and sim­ple lists, but what we need to hold in these files are mul­ti­ple nest­ed lists. Each list (verts or faces) con­tains mul­ti­ple lists of val­ues (three or four, respectively).

JSON works quite well for this since it maps one to one with Python’s data struc­tures. It’s also very com­pact (unlike XML) and Python includes a nice pack­age for encoding/decoding these files. Other pos­si­ble options could be YAML and OBJ, but you would need a third par­ty pack­age for those. And of course if you real­ly want to, you can also make your own format.

With that said let’s start by cre­at­ing 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 cre­ate this I put the verts and faces lists in a dic­tio­nary and ran json.dumps(). Then I past­ed the result in a new file. Lazy, but effec­tive. For some­thing this sim­ple you could also write it man­u­al­ly. Save this as cube.json in the same fold­er as the blend file where you are run­ning the script

Before we can read the file, we must be able to get a cor­rect 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 util­i­ties with os.path to make a func­tion 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-plat­form. 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 ver­bose para­me­ter in validate()? You will see a mes­sage print­ed in the ter­mi­nal like this:

BKE_mesh_validate_arrays: verts(8), edges(12), loops(24), polygons(6)
BKE_mesh_validate_arrays: finished

And obvi­ous­ly you will see a famil­iar cube sit­ting in the mid­dle of the scene.

Matrix transformation

The process for read­ing mesh data into objects is com­plete, but it’s noth­ing fan­cy. Let’s bring back matrix trans­for­ma­tions from part 2 to spice things up. But instead of direct­ly apply­ing matri­ces on objects, we will do it through a func­tion. This new func­tion will take care of gen­er­at­ing the matri­ces from more sim­ple para­me­ters and apply them whether we pass a mesh dat­a­block or an object.

The para­me­ters will be very simple:

  • The object or mesh to transform
  • Position as a tuple of three values
  • Scale as a tuple of scal­ing for each axis
  • Rotation as a tuple, where the first val­ue is the rota­tion in degrees and the sec­ond is a string rep­re­sent­ing the axis to rotate. This must be a string that Matrix.Rotation() accepts.

The last three are option­al and if they are omit­ted the matrix will be mul­ti­plied by one (the same as mul­ti­ply­ing by an iden­ti­ty matrix). How do we detect what the first para­me­ter is? We use the EAFP prin­ci­ple (“Easier to ask for for­give­ness, than per­mis­sion”). We know that objects have a matrix_world prop­er­ty and mesh­es a transform() method. We can try to use the first, and if we fail then try to use the sec­ond. If we still have failed, then the para­me­ter is not trans­formable and we can raise an error.

This excep­tion can be caught high­er in the stack when we call this func­tion 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 some­thing with them.

You might have noticed there are no mod­ule-lev­el vari­ables this time. We will put the main code inside a func­tion. There are two good rea­sons to do this: we can catch errors and stop the script with­out exit­ing 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 vari­ables con­trol the bev­el and sub­di­vi­sion mod­i­fiers that we’ll add in the next sec­tion. Notice that we are also tak­ing the path to the json file in the new func­tion, that way we can run the entire thing on dif­fer­ent meshes.

We can now add some basic error han­dling by call­ing this func­tion 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 call­ing this from an operator’s execute() you can also show a pop­up. The inter­est­ing 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 arbi­trary point by sim­ply return­ing from this func­tion. If we had put the code at mod­ule-lev­el instead of a func­tion (like we did in pre­vi­ous parts) we would have no ele­gant way of stop­ping the script. While Python offers sys.exit() and raise SystemExit() to stop exe­cu­tion, these also kill Blender.

There’s enough to talk about error han­dling to make an entire sep­a­rate tuto­r­i­al but hope­ful­ly this gives you some ideas.

Adding and applying modifiers

This is a tuto­r­i­al about ROUNDED cubes isn’t it? Let’s round them then! You could do this by man­u­al­ly chang­ing the ver­tices’ coor­di­nates. Catlike cod­ing has an excel­lent tuto­r­i­al on this (for Unity). But this is Blender, and we have an artillery of mod­i­fiers at our dis­pos­al. Let’s be lazy-smart and use the bev­el mod­i­fi­er to round the cubes.

    bevel = obj.modifiers.new('Bevel', 'BEVEL')
    bevel.segments = 10
    bevel.width = roundness / 10

Adding a mod­i­fi­er is that easy. The first para­me­ter is a name that can be any­thing you want, the sec­ond is the type of mod­i­fi­er. You can find a list of the mod­i­fi­er type strings in the API doc­u­men­ta­tion We can also add some refine­ment to the cube using sub­di­vi­sion. This is makes high­er lev­els of round­ness look bet­ter (at least more spher­i­cal). Subdivisions aren’t always nec­es­sary though. We can make it option­al by sim­ply mak­ing sure the subdivisions para­me­ter is larg­er than zero.

    if subdivisions > 0:
        subdiv = obj.modifiers.new('Subdivision', 'SUBSURF')
        subdiv.levels = subdivisions
        subdiv.render_levels = subdivisions

For anoth­er nice touch, we can apply the mod­i­fiers. There is no auto­mat­ic way of doing this (except call­ing the oper­a­tor). What we have to do is take the derived mesh (the result­ing mesh from all the mod­i­fiers) and replace the actu­al mesh data with it. Then we can remove all modifiers.

This is some­thing that you might want to do in many places, so let’s also make a func­tion 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 set­tings type para­me­ter expects one of two strings: 'PREVIEW' and 'RENDER'. These con­trol which mod­i­fiers and which set­tings in the mod­i­fiers is applied, depend­ing on them being enabled for the view­port or ren­der. For instance, the sub­surf mod­i­fi­er has two lev­els (view­port and ren­der). If you want­ed to apply the view­port lev­el you would pass PREVIEW.

Adding some randomness

To make our code a lit­tle more fan­cy, why not ran­dom­ize some parts? We can use Python’s ran­dom mod­ule. There are many ways we can add ran­dom­ness. For instance, we can cre­ate a ran­dom val­ue in a range using uniform() or do some math with a ran­dom val­ue (usu­al­ly mul­ti­pli­ca­tion). random() is great for this because it returns val­ues in the range 0.0−1.0. Try chan­ing these lines and you will get a dif­fer­ent 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 sci­en­tif­ic rea­son to use 5 by the way, it just looks good to me. Now that we are chang­ing the scale of the object, wouldn’t it be nice if we could also cal­cu­late the bev­el size to keep the round­ness con­sis­tent between all the cubes? This is as sim­ple as divid­ing by the scale average.

    mod.width = (roundness / 10) / (sum(scale) / 3)

There’s a very small chance that you get a divi­sion 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 but­ton 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 hope­ful­ly you can see how writ­ing mod­u­lar and sim­ple code can help keep com­plex­i­ty under con­trol. Unreadable code is unmain­tain­able code, don’t under­sti­mate the impor­tante of keep­ing things clean and mod­u­lar. Remember some­one will have to read and make sense of that code in the future. And that some­one will prob­a­bly be you.

Things you can for yourself:

  • Put this in a class or an operator
  • Add some ran­dom­ness to the ver­tices loca­tion before plug­ging them into from_pydata().
  • Calculate the amount of seg­ments in the bev­el mod­i­fi­er in rela­tion to the sub­di­vi­sons val­ue, so that the result­ing geom­e­try is more even.
  • Try mak­ing JSON files for oth­er mesh­es. All you have to do is put verts, edges and faces in a dic­tio­nary, call one of the json encod­ing func­tions and save it to a file.

In the last tuto­r­i­al in the series we get back to mak­ing shapes. Next up: Circles and Cylinders

All the posts you can read
TutorialsBlender, Meshes, Python19.10.2020