How I Built a Parametric 3D Model Generator Using Build123d

by sylvainFR in Design > 3D Design

70 Views, 0 Favorites, 0 Comments

How I Built a Parametric 3D Model Generator Using Build123d

png.png

If you’ve ever played with parametric modeling in the essential OpenSCAD, then you already know the idea behind code-driven 3D design.

In this project, I’ll walk you through build123d — a Python library that follows the same spirit as OpenSCAD, while offering a more modern and feature-rich approach to building parametric models.

It makes it easy to create clean, robust, and highly customizable 3D parts.

To show how it works in practice, you’ll see step-by-step how build123d generates one of the templates available on my website iteration3d: a fully customizable parametric pot/bowl.

Supplies

To get started with build123d, using Visual Studio Code along with the OCP CAD Viewer extension is by far the easiest setup.

It gives you a smooth install experience and a live 3D preview of whatever your script is generating. Super handy when you’re just getting into parametric modeling.

And of course, you’ll also need the most important supply of all: your brain.

Warning

The screenshots shown here come from Visual Studio Code and the OCP CAD Viewer for VS Code extension.

Very important:

The code shown here isn’t meant to be “the perfect way” to write build123d.

Some parts could be cleaner, faster, or more elegant — and there are definitely other ways to generate the same model.

The goal of this tutorial is not to showcase flawless code, but to help you understand the fundamentals of build123d and demonstrate many of its core construction tools.

Just keep in mind: there are simpler ways to build this exact model with build123d.

What Code-driven Parametric Modeling Means — and Build123d in Short


When you work with tools like Fusion 360 or SolidWorks, you’re used to drawing a sketch with your mouse or stylus, then extruding it to create a 3D shape. Script-based parametric modeling takes a different approach: instead of “drawing in 3D,” you’re programming the geometry.

And just to be clear: Fusion 360 and SolidWorks are parametric modelers under the hood — you can change a dimension or tweak a feature at any moment. They "simply" provide a graphical interface that makes everything more user-friendly.

Now, back to the idea of code-driven modeling.

OpenSCAD is a great example of this approach and often the easiest entry point. Every “shape” is defined by code. For instance, in OpenSCAD, the line cube([10, 10, 10]); generates — no surprise — a 10×10×10 cube. Simple. Add another block, move it with a transform, combine them, and your script gradually builds a full 3D object.

build123d follows the same philosophy, and goes much further.

Build123d is an open-source Python framework for parametric modeling released under the Apache 2.0 license. It’s built on top of the Open Cascade geometry kernel (OCCT), the same core used by FreeCAD.

You can also think of build123d as a more modern evolution of CadQuery.

Thanks to OpenCascade under the hood, build123d provides a much broader set of modeling tools than OpenSCAD — sometimes more complex, but not necessarily harder to use.

You can find the official build123d documentation here:

https://build123d.readthedocs.io/en/latest/

And several examples to help you get started here:

https://build123d.readthedocs.io/en/latest/introductory_examples.html

Define the Parameters and Import the Tools

So, our goal is to build a script that generates a parametric bowl — meaning a program where we can tweak a few values and get different bowls from the same “template” or “mold.”

The first step is to define our variables, the parameters that will control the shape of the bowl.

Let’s say we want to define our bowl using the following parameters:

  1. inner_bottom_diam — the inner diameter at the bottom
  2. inner_top_diam — the inner diameter at the top
  3. inner_height — the inner height of the bowl
  4. thickness — the wall thickness

We also want a rounded transition at the bottom, so we’ll add one more variable: bend_fillet, which we’ll set to 1 for now.

With that, our script starts with the following lines:

from build123d import *
from ocp_vscode import show

from math import atan2, tan, pi

# --- Inputs ---
inner_bottom_diam = 50
inner_top_diam = 80
inner_height = 70
thickness = 2

bend_fillet = 1.0


From these first lines, we’re aiming for a bowl with a wall thickness of 2, an inner height of 70, an inner bottom diameter of 50, and an inner top diameter of 80.

You’ll notice several lines starting with import. These import the Python libraries the script needs:

  1. from build123d import * – imports all functions from Build123d, the parametric 3D modeling library used to create geometry.
  2. from ocp_vscode import show – imports the show() function from ocp_vscode, which displays 3D models directly in VS Code.
  3. from math import atan2, tan – imports functions from the standard math library for trigonometric and geometric calculations.


Draw a Path

tutorial-path.png

We want to generate a bowl. A bowl is a symmetrical shape around a central axis: it can be created by revolving a 2D profile, whose rotation produces a perfectly regular geometry around that axis. So first, we’ll generate a path that will later serve as the basis for the revolution.

This path will be defined by three points. The first is at the origin (0.0, 0.0) and the second lies along the X-axis, at a distance of inner_bottom_diam from the origin (inner_bottom_diam / 2.0, 0.0). We divide by two to get the radius from the diameter.

For the third point (the top of the bowl), things are a bit more complex. We’ve decided that the bowl’s rim will be parallel to the base (parallel to the X-axis), not tilted. That’s tricky to picture and explain at this stage, so we’ll intentionally place the third point at twice the intended height—in other words, twice the inner height, inner_height. To be able to “stretch” this segment, we’ll compute the angle implied by the bowl’s dimensions. Then, depending on whether that angle is vertical or not, we’ll apply specific handling.

This yields the following code:


from build123d import *
from ocp_vscode import show

from math import atan2, tan

# --- Inputs ---
inner_bottom_diam = 50
inner_top_diam = 80
inner_height = 70
thickness = 2


bend_fillet = 1.0


x0, y0 = 0.0, 0.0
x1, y1 = inner_bottom_diam / 2.0, 0.0

dx0 = (inner_top_diam - inner_bottom_diam) / 2.0
dy0 = inner_height
angle_rad = atan2(dy0, dx0)

y2 = inner_height * 2.0
if abs(dx0) < 1e-9: # vertical wall case (top==bottom)
x2 = x1
else:
x2 = x1 + y2 / tan(angle_rad)


Now that we have the three points, we can build the path. To do this, we use FilletPolyline, which creates a path with a fillet (a rounded corner) so the base of our bowl is softened. FilletPolyline is the first construction function we use in our script. show lets us visualize the path, in purple on screen.


from build123d import *
from ocp_vscode import show

from math import atan2, tan

# --- Inputs ---
inner_bottom_diam = 50
inner_top_diam = 80
inner_height = 70
thickness = 2


bend_fillet = 1.0


x0, y0 = 0.0, 0.0
x1, y1 = inner_bottom_diam / 2.0, 0.0

dx0 = (inner_top_diam - inner_bottom_diam) / 2.0
dy0 = inner_height
angle_rad = atan2(dy0, dx0)

y2 = inner_height * 2.0
if abs(dx0) < 1e-9: # vertical wall case (top==bottom)
x2 = x1
else:
x2 = x1 + y2 / tan(angle_rad)

# 1) Filleted polyline (wire path)
with BuildLine() as ln:
FilletPolyline((x0, y0), (x1, y1), (x2, y2), radius=bend_fillet)
path = ln.wire()

show(path)


Extrude a 3D Shape

tutorial-sweep.png
tutorial-sweep-zoom.png

To revolve around the Y-axis, we need a 2D profile—a closed shape, a face. Our path is only a segment. So we’ll “extrude” a square along this path to obtain a kind of bent 3D bar.

To do this, we use the Rectangle and sweep construction functions. Rectangle defines a 2D rectangle (here a square, since length equals width) with side thickness, the desired wall thickness of our bowl. sweep “sweeps” the square profile along the path to create a 3D shape—a bent bar shown in transparency thanks to transparent=True.

from build123d import *
from ocp_vscode import show

from math import atan2, tan

# --- Inputs ---
inner_bottom_diam = 50
inner_top_diam = 80
inner_height = 70
thickness = 2


bend_fillet = 1.0


x0, y0 = 0.0, 0.0
x1, y1 = inner_bottom_diam / 2.0, 0.0

dx0 = (inner_top_diam - inner_bottom_diam) / 2.0
dy0 = inner_height
angle_rad = atan2(dy0, dx0)

y2 = inner_height * 2.0
if abs(dx0) < 1e-9: # vertical wall case (top==bottom)
x2 = x1
else:
x2 = x1 + y2 / tan(angle_rad)

# 1) Filleted polyline (wire path)
with BuildLine() as ln:
FilletPolyline((x0, y0), (x1, y1), (x2, y2), radius=bend_fillet)
path = ln.wire()



# 2) Sweep a square section along that path
with BuildPart() as swept:
with BuildSketch(path ^ 0):
Rectangle(thickness, thickness, align=(Align.MAX, Align.MIN))
sweep(path=path, transition=Transition.RIGHT)

show(path, swept, transparent=True)


Trim the 3D Shape

tutorial-face-selection-reference-plane.png
tutorial-split.png

Recall that we “stretched” the inclined arm. Now we need to trim it. We set the cut height to thickness + inner_height—that is, wall thickness plus inner height. This will be the bowl’s total height.

If you look closely at the 3D shape at this stage, you’ll see it isn’t aligned along X but shifted by thickness below the X-axis. The simplest approach would be to move the part toward +Y by thickness. But we’ll do it differently to introduce how to locate a specific face.

If we want to cut at thickness + inner_height, we need to position ourselves under the 3D shape. Here we use filter_by(Plane.XZ) to select only the faces parallel to the XZ plane of the swept part. Then we sort them by their position along the Y-axis with sort_by(Axis.Y) to get the lowest face, which will serve as our reference.

...

# 3) Cut the angled arm using a plane parallel to XZ
target_y = thickness + inner_height

# Pick the lowest XZ-parallel face as a reference
xz_faces = swept.part.faces().filter_by(Plane.XZ).sort_by(Axis.Y)

ref_face = xz_faces[0] # EN: lowest-Y face

# Build a reference plane from that face
ref_plane = Plane(ref_face)

show(path,swept.part,ref_plane)

...


Now we want to ensure that the reference plane’s normal points upward, that is, in the +Y direction, like the standard Plane.XZ. If the normal is oriented the other way (toward −Y), we invert it. Then we compute the offset distance between the plane’s current position and the target height Y = thickness + inner_height. This lets us create a new offset plane (cut_plane) at exactly the right height to perform the cut. We then trim our 3D shape with split.

from build123d import *
from ocp_vscode import show

from math import atan2, tan

# --- Inputs ---
inner_bottom_diam = 50
inner_top_diam = 80
inner_height = 70
thickness = 2


bend_fillet = 1.0


x0, y0 = 0.0, 0.0
x1, y1 = inner_bottom_diam / 2.0, 0.0

dx0 = (inner_top_diam - inner_bottom_diam) / 2.0
dy0 = inner_height
angle_rad = atan2(dy0, dx0)

y2 = inner_height * 2.0
if abs(dx0) < 1e-9: # vertical wall case (top==bottom)
x2 = x1
else:
x2 = x1 + y2 / tan(angle_rad)

# 1) Filleted polyline (wire path)
with BuildLine() as ln:
FilletPolyline((x0, y0), (x1, y1), (x2, y2), radius=bend_fillet)
path = ln.wire()



# 2) Sweep a square section along that path
with BuildPart() as swept:
with BuildSketch(path ^ 0):
Rectangle(thickness, thickness, align=(Align.MAX, Align.MIN))
sweep(path=path, transition=Transition.RIGHT)



# 3) Cut the angled arm using a plane parallel to XZ
target_y = thickness + inner_height

# Pick the lowest XZ-parallel face as a reference
xz_faces = swept.part.faces().filter_by(Plane.XZ).sort_by(Axis.Y)

ref_face = xz_faces[0] # EN: lowest-Y face

# Build a reference plane from that face
ref_plane = Plane(ref_face)

show(path,swept.part,ref_plane)

# Ensure the plane's normal points to +Y (same convention as Plane.XZ) so Keep.TOP keeps the upper side
if ref_plane.z_dir.dot(Vector(0, 1, 0)) < 0:
ref_plane = Plane(ref_plane.origin, ref_plane.x_dir, -ref_plane.z_dir)

# Compute the signed offset to reach target Y (thickness + inner_height)
offset_dist = target_y - ref_plane.origin.Y
cut_plane = ref_plane.offset(offset_dist)



with BuildPart() as trimmed:
add(swept.part)
split(bisect_by=cut_plane, keep=Keep.BOTTOM)

final_part = trimmed.part

show(path,final_part)


Prepare the Revolve Face

tutorial-face-selection.png

Remember, the goal of building this bent 3D bar is to get a profile we can rotate around the Y-axis. But you can’t revolve a solid directly. So we’ll extract the bar’s face that lies on the XY plane.


...
# 4) Get the seed_face (face lying on the XY plane, i.e., Z ≈ 0)
xy_faces = final_part.faces().filter_by(Plane.XY)

# Pick the XY face whose plane is closest to Z = 0
seed_face = min(xy_faces, key=lambda f: abs(Plane(f).origin.Z))

show(path,seed_face)
...


Revolution (revolve)

tutorial-revolve.png

Here we are! We can now apply the revolution with revolve.

from build123d import *
from ocp_vscode import show

from math import atan2, tan

# --- Inputs ---
inner_bottom_diam = 50
inner_top_diam = 80
inner_height = 70
thickness = 2


bend_fillet = 1.0


x0, y0 = 0.0, 0.0
x1, y1 = inner_bottom_diam / 2.0, 0.0

dx0 = (inner_top_diam - inner_bottom_diam) / 2.0
dy0 = inner_height
angle_rad = atan2(dy0, dx0)

y2 = inner_height * 2.0
if abs(dx0) < 1e-9: # vertical wall case (top==bottom)
x2 = x1
else:
x2 = x1 + y2 / tan(angle_rad)

# 1) Filleted polyline (wire path)
with BuildLine() as ln:
FilletPolyline((x0, y0), (x1, y1), (x2, y2), radius=bend_fillet)
path = ln.wire()



# 2) Sweep a square section along that path
with BuildPart() as swept:
with BuildSketch(path ^ 0):
Rectangle(thickness, thickness, align=(Align.MAX, Align.MIN))
sweep(path=path, transition=Transition.RIGHT)



# 3) Cut the angled arm using a plane parallel to XZ
target_y = thickness + inner_height

# Pick the lowest XZ-parallel face as a reference
xz_faces = swept.part.faces().filter_by(Plane.XZ).sort_by(Axis.Y)

ref_face = xz_faces[0] # EN: lowest-Y face

# Build a reference plane from that face
ref_plane = Plane(ref_face)

# Ensure the plane's normal points to +Y (same convention as Plane.XZ) so Keep.TOP keeps the upper side
if ref_plane.z_dir.dot(Vector(0, 1, 0)) < 0:
ref_plane = Plane(ref_plane.origin, ref_plane.x_dir, -ref_plane.z_dir)

# Compute the signed offset to reach target Y (thickness + inner_height)
offset_dist = target_y - ref_plane.origin.Y
cut_plane = ref_plane.offset(offset_dist)



with BuildPart() as trimmed:
add(swept.part)
split(bisect_by=cut_plane, keep=Keep.BOTTOM)

final_part = trimmed.part


# 4) Get the seed_face (face lying on the XY plane, i.e., Z ≈ 0)
xy_faces = final_part.faces().filter_by(Plane.XY)

# Pick the XY face whose plane is closest to Z = 0
seed_face = min(xy_faces, key=lambda f: abs(Plane(f).origin.Z))


# 5) Revolve the seed_face into a solid bowl
with BuildPart() as revolved:
revolve(seed_face, axis=Axis.Y)
final_part = revolved.part

show(path,final_part)


Apply Fillets

tutorial-with-fillet-on-edges.png
tutorial-without-fillet-on-edges.png

The rim edges are still sharp. We’ll apply a fillet—for aesthetics, but also to address the sometimes confusing fillet selection problem. Unlike Fusion 360, where a simple click on the edge suffices, here we must locate those edges programmatically. In our example this is fairly simple, but for complex models it can be much more tedious.

We’ll filter all the solid’s edges to keep only those of circular type (GeomType.CIRCLE), then sort them by their position along the Y-axis to isolate the two highest edges that form the bowl’s rim.

...
# 6) Select the two highest circular edges in Y
edges_sorted = (
final_part.edges()
.filter_by(GeomType.CIRCLE) # keep only circular edges
.sort_by(Axis.Y) # sort by Y position
)
target_edges = edges_sorted[-2:] # take the two highest

show(target_edges)
...


All that’s left is to add the fillets with fillet, which gives us the final code below:

from build123d import *
from ocp_vscode import show

from math import atan2, tan

# --- Inputs ---
inner_bottom_diam = 50
inner_top_diam = 80
inner_height = 70
thickness = 2


bend_fillet = 1.0


x0, y0 = 0.0, 0.0
x1, y1 = inner_bottom_diam / 2.0, 0.0

dx0 = (inner_top_diam - inner_bottom_diam) / 2.0
dy0 = inner_height
angle_rad = atan2(dy0, dx0)

y2 = inner_height * 2.0
if abs(dx0) < 1e-9: # vertical wall case (top==bottom)
x2 = x1
else:
x2 = x1 + y2 / tan(angle_rad)

# 1) Filleted polyline (wire path)
with BuildLine() as ln:
FilletPolyline((x0, y0), (x1, y1), (x2, y2), radius=bend_fillet)
path = ln.wire()



# 2) Sweep a square section along that path
with BuildPart() as swept:
with BuildSketch(path ^ 0):
Rectangle(thickness, thickness, align=(Align.MAX, Align.MIN))
sweep(path=path, transition=Transition.RIGHT)



# 3) Cut the angled arm using a plane parallel to XZ
target_y = thickness + inner_height

# Pick the lowest XZ-parallel face as a reference
xz_faces = swept.part.faces().filter_by(Plane.XZ).sort_by(Axis.Y)

ref_face = xz_faces[0] # EN: lowest-Y face

# Build a reference plane from that face
ref_plane = Plane(ref_face)

# Ensure the plane's normal points to +Y (same convention as Plane.XZ) so Keep.TOP keeps the upper side
if ref_plane.z_dir.dot(Vector(0, 1, 0)) < 0:
ref_plane = Plane(ref_plane.origin, ref_plane.x_dir, -ref_plane.z_dir)

# Compute the signed offset to reach target Y (thickness + inner_height)
offset_dist = target_y - ref_plane.origin.Y
cut_plane = ref_plane.offset(offset_dist)



with BuildPart() as trimmed:
add(swept.part)
split(bisect_by=cut_plane, keep=Keep.BOTTOM)

final_part = trimmed.part


# 4) Get the seed_face (face lying on the XY plane, i.e., Z ≈ 0)
xy_faces = final_part.faces().filter_by(Plane.XY)

# Pick the XY face whose plane is closest to Z = 0
seed_face = min(xy_faces, key=lambda f: abs(Plane(f).origin.Z))


# 5) Revolve the seed_face into a solid bowl
with BuildPart() as revolved:
revolve(seed_face, axis=Axis.Y)
final_part = revolved.part


# 6) Select the two highest circular edges in Y
with BuildPart() as fp:
add(final_part)
edges_sorted = (
fp.part.edges()
.filter_by(GeomType.CIRCLE)
.sort_by(Axis.Y)
)
target_edges = edges_sorted[-2:]
fillet(target_edges, 0.6)

show(fp)


Final Object

final-model.png

And that’s it—our bowl is modeled!

You can now adjust the values defined at the start of the script and see the rendering with these new values.


As mentioned at the outset, this isn’t the simplest or fastest way to obtain this 3D volume.


The idea was to explore as many construction functions as possible in this context and to give an overview of the build123d workflow. There are many other construction functions we didn’t use here. We also didn’t cover algebra mode.


Time to explore further and make the most of this very powerful library!