Building a Modular Set

A modular set is one design that comes in several sizes. Think drawer inserts, storage trays or shelf modules: the walls are the same thickness, the depth is the same, the height is the same, and only the width changes. Done by hand that is copying and rebuilding the same box three times. Done in a script it is one function, called three times with a different width.

Shared numbers, one function, a table of variants

The trick is to split the numbers into two piles. Some numbers belong to the whole set and must never change between modules, because that is what makes the parts fit together: wall thickness, depth, height, gap. The rest is per module and lives in a table: a name, a width and a colour. The function make_tray() reads both and builds one module.

  • PANEL_T, DEPTH, HEIGHT, GAP: set-wide constants, written once at the top. Change HEIGHT and the whole family gets taller together.
  • make_tray(name, width, x, rgb): the design. Every wall is positioned relative to width and the module’s left edge x, so a wider module stays correct.
  • x + PANEL_T / 2 and x + width - PANEL_T / 2: the side walls sit inside the module, measured from its own edges. This is the part worth reading twice, because it is the whole difference between a modular set and three boxes that happen to be near each other.
  • MODULES: the variant table. Adding a fourth module means adding one line, not writing code.
  • add_part() is the same idea one level down: every part of every module is created by the same four lines, so nothing can quietly differ.

modular_set.py

# A modular set: one function, three widths, one row
import bpy

for obj in list(bpy.data.objects):
    bpy.data.objects.remove(obj, do_unlink=True)

# numbers every module shares (in metres)
PANEL_T = 0.010          # wall thickness
DEPTH = 0.160            # front to back, same for the whole set
HEIGHT = 0.100           # wall height, same for the whole set
GAP = 0.020              # air between two modules


def add_part(name, size, loc, rgb):
    """One part of the set: a box with a name, an exact size and a colour."""
    bpy.ops.mesh.primitive_cube_add(size=1, location=loc)

    part = bpy.context.view_layer.objects.active
    part.name = name
    part.dimensions = size
    part.color = (rgb[0], rgb[1], rgb[2], 1.0)   # viewport colour

    material = bpy.data.materials.new("Mat_%s" % name)
    material.diffuse_color = (rgb[0], rgb[1], rgb[2], 1.0)
    part.data.materials.append(material)

    return part


def make_tray(name, width, x, rgb):
    """One module: a floor, two side walls and two end walls, all from the same numbers."""
    x0 = x + width / 2                      # centre of this module on X

    add_part("%s_Floor" % name, (width, DEPTH, PANEL_T),
             (x0, DEPTH / 2, PANEL_T / 2), rgb)
    add_part("%s_Wall_L" % name, (PANEL_T, DEPTH, HEIGHT),
             (x + PANEL_T / 2, DEPTH / 2, HEIGHT / 2), rgb)
    add_part("%s_Wall_R" % name, (PANEL_T, DEPTH, HEIGHT),
             (x + width - PANEL_T / 2, DEPTH / 2, HEIGHT / 2), rgb)
    add_part("%s_Wall_B" % name, (width, PANEL_T, HEIGHT),
             (x0, PANEL_T / 2, HEIGHT / 2), rgb)
    add_part("%s_Wall_F" % name, (width, PANEL_T, HEIGHT),
             (x0, DEPTH - PANEL_T / 2, HEIGHT / 2), rgb)


# the design table: same module, three widths, one colour each
MODULES = (
    ("Mod_A", 0.20, (0.95, 0.25, 0.25)),
    ("Mod_B", 0.30, (0.25, 0.80, 0.35)),
    ("Mod_C", 0.42, (0.25, 0.50, 0.95)),
)

x = 0.0
for name, width, rgb in MODULES:
    make_tray(name, width, x, rgb)
    x += width + GAP

# the set is data, so the script can measure it too: one rail under the whole row
total = x - GAP
bpy.ops.mesh.primitive_cube_add(size=1, location=(total / 2, DEPTH / 2, -PANEL_T / 2))
rail = bpy.context.view_layer.objects.active
rail.name = "Set_Base_Rail"
rail.dimensions = (total, DEPTH, PANEL_T)

print("parts built:", len(bpy.data.objects))
for name, width, _rgb in MODULES:
    print("  %-6s %4.0f mm wide" % (name, width * 1000))
print("  set is %4.0f mm total, %d parts" % (total * 1000, len(bpy.data.objects)))
The modular set script and the three trays it built
Three modules from one function: 20, 30 and 42 mm wide, all from the same numbers.
Text Editor close-up of shared numbers and the make_tray function
The script up close: shared numbers at the top, the module function below, the table at the bottom.

Why the loop walks a cursor

The modules are placed by carrying a running position, not by typing coordinates. Before each module is built the script calls it at x, and afterwards it moves the cursor forward by the module width plus the gap: x += width + GAP. That is why the row is evenly spaced no matter what widths you put in the table, and why adding or removing a variant cannot leave a hole or an overlap. When you catch yourself computing a position with a calculator, that is usually a sign the script should be carrying it instead.

The set can measure itself

Because the layout is data, the script can also do arithmetic on it. At the end the loop has already produced the total width of the row, so the same numbers place one rail under the whole set: Set_Base_Rail is total wide and sits at half of it. This matters in real work. The number you would otherwise measure by hand in the viewport is the number the script already knows, and the printout at the end turns it into a part list you can hand to a supplier.

Perspective view of three trays on a shared base rail
The set in 3D: one design in three widths, sitting on a rail measured from the same table.
Top view of the trays showing three different widths
From above: the widths differ, everything else is identical, which is what modular means.
Outliner close-up with the three modules and their parts
The Outliner up close: every module is a group of named parts, Mod_A_Floor to Mod_C_Wall_F.

What is still worth doing by hand

  • Naming is the difference between a set and a mess, and it comes free here: Mod_B_Wall_F tells you the module, the part and its side. In the Outliner the three modules read as three groups.
  • Grouping the parts of one module into a real collection is the next step up. It is coming in Module 6, where we also sort the pyramid parts the same way.
  • If a variant needs to be a different height, not a different width, the function is the single place to change: pass the height in as one more parameter instead of editing five positions.

Where this pays off in real work

  • Product families: a single script covers a small, medium and large version of the same article.
  • Interiors and shop fitting: module widths come from the wall dimensions, the design stays identical.
  • Custom orders: the customer picks widths, you change the table and ship the printout.
  • Prototyping: try five proportions in one run instead of modelling five times.

Next we take the whole set apart and write it out: one file per part, named after the object, in one run. That is what turns a nice scene into something a shop or a colleague can actually use.

← Back to the lessons list