How to Create a 3D Rubik's Cube in Python Tkinter
In this Python Tutorial we will see How to Create a 3D classic Rubik's Cube using Python Tkinter And PIL.
Project Overview
The code consists of three main classes:
- RubiksCube - The main 3D cube rendering engine.
- ModernButton - Custom styled buttons with gradient effects.
- RubiksCubeApp - The application controller that ties everything together.
Project Source Code:
import tkinter as tk
from tkinter import ttk
import math
import colorsys
from PIL import Image, ImageTk, ImageDraw, ImageFilter, ImageEnhance
class RubiksCube(tk.Canvas):
def __init__(self, parent, width=800, height=600, **kwargs):
# Initialize the canvas with given dimensions
super().__init__(parent, width=width, height=height, **kwargs)
self.parent = parent
self.width = width
self.height = height
self.cube_size = 200 # Size of the cube
self.cell_size = self.cube_size / 3 # Size of each small cube cell
self.center_x = width / 2 # X center of the canvas
self.center_y = height / 2 # Y center of the canvas
# Starting rotation angles (in degrees)
self.rotX = -30 # Rotation around X axis
self.rotY = 45 # Rotation around Y axis
self.rotZ = 0 # Rotation around Z axis
# Animation properties
self.animation_active = False # Flag to track if animation is running
self.target_rotX = self.rotX # Target rotation for X axis
self.target_rotY = self.rotY # Target rotation for Y axis
self.target_rotZ = self.rotZ # Target rotation for Z axis
# Colors for the six faces
self.base_colors = {
'front': '#ff3333', # Red
'back': '#ff9500', # Orange
'right': '#33ff33', # Green
'left': '#3333ff', # Blue
'top': '#ffffff', # White
'bottom': '#ffff33' # Yellow
}
# Create gradient colors for more visual appeal
self.colors = self.generate_gradients()
# Mouse tracking variables for rotation
self.is_dragging = False # Flag to track if user is dragging
self.prev_x = 0 # Previous X position of mouse
self.prev_y = 0 # Previous Y position of mouse
# Create background image
self.bg_image = self.create_background()
self.bg_image_tk = ImageTk.PhotoImage(self.bg_image)
# Set up mouse event handlers
# When mouse button is pressed
self.bind("<ButtonPress-1>", self.on_mouse_down)
# When mouse is moved while button is pressed
self.bind("<B1-Motion>", self.on_mouse_move)
# When mouse button is released
self.bind("<ButtonRelease-1>", self.on_mouse_up)
# Draw the cube initially
self.draw_cube()
# Setup gentle floating animation
self.float_direction = 1 # Direction of floating movement
self.float_offset = 0 # Current offset of floating movement
self.float_animation() # Start the floating animation
def generate_gradients(self):
# Create different shades of each base color
gradients = {}
for face, base_color in self.base_colors.items():
# Convert hex color code to RGB (values between 0 and 1)
r = int(base_color[1:3], 16) / 255.0
g = int(base_color[3:5], 16) / 255.0
b = int(base_color[5:7], 16) / 255.0
# Convert RGB to HSV (Hue, Saturation, Value) color space
h, s, v = colorsys.rgb_to_hsv(r, g, b)
# Generate 9 variations of the color (one for each cell in a face)
gradients[face] = []
for i in range(9):
# Vary saturation and value for cells
new_s = min(1.0, s * (0.8 + (i % 3) * 0.1))
new_v = min(1.0, v * (0.8 + (i // 3) * 0.1))
# Convert back to RGB
new_r, new_g, new_b = colorsys.hsv_to_rgb(h, new_s, new_v)
# Convert to hex color code
r = f'{int(new_r * 255):02x}'
g = f'{int(new_g * 255):02x}'
b = f'{int(new_b * 255):02x}'
hex_color = f'#{r}{g}{b}'
gradients[face].append(hex_color)
return gradients
def create_background(self):
# Create a nice looking background image
img = Image.new('RGBA', (self.width, self.height), (0, 0, 0, 255))
draw = ImageDraw.Draw(img)
# Create gradient background (dark blue to black)
for y in range(self.height):
r = int(10 - 10 * y / self.height)
g = int(10 - 10 * y / self.height)
b = int(30 - 20 * y / self.height)
draw.line([(0, y), (self.width, y)], fill=(r, g, b))
# Add darker corners for depth effect
for i in range(100):
alpha = 100 - i # Decreasing transparency
# Top left corner
draw.ellipse([-100+i, -100+i, 200-i, 200-i], fill=(0, 0, 0, alpha))
# Top right corner
draw.ellipse([self.width-200+i, -100+i, self.width+100-i, 200-i],
fill=(0, 0, 0, alpha))
# Bottom corners
draw.ellipse([-100+i, self.height-200+i, 200-i, self.height+100-i],
fill=(0, 0, 0, alpha))
draw.ellipse([self.width-200+i, self.height-200+i, self.width+100-i,
self.height+100-i], fill=(0, 0, 0, alpha))
# Add star-like dots
import random
for _ in range(100):
x = random.randint(0, self.width)
y = random.randint(0, self.height)
size = random.randint(1, 3)
brightness = random.randint(100, 255)
draw.ellipse([x-size, y-size, x+size, y+size],
fill=(brightness, brightness, brightness, random.randint(50, 200)))
# Apply a slight blur for a smoother look
img = img.filter(ImageFilter.GaussianBlur(radius=1))
return img
def on_mouse_down(self, event):
# Called when mouse button is pressed
self.is_dragging = True
self.prev_x = event.x
self.prev_y = event.y
def on_mouse_move(self, event):
# Called when mouse is moved while button is pressed
if self.is_dragging:
# Calculate how much the mouse has moved
delta_x = event.x - self.prev_x
delta_y = event.y - self.prev_y
# Update rotation based on mouse movement
self.rotY += delta_x * 0.5
self.rotX += delta_y * 0.5
# Update target rotations to match current rotations
self.target_rotX = self.rotX
self.target_rotY = self.rotY
# Redraw the cube with new rotation
self.draw_cube()
# Update previous mouse position
self.prev_x = event.x
self.prev_y = event.y
def on_mouse_up(self, event):
# Called when mouse button is released
self.is_dragging = False
# Update target rotations to match current rotations
self.target_rotX = self.rotX
self.target_rotY = self.rotY
def rotate_cube(self, axis):
# Rotate the cube by 90 degrees around specified axis
# Set target rotation for smooth animation
if axis == 'x':
self.target_rotX += 90
elif axis == 'y':
self.target_rotY += 90
elif axis == 'z':
self.target_rotZ += 90
# Start animation if not already running
if not self.animation_active:
self.animation_active = True
self.animate_rotation()
def animate_rotation(self):
# Smoothly animate rotation from current angles to target angles
# Calculate how much to rotate in this frame (10% of remaining distance)
dx = (self.target_rotX - self.rotX) * 0.1
dy = (self.target_rotY - self.rotY) * 0.1
dz = (self.target_rotZ - self.rotZ) * 0.1
# Apply rotation
self.rotX += dx
self.rotY += dy
self.rotZ += dz
# Check if we're close enough to targets
if (abs(dx) < 0.5 and abs(dy) < 0.5 and abs(dz) < 0.5):
self.rotX = self.target_rotX
self.rotY = self.target_rotY
self.rotZ = self.target_rotZ
self.animation_active = False
# Redraw and continue animation if needed
self.draw_cube()
if self.animation_active:
self.after(16, self.animate_rotation) # About 60fps
def float_animation(self):
# Create a gentle floating animation to make the cube look more alive
if not self.is_dragging and not self.animation_active:
# Only animate when not being dragged or rotated
self.float_offset += 0.03 * self.float_direction
# Change direction at limits
if abs(self.float_offset) > 2:
self.float_direction *= -1
# Apply a subtle movement
self.rotX = self.target_rotX + math.sin(self.float_offset) * 1
self.rotY = self.target_rotY + math.cos(self.float_offset) * 1
self.draw_cube()
# Continue animation
self.after(30, self.float_animation)
def draw_cube(self):
# Draw the entire cube with current rotation
self.delete("all") # Clear canvas
# Draw background
self.create_image(self.width/2, self.height/2, image=self.bg_image_tk)
# Calculate positions of all faces
faces = self.calculate_face_data()
# Sort faces by their z-order (furthest to closest)
# This ensures that faces in the back are drawn first
sorted_faces = sorted(faces.items(), key=lambda x: x[1]['z_order'])
# Draw each face
for face_name, face_data in sorted_faces:
self.draw_face(face_name, face_data)
# Draw a subtle frame around the canvas
self.create_rectangle(0, 0, self.width-1, self.height-1,
outline="#333333", width=2)
def calculate_face_data(self):
# Calculate the position of each face after rotation
faces = {
'front': {'vertices': [], 'z_order': 0},
'back': {'vertices': [], 'z_order': 0},
'right': {'vertices': [], 'z_order': 0},
'left': {'vertices': [], 'z_order': 0},
'top': {'vertices': [], 'z_order': 0},
'bottom': {'vertices': [], 'z_order': 0}
}
# Convert angles from degrees to radians for math functions
rot_x = math.radians(self.rotX)
rot_y = math.radians(self.rotY)
rot_z = math.radians(self.rotZ)
# Calculate cube corners (vertices)
half_size = self.cube_size / 2
corners = [
[-half_size, -half_size, -half_size], # 0: left-top-back
[half_size, -half_size, -half_size], # 1: right-top-back
[half_size, half_size, -half_size], # 2: right-bottom-back
[-half_size, half_size, -half_size], # 3: left-bottom-back
[-half_size, -half_size, half_size], # 4: left-top-front
[half_size, -half_size, half_size], # 5: right-top-front
[half_size, half_size, half_size], # 6: right-bottom-front
[-half_size, half_size, half_size] # 7: left-bottom-front
]
# Rotate corners based on current rotation angles
rotated_corners = []
for x, y, z in corners:
# Rotate around X axis
y1 = y * math.cos(rot_x) - z * math.sin(rot_x)
z1 = y * math.sin(rot_x) + z * math.cos(rot_x)
y, z = y1, z1
# Rotate around Y axis
x1 = x * math.cos(rot_y) + z * math.sin(rot_y)
z1 = -x * math.sin(rot_y) + z * math.cos(rot_y)
x, z = x1, z1
# Rotate around Z axis
x1 = x * math.cos(rot_z) - y * math.sin(rot_z)
y1 = x * math.sin(rot_z) + y * math.cos(rot_z)
x, y = x1, y1
# Project 3D coordinates to 2D screen coordinates
screen_x = self.center_x + x
screen_y = self.center_y + y
rotated_corners.append((screen_x, screen_y, z))
# Define which corners form each face
faces['front']['vertices'] = [rotated_corners[4], rotated_corners[5],
rotated_corners[6], rotated_corners[7]]
faces['back']['vertices'] = [rotated_corners[1], rotated_corners[0],
rotated_corners[3], rotated_corners[2]]
faces['right']['vertices'] = [rotated_corners[5], rotated_corners[1],
rotated_corners[2], rotated_corners[6]]
faces['left']['vertices'] = [rotated_corners[0], rotated_corners[4],
rotated_corners[7], rotated_corners[3]]
faces['top']['vertices'] = [rotated_corners[0], rotated_corners[1],
rotated_corners[5], rotated_corners[4]]
faces['bottom']['vertices'] = [rotated_corners[7], rotated_corners[6],
rotated_corners[2], rotated_corners[3]]
# Calculate average z-value for determining draw order
for face_name, face_data in faces.items():
avg_z = sum(vertex[2] for vertex in face_data['vertices']) / 4
face_data['z_order'] = avg_z
return faces
def draw_face(self, face_name, face_data):
# Draw a single face of the cube
vertices = face_data['vertices']
face_colors = self.colors[face_name]
# Create the outer face polygon
poly_coords = []
for vertex in vertices:
poly_coords.extend([vertex[0], vertex[1]])
# Draw face background with slight transparency
self.create_polygon(poly_coords, fill="#111111", outline="#222222",
width=2, tags="face")
# Calculate the cell corners
# Get the 2D vector directions for the face
v1 = ((vertices[1][0] - vertices[0][0]) / 3,
(vertices[1][1] - vertices[0][1]) / 3)
v2 = ((vertices[3][0] - vertices[0][0]) / 3,
(vertices[3][1] - vertices[0][1]) / 3)
# Draw individual cells (3x3 grid)
for row in range(3):
for col in range(3):
# Calculate cell position
start_x = vertices[0][0] + col * v1[0] + row * v2[0]
start_y = vertices[0][1] + col * v1[1] + row * v2[1]
# Calculate cell corners
cell_corners = [
(start_x, start_y),
(start_x + v1[0], start_y + v1[1]),
(start_x + v1[0] + v2[0], start_y + v1[1] + v2[1]),
(start_x + v2[0], start_y + v2[1])
]
# Create cell polygon
cell_coords = []
for corner in cell_corners:
cell_coords.extend([corner[0], corner[1]])
# Draw cell with slight padding
padding = 4
padded_cell = self.create_padded_polygon(cell_coords, padding)
# Use different color for each cell to create gradient effect
cell_index = row * 3 + col
cell_color = face_colors[cell_index]
# Calculate lighting effect based on face orientation
light_factor = self.calculate_lighting(face_name, face_data['z_order'])
cell_color = self.adjust_color_brightness(cell_color, light_factor)
# Add shadow and highlight effects
cell_id = self.create_polygon(padded_cell, fill=cell_color,
outline="#333333", width=1,
tags=f"cell_{face_name}_{row}_{col}")
# Add highlight effect
self.tag_bind(cell_id, "<Enter>", lambda e,
cid=cell_id: self.highlight_cell(cid))
self.tag_bind(cell_id, "<Leave>", lambda e,
cid=cell_id: self.unhighlight_cell(cid))
def create_padded_polygon(self, coords, padding):
# Create a slightly smaller polygon for visual effect
# Find the center of the polygon
num_points = len(coords) // 2
center_x = sum(coords[i] for i in range(0, len(coords), 2)) / num_points
center_y = sum(coords[i+1] for i in range(0, len(coords), 2)) / num_points
# Move each vertex toward the center by the padding amount
new_coords = []
for i in range(0, len(coords), 2):
x, y = coords[i], coords[i+1]
# Calculate direction vector to center
dir_x, dir_y = center_x - x, center_y - y
# Normalize
length = math.sqrt(dir_x**2 + dir_y**2)
if length > 0:
dir_x, dir_y = dir_x / length, dir_y / length
# Move point toward center
new_x = x + dir_x * padding
new_y = y + dir_y * padding
new_coords.extend([new_x, new_y])
return new_coords
def highlight_cell(self, cell_id):
# Make a cell brighter when mouse hovers over it
current_color = self.itemcget(cell_id, "fill")
brighter_color = self.adjust_color_brightness(current_color, 1.2)
self.itemconfig(cell_id, fill=brighter_color, width=2)
def unhighlight_cell(self, cell_id):
# Return cell to normal brightness when mouse leaves
current_color = self.itemcget(cell_id, "fill")
normal_color = self.adjust_color_brightness(current_color, 0.833) # 1/1.2
self.itemconfig(cell_id, fill=normal_color, width=1)
def calculate_lighting(self, face_name, z_order):
# Calculate how bright a face should be based on its orientation
# Basic lighting - faces closer to viewer are brighter
base_light = 0.7 + (z_order / self.cube_size) * 0.5
# Directional light effect based on face
directional = {
'front': 1.2, # Front face gets more light
'back': 0.85, # Back face gets less light
'right': 1.1, # Right face slightly bright
'left': 0.9, # Left face slightly dark
'top': 1.15, # Top face bright
'bottom': 0.8 # Bottom face darker
}
return base_light * directional[face_name]
def adjust_color_brightness(self, hex_color, factor):
# Adjust the brightness of a hex color
# Convert hex to RGB
r = int(hex_color[1:3], 16)
g = int(hex_color[3:5], 16)
b = int(hex_color[5:7], 16)
# Adjust brightness (multiply RGB values by factor)
r = min(255, max(0, int(r * factor)))
g = min(255, max(0, int(g * factor)))
b = min(255, max(0, int(b * factor)))
# Convert back to hex
return f'#{r:02x}{g:02x}{b:02x}'
class ModernButton(tk.Canvas):
def __init__(self, parent, text, command, width=150, height=40, **kwargs):
# Create a button with a modern look
super().__init__(parent, width=width, height=height,
highlightthickness=0, **kwargs)
self.text = text
self.command = command
self.width = width
self.height = height
self.state = "normal" # normal, hover, pressed
# Create gradient images for different button states
self.normal_bg = self.create_gradient("#222222", "#111111")
self.hover_bg = self.create_gradient("#333333", "#222222")
self.pressed_bg = self.create_gradient("#111111", "#222222")
self.current_bg = self.normal_bg
# Bind events to change button appearance and trigger actions
self.bind("<Enter>", self.on_enter) # Mouse enters button
self.bind("<Leave>", self.on_leave) # Mouse leaves button
self.bind("<ButtonPress-1>", self.on_press) # Mouse button pressed
self.bind("<ButtonRelease-1>", self.on_release) # Mouse button released
# Initial draw
self.draw()
def create_gradient(self, color1, color2):
# Create a gradient image for button background
img = Image.new('RGBA', (self.width, self.height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Convert hex colors to RGB
r1, g1, b1 = int(color1[1:3], 16), int(color1[3:5], 16), int(color1[5:7], 16)
r2, g2, b2 = int(color2[1:3], 16), int(color2[3:5], 16), int(color2[5:7], 16)
# Create gradient by drawing horizontal lines with gradually changing color
for y in range(self.height):
# Linear interpolation between colors
r = r1 + (r2 - r1) * y / self.height
g = g1 + (g2 - g1) * y / self.height
b = b1 + (b2 - b1) * y / self.height
draw.line([(0, y), (self.width, y)], fill=(int(r), int(g), int(b)))
# Add glow effect (thin white border that fades)
for i in range(3):
alpha = 70 - i * 20
draw.rectangle([i, i, self.width-i-1, self.height-i-1],
outline=(255, 255, 255, alpha))
return ImageTk.PhotoImage(img)
def draw(self):
# Draw the button with current appearance
self.delete("all")
# Draw background
self.create_image(self.width/2, self.height/2, image=self.current_bg)
# Draw text
text_color = "#ffffff" if self.state != "pressed" else "#aaaaaa"
self.create_text(self.width/2, self.height/2, text=self.text,
fill=text_color, font=("Helvetica", 12, "bold"))
def on_enter(self, event):
# Mouse enters button - change to hover state
if self.state != "pressed":
self.state = "hover"
self.current_bg = self.hover_bg
self.draw()
def on_leave(self, event):
# Mouse leaves button - change to normal state
if self.state != "pressed":
self.state = "normal"
self.current_bg = self.normal_bg
self.draw()
def on_press(self, event):
# Button is pressed - change to pressed state
self.state = "pressed"
self.current_bg = self.pressed_bg
self.draw()
def on_release(self, event):
# Button is released - change to normal state and execute command
self.state = "normal"
self.current_bg = self.normal_bg
self.draw()
# Check if mouse is still within button area
if 0 <= event.x <= self.width and 0 <= event.y <= self.height:
self.command() # Execute the button's command
class RubiksCubeApp:
def __init__(self, root):
# Main application class
self.root = root
root.title("Rubik's Cube")
root.geometry("850x700")
root.configure(bg="#000000")
# Create title and subtitle
title_frame = tk.Frame(root, bg="#000000")
title_frame.pack(pady=(10, 5))
title_label = tk.Label(title_frame, text="MODERN RUBIK'S CUBE",
font=("Helvetica", 16, "bold"), fg="#ffffff", bg="#000000")
title_label.pack()
subtitle_label = tk.Label(title_frame, text="Interactive 3D Visualization",
font=("Helvetica", 10), fg="#999999", bg="#000000")
subtitle_label.pack()
# Create main frame
main_frame = tk.Frame(root, bg="#000000")
main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=5)
# Create canvas for Rubik's cube
self.cube_canvas = RubiksCube(main_frame, width=800, height=500,
highlightthickness=0, bg="#000000")
self.cube_canvas.pack(fill=tk.BOTH, expand=True)
# Create control buttons
control_frame = tk.Frame(root, bg="#000000")
control_frame.pack(pady=10, fill=tk.X)
# Add modern buttons with spacing
button_frame = tk.Frame(control_frame, bg="#000000")
button_frame.pack(pady=5)
# Row of buttons
self.rotate_x_btn = ModernButton(button_frame, "ROTATE X",
lambda: self.cube_canvas.rotate_cube('x'))
self.rotate_x_btn.grid(row=0, column=0, padx=10, pady=5)
self.rotate_y_btn = ModernButton(button_frame, "ROTATE Y",
lambda: self.cube_canvas.rotate_cube('y'))
self.rotate_y_btn.grid(row=0, column=1, padx=10, pady=5)
self.rotate_z_btn = ModernButton(button_frame, "ROTATE Z",
lambda: self.cube_canvas.rotate_cube('z'))
self.rotate_z_btn.grid(row=0, column=2, padx=10, pady=5)
# Set window icon
try:
# Create a simple icon
icon = Image.new('RGBA', (32, 32), color=(0, 0, 0, 0))
draw = ImageDraw.Draw(icon)
# Draw a mini Rubik's cube
colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ffffff', '#ffa500']
for i in range(2):
for j in range(2):
draw.rectangle([i*16, j*16, (i+1)*16, (j+1)*16],
fill=colors[(i+j) % len(colors)])
# Convert to PhotoImage
icon_photo = ImageTk.PhotoImage(icon)
root.iconphoto(False, icon_photo)
except:
pass # Skip icon if there's an error
if __name__ == "__main__":
root = tk.Tk()
app = RubiksCubeApp(root)
root.mainloop()
The Final Result:
Download Projects Source Code




