Python Tkinter OBJ Wireframe Viewer

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.

What you will build

Prerequisites

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.

Complete Python script

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()

Key sections explained

1. OBJ loader

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.

2. Rotation about the Y axis

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.

3. Projection and drawing

An orthographic projection simply drops Z when mapping to screen coordinates and applies a uniform scale.

4. UI and animation

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:

Animated plasma effect

Animated particle constellations