"""3d rotating polyhedra. 2016 badge competition winner, ported for 2018!"""

___title___        = "3D Spin"
___license___      = "MIT"
___categories___   = ["Demo"]
___dependencies___ = ["app", "ugfx_helper", "sleep", "buttons"]

import ugfx
from tilda import Buttons
import math
from uos import listdir
import time
# from imu import IMU
import gc
# import pyb
import app

app_path = './3dspin'

from math import sqrt

class Vector3D:
	def __init__(self, x=0.0, y=0.0, z=0.0):
		self.x = x
		self.y = y
		self.z = z

	def magnitude(self):
		return sqrt(self.x*self.x+self.y*self.y+self.z*self.z)

	def __sub__(self, v):
		return Vector3D(self.x-v.x, self.y-v.y, self.z-v.z)

	def normalize(self):
		mag = self.magnitude()
		if (mag > 0.0):
			self.x /= mag
			self.y /= mag
			self.z /= mag
		else:
			raise Exception('*** Vector: error, normalizing zero vector! ***')

	def cross(self, v): #cross product
		return Vector3D(self.y*v.z-self.z*v.y, self.z*v.x-self.x*v.z, self.x*v.y-self.y*v.x)


#The layout of the matrix (row- or column-major) matters only when the user reads from or writes to the matrix (indexing). For example in the multiplication function we know that the first components of the Matrix-vectors need to be multiplied by the vector. The memory-layout is not important
class Matrix:
	''' Column-major order '''

	def __init__(self, createidentity=True):# (2,2) creates a 2*2 Matrix
		# if rows < 2 or cols < 2:
		# 	raise Exception('*** Matrix: error, getitem((row, col)), row, col problem! ***')
		self.rows = 4
		self.cols = 4
		self.m = [[0.0]*self.rows for x in range(self.cols)]

		#If quadratic matrix then create identity one
		if createidentity:
			for i in range(self.rows):
				self.m[i][i] = 1.0

	def mul(self, right):
		if isinstance(right, Matrix):
			r = Matrix(False)
			for i in range(self.rows):
				for j in range(right.cols):
					for k in range(self.cols):
						r.m[i][j] += self.m[i][k]*right.m[k][j]
			return r
		elif isinstance(right, Vector3D): #Translation: the last column of the matrix. Remains unchanged due to the the fourth coord of the vector (1).
#			if self.cols == 4:
			r = Vector3D()
			addx = addy = addz = 0.0
			if self.rows == self.cols == 4:
				addx = self.m[0][3]
				addy = self.m[1][3]
				addz = self.m[2][3]
			r.x = self.m[0][0]*right.x+self.m[0][1]*right.y+self.m[0][2]*right.z+addx
			r.y = self.m[1][0]*right.x+self.m[1][1]*right.y+self.m[1][2]*right.z+addy
			r.z = self.m[2][0]*right.x+self.m[2][1]*right.y+self.m[2][2]*right.z+addz

			#In 3D game programming we use homogenous coordinates instead of cartesian ones in case of Vectors in order to be able to use them with a 4*4 Matrix. The 4th coord (w) is not included in the Vector-class but gets computed on the fly
			w = self.m[3][0]*right.x+self.m[3][1]*right.y+self.m[3][2]*right.z+self.m[3][3]
			if (w != 1 and w != 0):
				r.x = r.x/w;
				r.y = r.y/w;
				r.z = r.z/w;
			return r
		else:
			raise Exception('*** Matrix: error, matrix multiply with not matrix, vector or int or float! ***')

def loadObject(filename):
    print(filename)
    if (".obj" in filename):
        loadObj(filename)
    if (".dat" in filename):
        loadDat(filename)

def loadDat(filename):
    global obj_vertices
    global obj_faces
    obj_vertices = []
    obj_faces = []
    f = open(app_path + "/" + filename)
    for line in f:
        if line[:2] == "v ":
            parts = line.split(" ")
            obj_vertices.append(
                Vector3D(
                    float(parts[1]),
                    float(parts[2]),
                    float(parts[3])
                )
            )
            gc.collect()
        elif line[:2] == "f ":
            parts = line.split(" ")
            face = []
            for part in parts[1:]:
                face.append(int(part.split("/",1)[0])-1)
            obj_faces.append(face)
            gc.collect()
    f.close()

def loadObj(filename):
    global obj_vertices
    global obj_faces
    obj_vertices = []
    obj_faces = []
    f = open(app_path + "/" + filename)
    for line in f:
        if line[:2] == "v ":
            parts = line.split(" ")
            obj_vertices.append(
                Vector3D(
                    float(parts[1]),
                    float(parts[2]),
                    float(parts[3])
                )
            )
            gc.collect()
        elif line[:2] == "f ":
            parts = line.split(" ")
            face = []
            for part in parts[1:]:
                face.append(int(part.split("/",1)[0])-1)
            obj_faces.append(face)
            gc.collect()
    f.close()

def toScreenCoords(pv):
	px = int((pv.x+1)*0.5*240)
	py = int((1-(pv.y+1)*0.5)*320)
	return [px, py]

def createCameraMatrix(x,y,z):
    camera_transform = Matrix()
    camera_transform.m[0][3] = x
    camera_transform.m[1][3] = y
    camera_transform.m[2][3] = z
    return camera_transform

def createProjectionMatrix(horizontal_fov, zfar, znear):
    s = 1/(math.tan(math.radians(horizontal_fov/2)))
    proj = Matrix()
    proj.m[0][0] = s * (320/240) # inverse aspect ratio
    proj.m[1][1] = s
    proj.m[2][2] = -zfar/(zfar-znear)
    proj.m[3][2] = -1.0
    proj.m[2][3] = -(zfar*znear)/(zfar-znear)
    return proj

def createRotationMatrix(x_rotation, y_rotation, z_rotation):
    rot_x = Matrix()
    rot_x.m[1][1] = rot_x.m[2][2] = math.cos(x_rotation)
    rot_x.m[2][1] = math.sin(x_rotation)
    rot_x.m[1][2] = -rot_x.m[2][1]

    rot_y = Matrix()
    rot_y.m[0][0] = rot_y.m[2][2] = math.cos(y_rotation)
    rot_y.m[0][2] = math.sin(y_rotation)
    rot_y.m[2][0] = -rot_y.m[0][2]

    rot_z = Matrix()
    rot_z.m[0][0] = rot_z.m[1][1] = math.cos(z_rotation)
    rot_z.m[1][0] = math.sin(z_rotation)
    rot_z.m[0][1] = -rot_z.m[1][0]

    return rot_z.mul(rot_x).mul(rot_y)

def normal(face, vertices, normalize = True):
    # Work out the face normal for lighting
    normal = (vertices[face[1]]-vertices[face[0]]).cross(vertices[face[2]]-vertices[face[0]])
    if normalize == True:
        normal.normalize()
    return normal

def clear_screen():
    # Selectively clear the screen by re-rendering the previous frame in black
    global last_polygons
    global last_mode
    for poly in last_polygons:
        if last_mode == FLAT:
            ugfx.fill_polygon(0,0, poly, ugfx.BLACK)
        ugfx.polygon(0,0, poly, ugfx.BLACK)

def render(mode, rotation):
    # Rotate all the vertices in one go
    vertices = [rotation.mul(vertex) for vertex in obj_vertices]
    # Calculate normal for each face (for lighting)
    if mode == FLAT:
        face_normal_zs = [normal(face, vertices).z for face in obj_faces]
    # Project (with camera) all the vertices in one go as well
    vertices = [camera_projection.mul(vertex) for vertex in vertices]
    # Calculate projected normals for each face
    if mode != WIREFRAME:
        proj_normal_zs = [normal(face, vertices, False).z for face in obj_faces]
    # Convert to screen coordinates all at once
    # We could do this faster by only converting vertices that are
    # in faces that will be need rendered, but it's likely that test
    # would take longer.
    vertices = [toScreenCoords(v) for v in vertices]
    # Render the faces to the screen
    vsync()
    clear_screen()

    global last_polygons
    global last_mode
    last_polygons = []
    last_mode = mode

    for index in range(len(obj_faces)):
        # Only render things facing towards us (unless we're in wireframe mode)
        if (mode == WIREFRAME) or (proj_normal_zs[index] > 0):
            # Convert polygon
            poly = [vertices[v] for v in obj_faces[index]]
            # Calculate colour and render
            ugcol = ugfx.WHITE
            if mode == FLAT:
                # Simple lighting calculation
                colour5 = int(face_normal_zs[index] * 31)
                colour6 = int(face_normal_zs[index] * 63)
                # Create a 5-6-5 grey
                ugcol = (colour5 << 11) | (colour6 << 5) | colour5
                # Render polygon
                ugfx.fill_polygon(0,0, poly, ugcol)
            # Always draw the wireframe in the same colour to fill gaps left by the
            # fill_polygon method
            ugfx.polygon(0,0, poly, ugcol)
            last_polygons.append(poly)

def vsync():
	None
    # while(tear.value() == 0):
    #     pass
    # while(tear.value()):
    #     pass

def calculateRotation(smoothing, accelerometer):
    # Keep a list of recent rotations to smooth things out
    global x_rotation
    global z_rotation
    # First, pop off the oldest rotation
    # if len(x_rotations) >= smoothing:
    #     x_rotations = x_rotations[1:]
    # if len(z_rotations) >= smoothing:
    #     z_rotations = z_rotations[1:]
    # Now append a new rotation
    pi_2 = math.pi / 2
    #x_rotations.append(-accelerometer['z'] * pi_2)
    #z_rotations.append(accelerometer['x'] * pi_2)
    # Calculate rotation matrix
    return createRotationMatrix(
        # this averaging isn't correct in the first <smoothing> frames, but who cares
        math.radians(x_rotation),
        math.radians(y_rotation),
        math.radians(z_rotation)
    )
print("Hello 3DSpin")

# Initialise hardware
ugfx.init()
ugfx.clear(ugfx.BLACK)
# imu=IMU()
# buttons.init()

# Enable tear detection for vsync
#  ugfx.enable_tear()
# tear = pyb.Pin("TEAR", pyb.Pin.IN)
#ugfx.set_tear_line(1)

print("Graphics initalised")

# Set up static rendering matrices
camera_transform = createCameraMatrix(0, 0, -5.0)
proj = createProjectionMatrix(45.0, 100.0, 0.1)
camera_projection = proj.mul(camera_transform)

print("Camera initalised")

# Get the list of available objects, and load the first one
obj_vertices = []
obj_faces = []
print("available objects: {}", listdir(app_path))
objects = [x for x in listdir(app_path) if (((".obj" in x) | (".dat" in x)) & (x[0] != "."))]
selected = 0
loadObject(objects[selected])

print("loaded object {}", objects[selected])

# Set up rotation tracking arrays
x_rotation = 0
z_rotation = 0
y_rotation = 0
# Smooth rotations over 5 frames
smoothing = 5

# Rendering modes
BACKFACECULL = 1
FLAT = 2
WIREFRAME = 3
# Start with backface culling mode
mode = BACKFACECULL

last_polygons = []
last_mode = WIREFRAME

# Main loop
run = True
while run:
    gc.collect()
    # Render the scene
    render(
        mode,
        calculateRotation(smoothing, None)
    )
    # Button presses
    y_rotation += 5
    x_rotation += 3
    z_rotation += 1
    if Buttons.is_pressed(Buttons.JOY_Left):
        y_rotation -= 5
    if Buttons.is_pressed(Buttons.JOY_Right):
        y_rotation += 5
    if Buttons.is_pressed(Buttons.JOY_Center):
        y_rotation = 0
    if Buttons.is_pressed(Buttons.BTN_B):
        selected += 1
        if selected >= len(objects):
            selected = 0
        loadObject(objects[selected])
        time.sleep_ms(500) # Wait a while to avoid skipping ahead if the user still has the button down
    if Buttons.is_pressed(Buttons.BTN_A):
        mode += 1
        if mode > 3:
            mode = 1
        time.sleep_ms(500) # Wait a while to avoid skipping ahead if the user still has the button down
    if Buttons.is_pressed(Buttons.BTN_Menu):
        run = False
        app.restart_to_default()
