--[[
  Very basic raytracer, passing results (i.e. hit "pixels") to a callback.

  Usage:
  local rt = require("raytracer").new()
  table.insert(rt.model, {0,0,0,16,16,16})
  --rt.camera.position = {-20,20,0}
  --rt.camera.target = {8,8,8}
  --rt.camera.fov = 100
  rt:render(width, height, function(hitX, hitY, box, normal)
    -- do stuff with the hit information, e.g. set pixel at hitX/hitY to boxes color
  end)

  Shapes must at least have their min/max coordinates given as the first six
  integer indexed entries of the table, as {minX,minY,minZ,maxX,maxY,maxZ}.
  The returned normal is a sequence with the x/y/z components of the normal.

  The camera can be configured as shown in the example above, i.e. it has a
  position, target and field of view (which is in degrees).

  MIT Licensed, Copyright Sangar 2015
]]
local M = {}

-- vector math stuffs

local function vadd(v1, v2)
  return {v1[1]+v2[1], v1[2]+v2[2], v1[3]+v2[3]}
end
local function vsub(v1, v2)
  return {v1[1]-v2[1], v1[2]-v2[2], v1[3]-v2[3]}
end
local function vmul(v1, v2)
  return {v1[1]*v2[1], v1[2]*v2[2], v1[3]*v2[3]}
end
local function vcross(v1, v2)
  return {v1[2]*v2[3]-v1[3]*v2[2], v1[3]*v2[1]-v1[1]*v2[3], v1[1]*v2[2]-v1[2]*v2[1]}
end
local function vmuls(v, s)
  return vmul(v, {s, s, s})
end
local function vdot(v1, v2)
  return v1[1]*v2[1] + v1[2]*v2[2] + v1[3]*v2[3]
end
local function vnorm(v)
  return vdot(v, v)
end
local function vlen(v)
  return math.sqrt(vnorm(v))
end
local function vnormalize(v)
  return vmuls(v, 1/vlen(v))
end

-- collision stuffs

-- http://tog.acm.org/resources/GraphicsGems/gems/RayBox.c
-- adjusted version also returning the surface normal
local function collideRayBox(box, origin, dir)
  local inside = true
  local quadrant = {0,0,0}
  local minB = {box[1],box[2],box[3]}
  local maxB = {box[4],box[5],box[6]}
  local maxT = {0,0,0}
  local candidatePlane = {0,0,0}
  local sign = 0

  -- Find candidate planes; this loop can be avoided if
  -- rays cast all from the eye(assume perpsective view)
  for i=1,3 do
    if origin[i] < minB[i] then
      quadrant[i] = true
      candidatePlane[i] = minB[i]
      inside = false
      sign = -1
    elseif origin[i] > maxB[i] then
      quadrant[i] = true
      candidatePlane[i] = maxB[i]
      inside = false
      sign = 1
    else
      quadrant[i] = false
    end
  end

  -- Ray origin inside bounding box
  if inside then
    return nil
  end

  -- Calculate T distances to candidate planes
  for i=1,3 do
    if quadrant[i] and dir[i] ~= 0 then
      maxT[i] = (candidatePlane[i] - origin[i]) / dir[i]
    else
      maxT[i] = -1
    end
  end

  -- Get largest of the maxT's for final choice of intersection
  local whichPlane = 1
  for i=2,3 do
    if maxT[whichPlane] < maxT[i] then
      whichPlane = i
    end
  end

  -- Check final candidate actually inside box
  if maxT[whichPlane] < 0 then return nil end
  local coord,normal = {0,0,0},{0,0,0}
  for i=1,3 do
    if whichPlane ~= i then
      coord[i] = origin[i] + maxT[whichPlane] * dir[i]
      if coord[i] < minB[i] or coord[i] > maxB[i] then
        return nil
      end
    else
      coord[i] = candidatePlane[i]
      normal[i] = sign
    end
  end

  return coord, normal -- ray hits box
end

local function trace(model, origin, dir)
  local bestBox, bestNormal, bestDist = nil, nil, math.huge
  for _, box in ipairs(model) do
    local hit, normal = collideRayBox(box, origin, dir)
    if hit then
      local dist = vlen(vsub(hit, origin))
      if dist < bestDist then
        bestBox = box
        bestNormal = normal
        bestDist = dist
      end
    end
  end
  return bestBox, bestNormal
end

-- public api

function M.new()
  return setmetatable({model={},camera={position={-1,1,-1},target={0,0,0},fov=90}}, {__index=M})
end

function M:render(w, h, f)
  if #self.model < 1 then return end
  -- overall model bounds, for quick empty space skipping
  local bounds = {self.model[1][1],self.model[1][2],self.model[1][3],self.model[1][4],self.model[1][5],self.model[1][6]}
  for _, shape in ipairs(self.model) do
    bounds[1] = math.min(bounds[1], shape[1])
    bounds[2] = math.min(bounds[2], shape[2])
    bounds[3] = math.min(bounds[3], shape[3])
    bounds[4] = math.max(bounds[4], shape[4])
    bounds[5] = math.max(bounds[5], shape[5])
    bounds[6] = math.max(bounds[6], shape[6])
  end
  bounds = {bounds}
  -- setup framework for ray generation
  local origin = self.camera.position
  local forward = vnormalize(vsub(self.camera.target, origin))
  local plane = vadd(origin, forward)
  local side = vcross(forward, {0,1,0})
  local up = vcross(forward, side)
  local lside = math.tan(self.camera.fov/2/180*math.pi)
  -- generate ray for each pixel, left-to-right, top-to-bottom
  local blanks = 0
  for sy = 1, h do
    local ry = (sy/h - 0.5)*lside
    local py = vadd(plane, vmuls(up, ry))
    for sx = 1, w do
      local rx = (sx/w - 0.5)*lside
      local px = vadd(py, vmuls(side, rx))
      local dir = vnormalize(vsub(px, origin))
      if trace(bounds, origin, dir) then
        local box, normal = trace(self.model, origin, dir)
        if box then
          blanks = 0
          if f(sx, sy, box, normal) == false then
            return
          end
        else
          blanks = blanks + 1
        end
      else
        blanks = blanks + 1
      end
      if blanks > 50 then
        blanks = 0
        os.sleep(0) -- avoid too long without yielding
      end
    end
  end
end

return M