Part 3 ended with the pen cup working the way it should: change the wall thickness or the inner diameter at the top of the file, and every dependent measurement followed along. That's real progress over hardcoded numbers, but look at what it still isn't — one specific cup. Wanting three cups at three sizes means either copying that whole difference() block three times, or something better. That something is modules and lists.
A module is a shape with a name
A module doesn't return a value the way a function in most other languages does. It just draws whatever geometry is inside it, wherever it's called from. Taking the pen cup body from Part 3 and wrapping it in a module changes nothing about what gets rendered — it changes where that code lives:
module cup() {
wall_t = 3;
inner_d = 50;
height = 80;
base_d = 70;
base_h = 4;
union() {
cylinder(d = base_d, h = base_h);
translate([0, 0, base_h - 0.1])
difference() {
cylinder(d = inner_d + wall_t*2, h = height);
translate([0, 0, wall_t])
cylinder(d = inner_d, h = height);
}
}
}
cup();
cup(); at the bottom draws exactly the same solid Part 3 built directly. The only difference so far is that the geometry now has a name — which matters the moment you want it more than once.
Parameters are what make a module worth calling twice
A module with no arguments is a named copy-paste and nothing more. The actual point is letting each call decide its own numbers, with defaults so a bare cup() still works:
module cup(inner_d = 50, height = 80, wall_t = 3, base_d = 70, base_h = 4) {
union() {
cylinder(d = base_d, h = base_h);
translate([0, 0, base_h - 0.1])
difference() {
cylinder(d = inner_d + wall_t*2, h = height);
translate([0, 0, wall_t])
cylinder(d = inner_d, h = height);
}
}
}
cup();
translate([100, 0, 0])
cup(inner_d = 35, height = 60);
translate([200, 0, 0])
cup(inner_d = 70, height = 100, wall_t = 4);
Three cups, three sizes, one design. Notice base_d and base_h are left at their defaults in every call above — that's what defaults are for. You only override the numbers that particular cup actually needs to differ on, and everything else quietly inherits the sensible value you picked once.
Lists are how you stop hand-typing translate()
You've already been using a list without calling it one — [x, y, z] inside every translate() is a three-element list. A plain list works the same way with any numbers you like, and it's indexed from zero:
sizes = [35, 50, 70];
echo(sizes[0]); // 35
echo(len(sizes)); // 3
That's most of what you need to turn three manual translate()/cup() pairs into something that scales on its own:
sizes = [35, 50, 70];
for (i = [0 : len(sizes) - 1])
translate([i * 120, 0, 0])
cup(inner_d = sizes[i]);
[0 : len(sizes) - 1] is a range — every whole number from 0 up to and including one less than the list's length, which is exactly the set of valid index positions. Change sizes to four numbers instead of three, and four cups appear, correctly spaced, with no other line touched. The list is now the only place the actual sizes live — which is the same idea Part 3 introduced with named variables, one level up.
Where the defaults quietly stop being safe
There's a bug sitting in that sizes list, and it's worth stopping on rather than fixing, because it says something the module itself never said. Render all three cups side by side and it looks like this:

The 70mm cup, on the right, has no visible base. It hasn't vanished — base_d is still defaulting to 70mm, same as it always has. What changed is the cup body sitting on top of it: its outer diameter is inner_d + wall_t*2, and at inner_d = 70 with the default wall_t = 3, that's 76mm — six millimetres wider than the base underneath it. The base is still there, just recessed under a cup that now overhangs it on every side, which isn't a base a printed part can actually stand on.
The earlier two-call example already had the same problem waiting in it. cup(inner_d = 70, height = 100, wall_t = 4) works out to a 78mm body over a 70mm base — worse than this one — it just never got rendered next to anything else to notice.
Nothing about the loop or the list caused this. The module did exactly what it was told, for every value it was given. What broke is an assumption that only ever lived in the designer's head: base_d = 70 was picked to comfortably clear a 50mm cup, and nothing tied the two numbers together, so nothing stopped the module from being called with a size that quietly stopped that being true. The "sensible value you picked once," from a page ago, turns out to have an edge, and nothing in the code said so.
That's not a bug this post is going to fix. Catching it — with assert(), or a base_d computed from inner_d instead of fixed to it — is the next thing worth learning, and it earns its own post rather than a patch bolted onto the end of this one.
Where a variable's scope actually starts and stops
Part 3 covered one sharp rule: within a scope, the last assignment to a name wins, everywhere that name is used in that scope, including above the line where it was reassigned. What it didn't cover is that a module's body is its own scope. Its parameters — inner_d, height, wall_t and the rest, with their defaults — live inside that boundary, and they shadow anything with the same name outside it. A global inner_d = 50; sitting elsewhere in the file doesn't leak into cup()'s own inner_d, and cup()'s inner_d doesn't leak back out either. That's a genuine wall, not just a naming convention — which is what makes it safe to reuse an ordinary name like height inside a module without it fighting some other height sitting somewhere else in the same file.
None of this changes what actually gets printed — a module compiles down to precisely the same solid as the same code written out by hand, every single time it's called. What changes is how much of the file you have to touch when you change your mind: one number in a list, one named argument, instead of finding and re-editing every copy of a shape you happened to need three of. What it doesn't do, on its own, is stop you from calling it with a number that quietly breaks something else — that's next. hull() and minkowski() are still coming, once modules and lists are second nature, but guarding a module's own assumptions turns out to be the more urgent lesson, and it's next in line.