#!/usr/bin/env python
# coding: iso-8859-1

# PYTHON IMPLEMENTATION FILE ______________________________________________________________________
#									vim:textwidth=100:tabstop=8
# *           project : Python raytracer
# *              file : raytracer.py
# *           version : 1.0
# *            author : Jakob Schmid
# *         commenced : 09/01/04
# *      last changes : 09/01/04
# *             notes : this simple raytracer is a Python implementation of the SDL raytracer 
#                       described in the POV-ray documentation. I made the 1.0 in a single night.
#                       (so tired, must sleep now...)
# *              TODO : split up in modules, more readable math code, more math comments, 
#                       more features, port it to c++ for X output (allegro) and PNG output
# *        references : 
# *       text format : this file is best viewed with linewidth 100 and tabsize 8 
#			- use a small monospace font for printing, e.g. Courier 9 (bold)
# _________________________________________________________________________________________________

from math import *
import sys

class Vector:
    def __init__(self, x=0, y=0, z=0):
	self.set(x, y, z)
    def __str__(self):
	return "(%.2f,%.2f,%.2f)" % (self.x, self.y, self.z)

    def set(self, x, y, z):
	self.x = x
	self.y = y
	self.z = z

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

    def normalized(self):
        return self.mul(1/self.length())

    def __add__(self, other):
	return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
    def __sub__(self, other):
	return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
    def dot(self, p): # dot-product
	return self.x * p.x + self.y * p.y + self.z * p.z
    def mul(self, scalar): # scalar multiplication
        return Vector(self.x * scalar, self.y * scalar, self.z * scalar)

class Sphere:
    def __init__(self, position, radius):
	self.position = position
	self.radius = radius
    def __str__(self):
	return "Sphere at " + str(self.position) + " radius %.2f" % self.radius

    def inside(self, p):
	if sqrt(p.x**2 + p.y**2 + p.z**2) <= self.radius: return True
	else: return False

    # ray_start : ray starting vector
    # ray_dir   : ray direction vector
    # returns the factor to multiply ray_dir with to get from ray_start to the closest
    # intersection between the sphere object and the ray or -1 if there is no intersection point
    
    # suggested notation:
    # capitals for vectors, non-capitals for scalars
    # RAY_dir
    def get_closest_intersection_distance(SPHERE, RAY_start, RAY_dir):

        # if the ray and the SPHERE intersect, we have:
        # |RAY_start + t * RAY_dir - SPHERE_center| = SPHERE_radius
        #
        # if we set V = RAY_start - SPHERE_center
	V = RAY_start - SPHERE.position

        # then we have:
        # |t * RAY_dir + V| = SPHERE_radius
        #
        # which is expanded to:
        # (t * RAY_dir.x + V.x)^2 + (t * RAY_dir.y + V.y)^2 + (t * RAY_dir.z + V.z)^2 
        #                                                                       = SPHERE_radius
        # which is solved to:
        # t = (-RAY_dir·V ± sqrt((RAY_dir·V)^2 - RAY_dir^2(V^2-SPHERE_radius^2)))/RAY_dir^2
        #
        # where '·' is dot product and RAY_dir^2 = RAY_dir · RAY_dir
	rdv = RAY_dir.dot(V)
	rd2 = RAY_dir.dot(RAY_dir)

        # if the discriminant (RAY_dir·V)^2 - RAY_dir^2(V^2-SPHERE_radius^2) is < 0, there is no
        # solutions and therefore the ray does not intersect the sphere
	discriminant = rdv*rdv - rd2 * (V.dot(V) - SPHERE.radius**2)
	if discriminant < 0: result = -1

        # otherwise, there is 1 or 2 solutions, here denoted t1 and t2
	else:
	    sqd = sqrt(discriminant)
	    t1 = (-rdv + sqd) / rd2
	    t2 = (-rdv - sqd) / rd2
	    result = min(t1, t2) # select closest intersection (front side of sphere)
	return result

class Light:
    def __init__(self, position):
	self.position = position
    def __str__(self):
	return "Light at " + str(self.position)

class Camera:
    def __init__(self, position, facing):
	self.position = position
	self.facing = facing
	self.projection_plane_distance = 1
    def __str__(self):
	return "Camera at " + str(self.position) + " facing " + str(self.facing)

class Scene:
    def __init__(self):
	self.objects = []
	self.image = []

    def add(self, obj):
	self.objects.append(obj)
	self.debug_msg("added object: " + str(obj))

    def render(self, width, height):
        # create space for image FIXME: broken way of doing that
        self.image = [range(width) for v in range(height)]

	# check for camera
	ok = False
	for o in self.objects:
	    if isinstance(o, Camera):
		ok = True
		camera = o
		break
	if not ok: return False

	# trace ray for each pixel in rendered image
	for image_y in range(height):
            # y is scaled from [0;height-1] to [-1;1]
            y = float(image_y) / (height-1) * 2 - 1

	    for image_x in range(width):
                # image_x is scaled, keeping aspect
                x = (float(image_x) / (width-1) -.5 ) * 2. * width / height;

                self.image[image_y][image_x] = self.trace(camera.position, Vector(x, y, 3), 1)
                # FIXME: the cameras 'facing' attribute does nothing right now
            self.show(image_y)
	return True

    # ray_start : starting point of ray
    # ray_dir   : direction of ray
    def trace(self, ray_start, ray_dir, reflection):

        max_reflections = 5 # FIXME: set as scene attribute

	# FIND CLOSEST INTERSECTION _______________________________________________________________
        shortest = 1000.0   # max distance
        closest = None
        for o in self.objects:
            if isinstance(o, Sphere):
                dist = o.get_closest_intersection_distance(ray_start, ray_dir)
                if dist > 0 and dist < shortest:
                    shortest = dist
                    closest = o

	# if the ray hit nothing, black
        if closest == None: pixel = 0

	# CALCULATE THE PIXEL COLOR _______________________________________________________________
        else:
        # initialize
            IP = ray_start + ray_dir.mul(shortest) # intersection point
            # normal vector at intersection point - normalized (length 1)
            normal = (IP - closest.position).mul(1/closest.radius)
            # 'mirror' vector ray_start-IP with respect to 'normal'
            V = ray_start - IP
            refl = normal.mul(2).mul(normal.dot(V)) - V
        
            pixel = .2 # ambient light FIXME: set as scene attribute

            # shadow test
            shadowed = False
            for l in self.objects:
                if isinstance(l, Light): # for all lights
                    for o in self.objects:
                        if isinstance(o, Sphere) and o!=closest:
                            if o.get_closest_intersection_distance(IP, l.position) > 0:
                                shadowed = True
            
                    if not shadowed:
                        # diffuse lighting
                        factor = normal.dot(l.position.normalized())
                        # 1.0 = color  1.0 = light color  FIXME: read them from the objects
                        if factor > 0: pixel += factor * 1.0 * 1.0 

                        # specular lighting
                        factor = refl.normalized().dot(l.position)
                        # light col = 1.0, phong size = 2, phong amount = .001 FIXME: read etc...
                        if factor > 0: pixel += 1.0 * pow ( factor, 2 ) * .001

            # reflection calculation (True: does closest object reflect?)
            if reflection < max_reflections and True:
                # 1.0 = reflection level of closest object FIXME: read etc...
                pixel += self.trace(IP, refl, reflection + 1) * 1.0;
        return pixel


    def show(self, line):
	asciiart = " ,.;:x*2OD#@"
        linestr = ""
        colors = len(asciiart)
        for pixel in self.image[line]:
            pixel = min(1.4, pixel) # overlight
            linestr += asciiart[int(pixel/1.5*colors)]
            # TODO: could be smarter...
        print linestr

    def debug_msg(self, msg):
	print msg

# TEST ____________________________________________________________________________________________
if      len(sys.argv) == 1: width, height = 70, 24
elif    len(sys.argv) == 3: width, height = int(sys.argv[1]), int(sys.argv[2])
else:
    print "usage: raytracer.py [width height]"
    sys.exit(1)

scene = Scene()

scene.add(Sphere(Vector(-.7, -.02, -.2), .7))
scene.add(Sphere(Vector( .7, .02, .2), .7))
scene.add(Sphere(Vector( -.9, -.4, -.9), .3))

scene.add(Camera(Vector(0, -0.1, -3), Vector(0, 0, 0)))
scene.add(Light (Vector(0, -4, -3)))

scene.render(width, height)
