
This tutorial shows how to build a minimal, dependency-free Python program using Tkinter that loads geometry from a simple .obj file, projects it orthographically, and continuously rotates the wireframe about the Y axis. It includes a complete runnable script you can copy-paste and explanations of the key parts.
v and f).Canvas.Python 3 (any recent 3.x) — no external libraries are required. If you want to display HTML inside a Tkinter window (for example to render this tutorial inside your app), there are lightweight modules such as tkhtmlview or the TkinterWeb project that provide simple HTML rendering inside Tkinter widgets.
Copy the following into a file named 3d_obj_viewer.py or download it here: 3d_obj_viewer.py and run it with python 3d_obj_viewer.py.
The .obj format is supported by most 3d editing tools. You can download 3d models in this format from the internet or create them using for example Blender 3d. Here's our sample file: dino.obj
#!/usr/bin/env python3
import math
import tkinter as tk
from tkinter import filedialog, messagebox
# ---------- OBJ loader ----------
def load_obj(path):
verts = []
faces = []
with open(path, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split()
if parts[0] == 'v' and len(parts) >= 4:
x, y, z = map(float, parts[1:4])
verts.append((x, y, z))
elif parts[0] == 'f' and len(parts) >= 4:
idxs = []
for p in parts[1:]:
i = p.split('/')[0]
idxs.append(int(i) - 1)
faces.append(idxs)
return verts, faces
# ---------- 3D transforms / projection ----------
def rotate_y(vertex, angle_rad):
x, y, z = vertex
c = math.cos(angle_rad)
s = math.sin(angle_rad)
xr = x * c + z * s
yr = y
zr = -x * s + z * c
return (xr, yr, zr)
def project_orthographic(vertex, scale, center):
x, y, z = vertex
sx = center[0] + x * scale
sy = center[1] - y * scale
return (sx, sy)
# ---------- Viewer App ----------
class WireframeViewer(tk.Tk):
def __init__(self):
super().__init__()
self.title("OBJ Wireframe viewer")
self.geometry("900x700")
self.canvas = tk.Canvas(self, bg='white')
self.canvas.pack(fill=tk.BOTH, expand=True)
toolbar = tk.Frame(self)
toolbar.pack(side=tk.TOP, fill=tk.X)
self.load_btn = tk.Button(toolbar, text="Load OBJ", command=self.load)
self.load_btn.pack(side=tk.LEFT, padx=6, pady=4)
self.pause_btn = tk.Button(toolbar, text="Pause", command=self.toggle_pause)
self.pause_btn.pack(side=tk.LEFT, padx=6, pady=4)
self.speed_scale = tk.Scale(toolbar, from_=-2.0, to=2.0, resolution=0.05,
orient=tk.HORIZONTAL, label="Rotation speed (rad/s)")
self.speed_scale.set(0.8)
self.speed_scale.pack(side=tk.LEFT, padx=6, pady=4)
self.verts = []
self.faces = []
self.angle = 0.0
self.paused = False
self.scale_model = 1.0
self.bind("<Configure>", lambda e: self.redraw())
self.after(10, self.update_frame)
def load(self):
path = filedialog.askopenfilename(filetypes=[("OBJ files", "*.obj"), ("All files", "*.*")])
if not path:
return
verts, faces = load_obj(path)
if not verts:
messagebox.showerror("Error", "No vertices found in OBJ")
return
self.verts = verts
self.faces = faces
self.center_and_scale()
self.angle = 0.0
self.redraw()
def center_and_scale(self):
xs = [v[0] for v in self.verts]
ys = [v[1] for v in self.verts]
zs = [v[2] for v in self.verts]
minx, maxx = min(xs), max(xs)
miny, maxy = min(ys), max(ys)
minz, maxz = min(zs), max(zs)
cx = (minx + maxx) / 2.0
cy = (miny + maxy) / 2.0
cz = (minz + maxz) / 2.0
self.verts = [ (x - cx, y - cy, z - cz) for (x,y,z) in self.verts ]
span = max(maxx - minx, maxy - miny, maxz - minz, 1e-6)
self.scale_model = 1.0 / span * 0.9
def toggle_pause(self):
self.paused = not self.paused
self.pause_btn.config(text="Resume" if self.paused else "Pause")
def update_frame(self):
if not self.paused and self.verts:
speed = self.speed_scale.get()
dt = 1/60.0
self.angle += speed * dt
self.redraw()
self.after(16, self.update_frame)
def redraw(self):
self.canvas.delete("all")
if not self.verts:
w = self.canvas.winfo_width()
h = self.canvas.winfo_height()
self.canvas.create_text(w//2, h//2, text="Load an OBJ file to begin", fill="gray")
return
w = max(10, self.canvas.winfo_width())
h = max(10, self.canvas.winfo_height())
center = (w/2, h/2)
s = min(w, h) * self.scale_model * 0.9
# rotate about Y
rotated = [rotate_y(v, self.angle) for v in self.verts]
# Draw faces
edge_color = "#000000"
for face in self.faces:
pts = [project_orthographic(rotated[i], s, center) for i in face]
for a, b in zip(pts, pts[1:] + pts[:1]):
self.canvas.create_line(a[0], a[1], b[0], b[1], fill=edge_color)
if __name__ == "__main__":
app = WireframeViewer()
app.mainloop()
The loader reads lines starting with v (vertex) and f (face). Vertices are stored as (x,y,z) tuples and faces as lists of zero-based vertex indices. The loader tolerates face entries like f 1/1 2/2 3/3 by splitting at the slash and taking the first part.
The rotation uses the Y-axis rotation matrix. For angle θ:
x' = x * cosθ + z * sinθ
y' = y
z' = -x * sinθ + z * cosθ
Applying this to each vertex produces the rotated coordinates used for projection and drawing.
An orthographic projection simply drops Z when mapping to screen coordinates and applies a uniform scale.
A small toolbar provides "Load OBJ", a pause/resume button, and a slider for rotation speed (radians/second). The animation uses a fixed timestep (1/60) inside a recurring after callback to update the rotation angle and redraw.
More Python tutorials: