ipycanvas + NumPy = 3D

Disclaimer: The following is a stupid implementation of an .obj loader, do not use it in your codebase

In [ ]:
import numpy as np
In [ ]:
with open('monkey.obj', 'r') as fobj:
    lines = [line.strip() for line in fobj.readlines()]
In [ ]:
vertices = []
vertex_normals = []
faces = []
faces_normals = []
In [ ]:
# Extract vertices
for line in lines:
    splitted = line.split(' ')

    # Vertex
    if splitted[0] == 'v':
        _, v1, v2, v3 = splitted
        vertices.append([float(v1), float(v2), float(v3)])

    # Normal
    if splitted[0] == 'vn':
        _, v1, v2, v3 = splitted
        vertex_normals.append([float(v1), float(v2), float(v3)])

# Extract faces
for line in lines:
    splitted = line.split(' ')

    # Face
    if splitted[0] == 'f':
        # This file is triangulated, so this is fine
        _, v1, v2, v3 = splitted

        # It happens that this mesh is flat-shaded, so the normal is 
        # the same on all vertices
        v1_index, _, v1_normal = v1.split('/')
        v2_index, _, _ = v2.split('/')
        v3_index, _, _ = v3.split('/')

        faces.append([int(v1_index) - 1, int(v2_index) - 1, int(v3_index) - 1])
        faces_normals.append(int(v1_normal) - 1)
In [ ]:
vertices = np.array(vertices)
faces = np.array(faces)
vertex_normals = np.array(vertex_normals)
faces_normals = np.array(faces_normals)
In [ ]:
vertices
In [ ]:
faces

First let's display our monkey as a PointCloud

In [ ]:
from ipycanvas import Canvas, hold_canvas
In [ ]:
from py3d_engine import OrbitCamera, project_vector
In [ ]:
class MonkeyCloud(Canvas):
    def __init__(self):
        super(MonkeyCloud, self).__init__(width=500, height=500)

        self.dragging = False
        
        self.x = vertices[:,0]
        self.y = vertices[:,1]
        self.z = vertices[:,2]
        
        self.dx = 0
        self.dy = 0
        self.radius = 10

        self.camera = OrbitCamera(self.radius, [0, 0, 0], self.width/self.height)
        self.x2, self.y2, self.z2 = project_vector(self.x, self.y, self.z, self.camera.matrix)
        self.draw()

        self.on_mouse_down(self.mouse_down_handler)
        self.on_mouse_move(self.mouse_move_handler)
        self.on_mouse_up(self.mouse_up_handler)
        self.on_mouse_out(self.mouse_out_handler)

    def update_matrix(self, dx=None, dy=None):
        dx = dx if dx is not None else self.dx
        dy = dy if dy is not None else self.dy
        
        self.camera.update_position(dy, dx)
        self.x2, self.y2, self.z2 = project_vector(self.x, self.y, self.z, self.camera.matrix)
        self.draw()

    def draw(self):
        x = self.x2 * self.width + self.width / 2
        y = self.y2 * self.height + self.height / 2
        with hold_canvas(self):
            self.clear()
            self.fill_circles(x, y, 2)

    def mouse_down_handler(self, pixel_x, pixel_y):
        self.dragging = True
        self.x_mouse = pixel_x
        self.y_mouse = pixel_y

    def mouse_move_handler(self, pixel_x, pixel_y):
        if self.dragging:
            self.dx_new = self.dx + pixel_x - self.x_mouse
            self.dy_new = self.dy + pixel_y - self.y_mouse
            
            self.update_matrix(self.dx_new, self.dy_new)
    
    def mouse_up_handler(self, pixel_x, pixel_y):
        if self.dragging:
            self.dragging = False
            self.dx = self.dx_new
            self.dy = self.dy_new
    
    def mouse_out_handler(self, pixel_x, pixel_y):
        if self.dragging:
            self.dragging = False
            self.dx = self.dx_new
            self.dy = self.dy_new
In [ ]:
cloud = MonkeyCloud()
cloud

Looks good, but the monkey is made of triangles, so let's display them

In [ ]:
triangles = vertices[faces]
triangles_positions = np.mean(triangles, axis=1)
triangles_normals = vertex_normals[faces_normals]
In [ ]:
light_direction = np.array([-1, 1, 0])
In [ ]:
class Monkey(Canvas):
    def __init__(self):
        super(Monkey, self).__init__(width=500, height=500)

        self.dragging = False
        
        self.x = vertices[:,0]
        self.y = vertices[:,1]
        self.z = vertices[:,2]
        
        self.dx = 0
        self.dy = 0
        self.radius = 10

        self.camera = OrbitCamera(self.radius, [0, 0, 0], self.width/self.height)
        self.update_matrix()

        self.on_mouse_down(self.mouse_down_handler)
        self.on_mouse_move(self.mouse_move_handler)
        self.on_mouse_up(self.mouse_up_handler)
        self.on_mouse_out(self.mouse_out_handler)

    def update_matrix(self, dx=None, dy=None):
        dx = dx if dx is not None else self.dx
        dy = dy if dy is not None else self.dy

        self.camera.update_position(dy, dx)

        dist = np.linalg.norm(self.camera.position - triangles_positions, axis=1)
        
        # Face culling: Get rid of the triangles that are not facing the camera
        triangles_facing_camera = np.dot(triangles_normals, self.camera.front) < 0

        self.triangles = triangles[triangles_facing_camera]
        self.triangles_normals = triangles_normals[triangles_facing_camera]
        self.dist = dist[triangles_facing_camera]
        
        # Face sorting: Sort triangle by depth (distance to camera) so we can draw further triangles first
        self.order = np.flip(np.argsort(self.dist))
        
        # Project triangles
        triangle_vertices = self.triangles.reshape(self.triangles.shape[0] * self.triangles.shape[1], 3)
        proj_x, proj_y, _ = project_vector(triangle_vertices[:,0], triangle_vertices[:,1], triangle_vertices[:,2], self.camera.matrix)
        proj_x = proj_x * self.width + self.width / 2
        proj_y = proj_y * self.height + self.height / 2

        self.proj_triangles = np.stack((proj_x, proj_y), axis=1).reshape(self.triangles.shape[0], self.triangles.shape[1], 2)

        self.draw()

    def draw(self):
        with hold_canvas(self):
            self.clear()

            # Now let's draw triangles
            for i in self.order:
                triangle = self.proj_triangles[i]
                normal = self.triangles_normals[i]
                
                # Shading depending on light direction and face normal
                light = np.dot(light_direction, normal)
                if light < 0.4:
                    light = 0.4
                elif light > 1:
                    light = 1
                r, g, b = int(214 * light), int(224 * light), int(125 * light)
                self.fill_style = 'rgb({}, {}, {})'.format(r, g, b)
                
                self.fill_polygon(triangle)

    def mouse_down_handler(self, pixel_x, pixel_y):
        self.dragging = True
        self.x_mouse = pixel_x
        self.y_mouse = pixel_y

    def mouse_move_handler(self, pixel_x, pixel_y):
        if self.dragging:
            self.dx_new = self.dx + pixel_x - self.x_mouse
            self.dy_new = self.dy + pixel_y - self.y_mouse
            
            self.update_matrix(self.dx_new, self.dy_new)
    
    def mouse_up_handler(self, pixel_x, pixel_y):
        if self.dragging:
            self.dragging = False
            self.dx = self.dx_new
            self.dy = self.dy_new
    
    def mouse_out_handler(self, pixel_x, pixel_y):
        if self.dragging:
            self.dragging = False
            self.dx = self.dx_new
            self.dy = self.dy_new
In [ ]:
monkey = Monkey()
monkey