Creating a Jigsaw Puzzle in Blender Using Geometry Nodes and Python
Have you ever wanted to turn any image into a jigsaw puzzle inside Blender? With the Geometry Nodes and Blender’s Python, we can automate the entire process — generating puzzle pieces, assigning materials, and even exporting the result for 3D printing or game development.
In this short post, I’ll walk you through the core ideas and steps. We won’t go line-by-line through the code, but I’ll explain the overall workflow so you can build your own puzzle generator.
As always, the full code is available on my GitHub: https://github.com/IRCSS/Blender-Jigsaw-Puzzle-Generator
Overview
Here’s a quick rundown of the puzzle generation process:
- Load the texture — Import the image you want to turn into a puzzle.
- Set up the puzzle material — Create a material and link the image texture to it.
- Determine the grid size — Calculate the number of columns and rows based on the image’s aspect ratio.
- Define indentations — Use a wave function collapse–like approach to assign edge shapes (tabs and blanks) to each puzzle piece.
- Generate 3D geometry — Use Geometry Nodes to build the puzzle piece meshes.
- Assign UVs — Map the image correctly onto the puzzle surface.
Texture and Material Setup
Since the puzzle is based on a photo, the first step is to load the image using a Python script. In the example from the repo, the image file (e.g. example.jpeg
) is hardcoded and placed next to the .blend
file.
After importing the image, it’s a good idea to enable fake user. This ensures Blender keeps the image in memory and prevents it from being accidentally removed during data cleanup.
image = bpy.data.images.load(photo_path)
image.use_fake_user = True
Next, we create a material to assign to all puzzle pieces. You could make this manually and reference it in the script, but creating it directly in Python is quick and keeps everything self-contained — so let’s do that!
# — — — — — — — — — — — — — — -
# Create Puzzle Material (common for all puzzles)
# — — — — — — — — — — — — — — -
puzzle_mat = bpy.data.materials.new(name=”PuzzleMaterial”)
puzzle_mat.use_nodes = True
puzzle_mat.use_fake_user = True
nodes = puzzle_mat.node_tree.nodes
links = puzzle_mat.node_tree.links
# Clear default nodes
for node in nodes:
nodes.remove(node)
# Create Principled BSDF and Material Output nodes
output_node = nodes.new(type='ShaderNodeOutputMaterial')
output_node.location = (300, 0)
bsdf_node = nodes.new(type='ShaderNodeBsdfPrincipled')
bsdf_node.location = (0, 0)
img_tex_node = nodes.new(type='ShaderNodeTexImage')
img_tex_node.location = (-300, 0)
img_tex_node.image = image
links.new(img_tex_node.outputs["Color"], bsdf_node.inputs["Base Color"])
links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"])
The material includes a Principled BSDF node with the image connected to its Base Color input. If you prefer an unlit look, you can plug the image directly into the Material Output.
That wraps up the material setup — on to puzzle generation!
Puzzle Making
There are a few steps involved here. First, we need to determine how many puzzle pieces to generate based on the image’s aspect ratio. While we can define a target piece count, it’s not always possible to hit that exact number.
For example, if the user provides a square image and wants 99 pieces, there’s no clean way to divide that into a perfect square grid. The same issue applies to any aspect ratio — there’s no ideal 16:9 layout that results in exactly 100 pieces.
To handle this, we have two options: crop the image to match the desired piece count, or adjust the piece count to best fit the image’s aspect ratio. I went with the second option.
aspect_ratio = image.size[0] / image.size[1]
# - - - - - - - - - - - - - - -
# Compute Grid Dimensions from desired piece count and image aspect ratio
# - - - - - - - - - - - - - - -
cols = round(math.sqrt(user_piece_count * aspect_ratio))
rows = round(user_piece_count / cols)
actual_piece_count = rows * cols
print(f"Generating puzzle with grid dimensions: {cols} columns x {rows} rows (Total Pieces: {actual_piece_count})")
If the user wants a square puzzle, the number of columns and rows would simply be the square root of the desired piece count. For example, a 100-piece puzzle would be a 10x10 grid.
However, for non-square images, we can calculate the number of columns first, then derive the rows. The key insight is that the number of horizontal pieces should be proportional to the image’s aspect ratio. So, for a 2:1 aspect ratio, there should be twice as many columns as rows.
To maintain the image proportions, we can multiply the desired piece count by the aspect ratio, take the square root of that value to get the number of columns, and then divide the piece count by the column count to get the number of rows. This approach keeps the layout consistent with the original image dimensions.
It might not always give the closest possible result to the target piece count, but it works well enough with only minor deviations.
Next, we’ll use the calculated number of rows and columns to generate dummy Blender mesh objects — one for each puzzle piece. It’ll look something like this:
# — — — — — — — — — — — — — — -
# Create Puzzle Piece Meshes
# — — — — — — — — — — — — — — -
offset = 2.0
for i in range(rows):
for j in range(cols):
# Center pieces: add half an offset to each coordinate
loc = (j * offset + offset * 0.5, i * offset + offset * 0.5, 0)
bpy.ops.mesh.primitive_cube_add(size=2, location=loc)
obj = bpy.context.active_object
obj.name = f”Piece_{i}_{j}”
pieces[i][j] = obj
Notice that we’ve already generated several data structures to store the information we’ll need later:
# — — — — — — — — — — — — — — -
# Data Structures for pieces and indentations
# — — — — — — — — — — — — — — -
pieces = [[None for _ in range(cols)] for _ in range(rows)]
indentations = [[{‘left’: None, ‘right’: None, ‘top’: None, ‘bottom’: None} for _ in range(cols)]
for _ in range(rows)]
Once we have our puzzle piece objects, it’s time to define their shapes. Each side of a puzzle piece can be in one of three states:
- Flat (0): Used on the borders.
- Tab (1) or Socket (2): For connecting internal pieces.
For each side of every piece, we check:
- Is it on the puzzle’s outer edge? Then it must be flat (0).
- If it’s an internal edge, has the adjacent piece’s side already been set? If so, we assign the opposite type. If not, we randomly choose between tab (1) and socket (2).
# — — — — — — — — — — — — — — -
# Determine Indentations (Wave Function Collapse-like)
# — — — — — — — — — — — — — — -
for i in range(rows):
for j in range(cols):
current = indentations[i][j]
current[‘left’] = 0 if j == 0 else get_opposite(indentations[i][j-1][‘right’])
current[‘bottom’] = 0 if i == 0 else get_opposite(indentations[i-1][j][‘top’])
current[‘right’] = 0 if j == cols — 1 else random.choice([1, 2])
current[‘top’] = 0 if i == rows — 1 else random.choice([1, 2])
print(f”Piece_{i}_{j} indentations -> left: {current[‘left’]}, right: {current[‘right’]}, bottom: {current[‘bottom’]}, top: {current[‘top’]}”)
Where the get_opposite function does:
def get_opposite(val):
if val == 1:
return 2
elif val == 2:
return 1
else:
return 0
Since we iterate from bottom-left to top-right of the grid, we can ensure that unless the left or bottom edge of a piece is a border, their states have already been set. In that case, we can simply take the opposite of the right and top sides of the neighboring left and bottom pieces. The right and top sides will always get a random state, unless they are border edges.
Once this is done, we apply the geometry node modifier, which will convert this information into a mesh. We pass the states of each edge (0, 1, or 2) as integers to the geometry node.
# — — — — — — — — — — — — — — -
# Attach Geometry Nodes Modifier and Set Inputs
# — — — — — — — — — — — — — — -
node_group = bpy.data.node_groups.get(“PieceShapeCreator”)
if not node_group:
raise ValueError(“Geometry node group ‘PieceShapeCreator’ not found in the blend file.”)
for i in range(rows):
for j in range(cols):
obj = pieces[i][j]
mod = obj.modifiers.new(name=”JigsawMod”, type=’NODES’)
mod.node_group = node_group
mod[“Socket_2”] = indentations[i][j][‘left’]
mod[“Socket_3”] = indentations[i][j][‘right’]
mod[“Socket_4”] = indentations[i][j][‘top’]
mod[“Socket_5”] = indentations[i][j][‘bottom’]
print("Puzzle pieces created and geometry nodes assigned.")
In a separate collection, I have a dummy object with a geometry node setup called PieceShapeCreator, which is protected by a fake user. The script will reference this setup and apply it to each puzzle piece.
The way the geometry node creates the puzzle pieces is quite simple. However, there’s a more advanced approach you could take. For instance, you could use booleans in geometry nodes to generate each corner based on the passed state. With booleans, you gain precise control over the shape of the indentations.
Instead of using booleans, I opted to pre-model the sides of the puzzle piece for each state:
For each side, we can switch between the three states based on the integer passed:
The model we created before is always facing along the X-Axis, but the puzzle piece has 4 corners and depending on which one it is, it should be facing one of the cardinal direction. That’s why, for each side, we also need to rotate the mesh to position it correctly.
We can pack the state selection and rotation into a node group function, which we call four times — each receiving the appropriate integer and a rotation value based on the side.
With that, we’ve created a puzzle piece. Here’s an example of what the input “2, 2, 0, 0” would look like:
Real jigsaw puzzles often have unique tab-and-socket connections, where each tab only fits into one specific socket in the entire puzzle. To achieve this, we need to randomize the shape and bend of each side. To ensure that the modified tabs still align with their neighbors, we can pass a unique random seed for each side from the Python script. As long as the seed is the same for each opposing side of the puzzle piece and its neighbor, the deformation based on that seed will guarantee that the socket and tab match up.
To randomize each corner, we need two steps:
- In Python, generate a unique seed for each pair of corners.
- In Geometry Nodes, use that seed to deform the vertices so that each connection remains unique.
Generating Unique Seed for Each Edge Pair
To ensure unique connections between puzzle pieces, we need to generate a distinct seed for each edge.
We can achieve this by creating a separate parallel data structure to store these seeds. Then, similar to the previous steps, we iterate over the puzzle pieces and populate the structure. This data structure will hold a unique seed integer for each of the four sides of every puzzle piece.
# - - - - - - - - - - - - - - -
# Assign unique matching seed for each puzzle edge
# - - - - - - - - - - - - - - -
edge_seeds = [[{"left": None, "right": None, "top": None, "bottom": None} for _ in range(cols)] for _ in range(rows)]
for i in range(rows):
for j in range(cols):
# Only assign right and bottom edges (and propagate to neighbors)
if j < cols — 1:
new_seed = random.randint(0, 999999)
edge_seeds[i][j][“right”] = new_seed
edge_seeds[i][j + 1][“left”] = new_seed
else:
edge_seeds[i][j][“right”] = random.randint(0, 999999)
if i < rows - 1:
new_seed = random.randint(0, 999999)
edge_seeds[i][j]["top"] = new_seed
edge_seeds[i + 1][j]["bottom"] = new_seed
else:
edge_seeds[i][j]["top"] = random.randint(0, 999999)
# Left and top edges only need a value if not already set (i.e., on borders)
if j == 0:
edge_seeds[i][j]["left"] = random.randint(0, 999999)
if i == 0:
new_seed = random.randint(0, 999999)
edge_seeds[i][j]["bottom"] = new_seed
Similar to determining whether a corner is a tab or slot, we iterate over each puzzle piece. For the left and bottom edges, we copy the seed from the neighboring piece (left or bottom). For the right and top edges, we generate a new seed. These seeds are then passed as parameters to the geometry nodes, enabling us to proceed with the actual deformation.
Deformation
In Geometry Nodes, random nodes use a seed and an ID to generate random values. By keeping both the seed and ID consistent, we ensure the same random number is generated every time. This allows us to maintain consistent deformations across puzzle pieces.
We will add a new section to our geometry node for deformation. In the previous step, we created four copies of the puzzle piece corners, selected the appropriate mesh based on the edge state, and rotated them to align with the puzzle piece’s sides. This space before rotation is a “local space,” where the forward direction of the corner piece (the tab or nose) aligns with the axis. In this space, we will deform the corner before applying the final rotation, making it easier to manipulate along the X-axis.
The first deformation we’ll apply is a simple translation. While it’s tempting to move all the vertices along the X-axis based on a random number generated from the seed, this can cause the puzzle piece to break apart like a pizza sliced into four parts. To prevent this, we first create a mask to identify the vertices that shouldn’t be moved. Using the vertices’ positions in local space, we ensure that the central and corner vertices stay in place, preserving the overall shape of the puzzle piece.
Now that we have the mask, we can ensure that only the parts we want to move are affected. Section 2 in the image below demonstrates this. Section 1 generates a random value based on the seed to move the border. The mask ensures that only the vertices we want to move are affected, preserving the integrity of the puzzle piece’s shape.
You might wonder what’s happening in Section 3, which flips the direction of the X-axis. This step occurs before the puzzle piece corner is rotated into its final position. Since two opposing sides will be rotated 180 degrees from each other, we need to flip the forward direction.
I’m using a trick here: because a tab always corresponds to a slot on the opposite side, I leverage this relationship to flip the deformation axis for one side of the connection pair. This ensures that the deformations match up correctly between connected puzzle pieces.
The deformation above ensures that each connection pair has a unique curvature. However, the size of the tabs remains the same, and in puzzles, people often pay close attention to the tab sizes to find the correct matches. To address this, we need to scale the tabs themselves.
Whenever we want to scale (or rotate) something, the first thing we need to ask ourselves is, “Where is the pivot?” In this case, we want to scale around the center of the tab. While the exact position of the pivot can be calculated or eyeballed, I found that placing the pivot at the midpoint between the two puzzle pieces sharing the edge works well. Given that the offset between the puzzle pieces is 2, we want the pivot to be 1 unit away from each piece, which places it at the center.
Section 1 in the screenshot above demonstrates this: it moves all vertices so that the midpoint between Piece A and Piece B sits at the origin. After that, we scale the piece in this space, and then we translate the vertices back using the same offset, ensuring no actual translation occurs during the scaling process.
The scaling itself is similar to the other deformation methods — generate a random number and apply the mask. The only additional consideration here is that the two corners are 180 degrees rotated from each other. However, since the pivot is positioned exactly in the middle of the two points, we don’t need any extra adjustments. If the pivot were off-center, we would need to figure out where 0.5 units away from Piece A would be in the local space of Piece B. But I’m happy with the results as they are, so I’ll leave it at that.
This approach gives us unique deformations that are shared between each pair of neighboring puzzle pieces. The various permutations of these deformations look like this:
And here is how 400 pieces would look. Notice how each corner is unique:
Assign UV Maps and Clean Up
Now that we have our mesh, it’s time to figure out the UVs and perform some general clean-up.
First, I’ll apply the geometry node, assign the correct material (which I created in the script), and apply all transforms. The reason I apply the transforms is that I need all the vertices to be in world space to normalize them.
# -----------------------------
# Apply Geometry Nodes and Transforms, then assign the puzzle material
# -----------------------------
bpy.ops.object.select_all(action='DESELECT')
for i in range(rows):
for j in range(cols):
obj = pieces[i][j]
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.modifier_apply(modifier="JigsawMod")
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
if obj.data.materials:
obj.data.materials[0] = puzzle_mat
else:
obj.data.materials.append(puzzle_mat)
obj.select_set(False)
Next, we need to normalize the puzzle. Right now, a 1000-piece puzzle will physically be much larger than a 10-piece puzzle. I want to normalize them so that they always have the same size. The reason for this is that the vertex positions will then be the same across all puzzles, allowing me to generate a consistent UV map and apply any other effects I might want to add in the shader. The script calculates the width and length, creates UV map channels if none exist, and normalizes the extent of the entire puzzle.
# -----------------------------
# Compute Total Puzzle Size and Assign Correct UV Coordinates
# -----------------------------
total_width = (cols) * offset
total_height = (rows) * offset
for i in range(rows):
for j in range(cols):
obj = pieces[i][j]
if not obj.data.uv_layers:
obj.data.uv_layers.new(name="UVMap")
uv_layer = obj.data.uv_layers.active.data
for loop_index, loop in enumerate(obj.data.loops):
vert = obj.data.vertices[loop.vertex_index]
world_pos = obj.matrix_world @ vert.co
uv_x = (world_pos.x - pieces[0][0].location.x) / total_width
uv_y = (world_pos.y - pieces[0][0].location.y) / total_height
uv_layer[loop_index].uv = (uv_x, uv_y)
print("UV mapping completed successfully!")
# -----------------------------
# Normalize Puzzle Size so that total width becomes 10
# -----------------------------
scale_factor = 10 / total_width
print(f"Normalizing puzzle size with scale factor: {scale_factor:.3f}")
for i in range(rows):
for j in range(cols):
obj = pieces[i][j]
obj.select_set(True)
obj.scale.x *= scale_factor
obj.scale.y *= scale_factor
obj.scale.z *= scale_factor
obj.select_set(False)
bpy.ops.object.select_all(action='DESELECT')
for i in range(rows):
for j in range(cols):
obj = pieces[i][j]
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
obj.select_set(False)
print("Puzzle has been normalized to fit within 0 to 10 on the X-axis.")
Last but not least, it’s time to clean up the model. Since we’re duplicating a mesh for each corner of the puzzle piece, and it’s not a unified mesh, we’re using far more vertices than necessary. To fix that, I merge any overlapping vertices, triangulate the mesh, and then decimate it by 50%. There’s more that could be done here, but I’ll leave it at that!
# -----------------------------
# Preprocess Meshes: Smooth Shading, Merge Vertices, Triangulate, Decimate
# -----------------------------
for obj in bpy.data.collections["Pieces"].objects:
if obj.type == 'MESH':
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
# Shade smooth
bpy.ops.object.shade_smooth()
# Merge by distance
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=0.0001) # Adjust threshold as needed
# Triangulate
bpy.ops.mesh.quads_convert_to_tris(quad_method='BEAUTY', ngon_method='BEAUTY')
bpy.ops.object.mode_set(mode='OBJECT')
# Decimate
decimate_mod = obj.modifiers.new(name="Decimate", type='DECIMATE')
decimate_mod.ratio = 0.5 # Adjust decimation ratio if needed
bpy.ops.object.modifier_apply(modifier=decimate_mod.name)
obj.select_set(False)
Final Thoughts
Going from here to 3D printing or an actual game isn’t far off. For example, here I’m using the mesh in a simple puzzle game:
Having said that, if you do want to use this in a game, it’s probably best to cut the pieces on the fly. That way, you can import any picture you want directly on the client’s machine. I’m not sure what the standard workflow is for cutting real puzzle pieces, but you can definitely generate 3D prints, and if you modify this format further, you could even cut real puzzle pieces.
As always, thanks for reading! You can follow me on various socials listed here: