/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";

const {Cu, Ci, ChromeWorker} = require("chrome");

let TiltGL = require("devtools/tilt/tilt-gl");
let TiltUtils = require("devtools/tilt/tilt-utils");
let TiltVisualizerStyle = require("devtools/tilt/tilt-visualizer-style");
let {EPSILON, TiltMath, vec3, mat4, quat4} = require("devtools/tilt/tilt-math");
let {TargetFactory} = require("devtools/framework/target");

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource:///modules/devtools/gDevTools.jsm");

const ELEMENT_MIN_SIZE = 4;
const INVISIBLE_ELEMENTS = {
  "head": true,
  "base": true,
  "basefont": true,
  "isindex": true,
  "link": true,
  "meta": true,
  "option": true,
  "script": true,
  "style": true,
  "title": true
};

// a node is represented in the visualization mesh as a rectangular stack
// of 5 quads composed of 12 vertices; we draw these as triangles using an
// index buffer of 12 unsigned int elements, obviously one for each vertex;
// if a webpage has enough nodes to overflow the index buffer elements size,
// weird things may happen; thus, when necessary, we'll split into groups
const MAX_GROUP_NODES = Math.pow(2, Uint16Array.BYTES_PER_ELEMENT * 8) / 12 - 1;

const WIREFRAME_COLOR = [0, 0, 0, 0.25];
const INTRO_TRANSITION_DURATION = 1000;
const OUTRO_TRANSITION_DURATION = 800;
const INITIAL_Z_TRANSLATION = 400;
const MOVE_INTO_VIEW_ACCURACY = 50;

const MOUSE_CLICK_THRESHOLD = 10;
const MOUSE_INTRO_DELAY = 200;
const ARCBALL_SENSITIVITY = 0.5;
const ARCBALL_ROTATION_STEP = 0.15;
const ARCBALL_TRANSLATION_STEP = 35;
const ARCBALL_ZOOM_STEP = 0.1;
const ARCBALL_ZOOM_MIN = -3000;
const ARCBALL_ZOOM_MAX = 500;
const ARCBALL_RESET_SPHERICAL_FACTOR = 0.1;
const ARCBALL_RESET_LINEAR_FACTOR = 0.01;

const TILT_CRAFTER = "resource:///modules/devtools/tilt/TiltWorkerCrafter.js";
const TILT_PICKER = "resource:///modules/devtools/tilt/TiltWorkerPicker.js";


/**
 * Initializes the visualization presenter and controller.
 *
 * @param {Object} aProperties
 *                 an object containing the following properties:
 *        {Window} chromeWindow: a reference to the top level window
 *        {Window} contentWindow: the content window holding the visualized doc
 *       {Element} parentNode: the parent node to hold the visualization
 *        {Object} notifications: necessary notifications for Tilt
 *      {Function} onError: optional, function called if initialization failed
 *      {Function} onLoad: optional, function called if initialization worked
 */
function TiltVisualizer(aProperties)
{
  // make sure the properties parameter is a valid object
  aProperties = aProperties || {};

  /**
   * Save a reference to the top-level window.
   */
  this.chromeWindow = aProperties.chromeWindow;
  this.tab = aProperties.tab;

  /**
   * The canvas element used for rendering the visualization.
   */
  this.canvas = TiltUtils.DOM.initCanvas(aProperties.parentNode, {
    focusable: true,
    append: true
  });

  /**
   * Visualization logic and drawing loop.
   */
  this.presenter = new TiltVisualizer.Presenter(this.canvas,
    aProperties.chromeWindow,
    aProperties.contentWindow,
    aProperties.notifications,
    aProperties.onError || null,
    aProperties.onLoad || null);

  /**
   * Visualization mouse and keyboard controller.
   */
  this.controller = new TiltVisualizer.Controller(this.canvas, this.presenter);
}

exports.TiltVisualizer = TiltVisualizer;

TiltVisualizer.prototype = {

  /**
   * Initializes the visualizer.
   */
  init: function TV_init()
  {
    this.presenter.init();
    this.bindToInspector(this.tab);
  },

  /**
   * Checks if this object was initialized properly.
   *
   * @return {Boolean} true if the object was initialized properly
   */
  isInitialized: function TV_isInitialized()
  {
    return this.presenter && this.presenter.isInitialized() &&
           this.controller && this.controller.isInitialized();
  },

  /**
   * Removes the overlay canvas used for rendering the visualization.
   */
  removeOverlay: function TV_removeOverlay()
  {
    if (this.canvas && this.canvas.parentNode) {
      this.canvas.parentNode.removeChild(this.canvas);
    }
  },

  /**
   * Explicitly cleans up this visualizer and sets everything to null.
   */
  cleanup: function TV_cleanup()
  {
    this.unbindInspector();

    if (this.controller) {
      TiltUtils.destroyObject(this.controller);
    }
    if (this.presenter) {
      TiltUtils.destroyObject(this.presenter);
    }

    let chromeWindow = this.chromeWindow;

    TiltUtils.destroyObject(this);
    TiltUtils.clearCache();
    TiltUtils.gc(chromeWindow);
  },

  /**
   * Listen to the inspector activity.
   */
  bindToInspector: function TV_bindToInspector(aTab)
  {
    this._browserTab = aTab;

    this.onNewNodeFromInspector = this.onNewNodeFromInspector.bind(this);
    this.onNewNodeFromTilt = this.onNewNodeFromTilt.bind(this);
    this.onInspectorReady = this.onInspectorReady.bind(this);
    this.onToolboxDestroyed = this.onToolboxDestroyed.bind(this);

    gDevTools.on("inspector-ready", this.onInspectorReady);
    gDevTools.on("toolbox-destroyed", this.onToolboxDestroyed);

    Services.obs.addObserver(this.onNewNodeFromTilt,
                             this.presenter.NOTIFICATIONS.HIGHLIGHTING,
                             false);
    Services.obs.addObserver(this.onNewNodeFromTilt,
                             this.presenter.NOTIFICATIONS.UNHIGHLIGHTING,
                             false);

    let target = TargetFactory.forTab(aTab);
    let toolbox = gDevTools.getToolbox(target);
    if (toolbox) {
      let panel = toolbox.getPanel("inspector");
      if (panel) {
        this.inspector = panel;
        this.inspector.selection.on("new-node", this.onNewNodeFromInspector);
        this.onNewNodeFromInspector();
      }
    }
  },

  /**
   * Unregister inspector event listeners.
   */
  unbindInspector: function TV_unbindInspector()
  {
    this._browserTab = null;

    if (this.inspector) {
      if (this.inspector.selection) {
        this.inspector.selection.off("new-node", this.onNewNodeFromInspector);
      }
      this.inspector = null;
    }

    gDevTools.off("inspector-ready", this.onInspectorReady);
    gDevTools.off("toolbox-destroyed", this.onToolboxDestroyed);

    Services.obs.removeObserver(this.onNewNodeFromTilt,
                                this.presenter.NOTIFICATIONS.HIGHLIGHTING);
    Services.obs.removeObserver(this.onNewNodeFromTilt,
                                this.presenter.NOTIFICATIONS.UNHIGHLIGHTING);
  },

  /**
   * When a new inspector is started.
   */
  onInspectorReady: function TV_onInspectorReady(event, toolbox, panel)
  {
    if (toolbox.target.tab === this._browserTab) {
      this.inspector = panel;
      this.inspector.selection.on("new-node", this.onNewNodeFromInspector);
      this.onNewNodeFromTilt();
    }
  },

  /**
   * When the toolbox, therefor the inspector, is closed.
   */
  onToolboxDestroyed: function TV_onToolboxDestroyed(event, tab)
  {
    if (tab === this._browserTab &&
        this.inspector) {
      if (this.inspector.selection) {
        this.inspector.selection.off("new-node", this.onNewNodeFromInspector);
      }
      this.inspector = null;
    }
  },

  /**
   * When a new node is selected in the inspector.
   */
  onNewNodeFromInspector: function TV_onNewNodeFromInspector()
  {
    if (this.inspector &&
        this.inspector.selection.reason != "tilt") {
      let selection = this.inspector.selection;
      let canHighlightNode = selection.isNode() &&
                              selection.isConnected() &&
                              selection.isElementNode();
      if (canHighlightNode) {
        this.presenter.highlightNode(selection.node);
      } else {
        this.presenter.highlightNodeFor(-1);
      }
    }
  },

  /**
   * When a new node is selected in Tilt.
   */
  onNewNodeFromTilt: function TV_onNewNodeFromTilt()
  {
    if (!this.inspector) {
      return;
    }
    let nodeIndex = this.presenter._currentSelection;
    if (nodeIndex < 0) {
      this.inspector.selection.setNode(null, "tilt");
    }
    let node = this.presenter._traverseData.nodes[nodeIndex];
    this.inspector.selection.setNode(node, "tilt");
  },
};

/**
 * This object manages the visualization logic and drawing loop.
 *
 * @param {HTMLCanvasElement} aCanvas
 *                            the canvas element used for rendering
 * @param {Window} aChromeWindow
 *                 a reference to the top-level window
 * @param {Window} aContentWindow
 *                 the content window holding the document to be visualized
 * @param {Object} aNotifications
 *                 necessary notifications for Tilt
 * @param {Function} onError
 *                   function called if initialization failed
 * @param {Function} onLoad
 *                   function called if initialization worked
 */
TiltVisualizer.Presenter = function TV_Presenter(
  aCanvas, aChromeWindow, aContentWindow, aNotifications, onError, onLoad)
{
  /**
   * A canvas overlay used for drawing the visualization.
   */
  this.canvas = aCanvas;

  /**
   * Save a reference to the top-level window, to access Tilt.
   */
  this.chromeWindow = aChromeWindow;

  /**
   * The content window generating the visualization
   */
  this.contentWindow = aContentWindow;

  /**
   * Shortcut for accessing notifications strings.
   */
  this.NOTIFICATIONS = aNotifications;

  /**
   * Use the default node callback function
   */
  this.nodeCallback = null;

  /**
   * Create the renderer, containing useful functions for easy drawing.
   */
  this._renderer = new TiltGL.Renderer(aCanvas, onError, onLoad);

  /**
   * A custom shader used for drawing the visualization mesh.
   */
  this._visualizationProgram = null;

  /**
   * The combined mesh representing the document visualization.
   */
  this._texture = null;
  this._meshData = null;
  this._meshStacks = null;
  this._meshWireframe = null;
  this._traverseData = null;

  /**
   * A highlight quad drawn over a stacked dom node.
   */
  this._highlight = {
    disabled: true,
    v0: vec3.create(),
    v1: vec3.create(),
    v2: vec3.create(),
    v3: vec3.create()
  };

  /**
   * Scene transformations, exposing offset, translation and rotation.
   * Modified by events in the controller through delegate functions.
   */
  this.transforms = {
    zoom: 1,
    offset: vec3.create(),      // mesh offset, aligned to the viewport center
    translation: vec3.create(), // scene translation, on the [x, y, z] axis
    rotation: quat4.create()    // scene rotation, expressed as a quaternion
  };

  /**
   * Variables holding information about the initial and current node selected.
   */
  this._currentSelection = -1; // the selected node index
  this._initialMeshConfiguration = false; // true if the 3D mesh was configured

  /**
   * Variable specifying if the scene should be redrawn.
   * This should happen usually when the visualization is translated/rotated.
   */
  this._redraw = true;

  /**
   * Total time passed since the rendering started.
   * If the rendering is paused, this property won't get updated.
   */
  this._time = 0;

  /**
   * Frame delta time (the ammount of time passed for each frame).
   * This is used to smoothly interpolate animation transfroms.
   */
  this._delta = 0;
  this._prevFrameTime = 0;
  this._currFrameTime = 0;
};

TiltVisualizer.Presenter.prototype = {

  /**
   * Initializes the presenter and starts the animation loop
   */
  init: function TVP_init()
  {
    this._setup();
    this._loop();
  },

  /**
   * The initialization logic.
   */
  _setup: function TVP__setup()
  {
    let renderer = this._renderer;

    // if the renderer was destroyed, don't continue setup
    if (!renderer || !renderer.context) {
      return;
    }

    // create the visualization shaders and program to draw the stacks mesh
    this._visualizationProgram = new renderer.Program({
      vs: TiltVisualizer.MeshShader.vs,
      fs: TiltVisualizer.MeshShader.fs,
      attributes: ["vertexPosition", "vertexTexCoord", "vertexColor"],
      uniforms: ["mvMatrix", "projMatrix", "sampler"]
    });

    // get the document zoom to properly scale the visualization
    this.transforms.zoom = this._getPageZoom();

    // bind the owner object to the necessary functions
    TiltUtils.bindObjectFunc(this, "^_on");
    TiltUtils.bindObjectFunc(this, "_loop");

    this._setupTexture();
    this._setupMeshData();
    this._setupEventListeners();
    this.canvas.focus();
  },

  /**
   * Get page zoom factor.
   * @return {Number}
   */
  _getPageZoom: function TVP__getPageZoom() {
    return this.contentWindow
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIDOMWindowUtils)
      .fullZoom;
  },

  /**
   * The animation logic.
   */
  _loop: function TVP__loop()
  {
    let renderer = this._renderer;

    // if the renderer was destroyed, don't continue rendering
    if (!renderer || !renderer.context) {
      return;
    }

    // prepare for the next frame of the animation loop
    this.chromeWindow.mozRequestAnimationFrame(this._loop);

    // only redraw if we really have to
    if (this._redraw) {
      this._redraw = false;
      this._drawVisualization();
    }

    // update the current presenter transfroms from the controller
    if ("function" === typeof this._controllerUpdate) {
      this._controllerUpdate(this._time, this._delta);
    }

    this._handleFrameDelta();
    this._handleKeyframeNotifications();
  },

  /**
   * Calculates the current frame delta time.
   */
  _handleFrameDelta: function TVP__handleFrameDelta()
  {
    this._prevFrameTime = this._currFrameTime;
    this._currFrameTime = this.chromeWindow.mozAnimationStartTime;
    this._delta = this._currFrameTime - this._prevFrameTime;
  },

  /**
   * Draws the visualization mesh and highlight quad.
   */
  _drawVisualization: function TVP__drawVisualization()
  {
    let renderer = this._renderer;
    let transforms = this.transforms;
    let w = renderer.width;
    let h = renderer.height;
    let ih = renderer.initialHeight;

    // if the mesh wasn't created yet, don't continue rendering
    if (!this._meshStacks || !this._meshWireframe) {
      return;
    }

    // clear the context to an opaque black background
    renderer.clear();
    renderer.perspective();

    // apply a transition transformation using an ortho and perspective matrix
    let ortho = mat4.ortho(0, w, h, 0, -1000, 1000);

    if (!this._isExecutingDestruction) {
      let f = this._time / INTRO_TRANSITION_DURATION;
      renderer.lerp(renderer.projMatrix, ortho, f, 8);
    } else {
      let f = this._time / OUTRO_TRANSITION_DURATION;
      renderer.lerp(renderer.projMatrix, ortho, 1 - f, 8);
    }

    // apply the preliminary transformations to the model view
    renderer.translate(w * 0.5, ih * 0.5, -INITIAL_Z_TRANSLATION);

    // calculate the camera matrix using the rotation and translation
    renderer.translate(transforms.translation[0], 0,
                       transforms.translation[2]);

    renderer.transform(quat4.toMat4(transforms.rotation));

    // offset the visualization mesh to center
    renderer.translate(transforms.offset[0],
                       transforms.offset[1] + transforms.translation[1], 0);

    renderer.scale(transforms.zoom, transforms.zoom);

    // draw the visualization mesh
    renderer.strokeWeight(2);
    renderer.depthTest(true);
    this._drawMeshStacks();
    this._drawMeshWireframe();
    this._drawHighlight();

    // make sure the initial transition is drawn until finished
    if (this._time < INTRO_TRANSITION_DURATION ||
        this._time < OUTRO_TRANSITION_DURATION) {
      this._redraw = true;
    }
    this._time += this._delta;
  },

  /**
   * Draws the meshStacks object.
   */
  _drawMeshStacks: function TVP__drawMeshStacks()
  {
    let renderer = this._renderer;
    let mesh = this._meshStacks;

    let visualizationProgram = this._visualizationProgram;
    let texture = this._texture;
    let mvMatrix = renderer.mvMatrix;
    let projMatrix = renderer.projMatrix;

    // use the necessary shader
    visualizationProgram.use();

    for (let i = 0, len = mesh.length; i < len; i++) {
      let group = mesh[i];

      // bind the attributes and uniforms as necessary
      visualizationProgram.bindVertexBuffer("vertexPosition", group.vertices);
      visualizationProgram.bindVertexBuffer("vertexTexCoord", group.texCoord);
      visualizationProgram.bindVertexBuffer("vertexColor", group.color);

      visualizationProgram.bindUniformMatrix("mvMatrix", mvMatrix);
      visualizationProgram.bindUniformMatrix("projMatrix", projMatrix);
      visualizationProgram.bindTexture("sampler", texture);

      // draw the vertices as TRIANGLES indexed elements
      renderer.drawIndexedVertices(renderer.context.TRIANGLES, group.indices);
    }

    // save the current model view and projection matrices
    mesh.mvMatrix = mat4.create(mvMatrix);
    mesh.projMatrix = mat4.create(projMatrix);
  },

  /**
   * Draws the meshWireframe object.
   */
  _drawMeshWireframe: function TVP__drawMeshWireframe()
  {
    let renderer = this._renderer;
    let mesh = this._meshWireframe;

    for (let i = 0, len = mesh.length; i < len; i++) {
      let group = mesh[i];

      // use the necessary shader
      renderer.useColorShader(group.vertices, WIREFRAME_COLOR);

      // draw the vertices as LINES indexed elements
      renderer.drawIndexedVertices(renderer.context.LINES, group.indices);
    }
  },

  /**
   * Draws a highlighted quad around a currently selected node.
   */
  _drawHighlight: function TVP__drawHighlight()
  {
    // check if there's anything to highlight (i.e any node is selected)
    if (!this._highlight.disabled) {

      // set the corresponding state to draw the highlight quad
      let renderer = this._renderer;
      let highlight = this._highlight;

      renderer.depthTest(false);
      renderer.fill(highlight.fill, 0.5);
      renderer.stroke(highlight.stroke);
      renderer.strokeWeight(highlight.strokeWeight);
      renderer.quad(highlight.v0, highlight.v1, highlight.v2, highlight.v3);
    }
  },

  /**
   * Creates or refreshes the texture applied to the visualization mesh.
   */
  _setupTexture: function TVP__setupTexture()
  {
    let renderer = this._renderer;

    // destroy any previously created texture
    TiltUtils.destroyObject(this._texture); this._texture = null;

    // if the renderer was destroyed, don't continue setup
    if (!renderer || !renderer.context) {
      return;
    }

    // get the maximum texture size
    this._maxTextureSize =
      renderer.context.getParameter(renderer.context.MAX_TEXTURE_SIZE);

    // use a simple shim to get the image representation of the document
    // this will be removed once the MOZ_window_region_texture bug #653656
    // is finished; currently just converting the document image to a texture
    // applied to the mesh
    this._texture = new renderer.Texture({
      source: TiltGL.TextureUtils.createContentImage(this.contentWindow,
                                                     this._maxTextureSize),
      format: "RGB"
    });

    if ("function" === typeof this._onSetupTexture) {
      this._onSetupTexture();
      this._onSetupTexture = null;
    }
  },

  /**
   * Create the combined mesh representing the document visualization by
   * traversing the document & adding a stack for each node that is drawable.
   *
   * @param {Object} aMeshData
   *                 object containing the necessary mesh verts, texcoord etc.
   */
  _setupMesh: function TVP__setupMesh(aMeshData)
  {
    let renderer = this._renderer;

    // destroy any previously created mesh
    TiltUtils.destroyObject(this._meshStacks); this._meshStacks = [];
    TiltUtils.destroyObject(this._meshWireframe); this._meshWireframe = [];

    // if the renderer was destroyed, don't continue setup
    if (!renderer || !renderer.context) {
      return;
    }

    // save the mesh data for future use
    this._meshData = aMeshData;

    // create a sub-mesh for each group in the mesh data
    for (let i = 0, len = aMeshData.groups.length; i < len; i++) {
      let group = aMeshData.groups[i];

      // create the visualization mesh using the vertices, texture coordinates
      // and indices computed when traversing the document object model
      this._meshStacks.push({
        vertices: new renderer.VertexBuffer(group.vertices, 3),
        texCoord: new renderer.VertexBuffer(group.texCoord, 2),
        color: new renderer.VertexBuffer(group.color, 3),
        indices: new renderer.IndexBuffer(group.stacksIndices)
      });

      // additionally, create a wireframe representation to make the
      // visualization a bit more pretty
      this._meshWireframe.push({
        vertices: this._meshStacks[i].vertices,
        indices: new renderer.IndexBuffer(group.wireframeIndices)
      });
    }

    // configure the required mesh transformations and background only once
    if (!this._initialMeshConfiguration) {
      this._initialMeshConfiguration = true;

      // set the necessary mesh offsets
      this.transforms.offset[0] = -renderer.width * 0.5;
      this.transforms.offset[1] = -renderer.height * 0.5;

      // make sure the canvas is opaque now that the initialization is finished
      this.canvas.style.background = TiltVisualizerStyle.canvas.background;

      this._drawVisualization();
      this._redraw = true;
    }

    if ("function" === typeof this._onSetupMesh) {
      this._onSetupMesh();
      this._onSetupMesh = null;
    }
  },

  /**
   * Computes the mesh vertices, texture coordinates etc. by groups of nodes.
   */
  _setupMeshData: function TVP__setupMeshData()
  {
    let renderer = this._renderer;

    // if the renderer was destroyed, don't continue setup
    if (!renderer || !renderer.context) {
      return;
    }

    // traverse the document and get the depths, coordinates and local names
    this._traverseData = TiltUtils.DOM.traverse(this.contentWindow, {
      nodeCallback: this.nodeCallback,
      invisibleElements: INVISIBLE_ELEMENTS,
      minSize: ELEMENT_MIN_SIZE,
      maxX: this._texture.width,
      maxY: this._texture.height
    });

    let worker = new ChromeWorker(TILT_CRAFTER);

    worker.addEventListener("message", function TVP_onMessage(event) {
      this._setupMesh(event.data);
    }.bind(this), false);

    // calculate necessary information regarding vertices, texture coordinates
    // etc. in a separate thread, as this process may take a while
    worker.postMessage({
      maxGroupNodes: MAX_GROUP_NODES,
      style: TiltVisualizerStyle.nodes,
      texWidth: this._texture.width,
      texHeight: this._texture.height,
      nodesInfo: this._traverseData.info
    });
  },

  /**
   * Sets up event listeners necessary for the presenter.
   */
  _setupEventListeners: function TVP__setupEventListeners()
  {
    this.contentWindow.addEventListener("resize", this._onResize, false);
  },

  /**
   * Called when the content window of the current browser is resized.
   */
  _onResize: function TVP_onResize(e)
  {
    let zoom = this._getPageZoom();
    let width = e.target.innerWidth * zoom;
    let height = e.target.innerHeight * zoom;

    // handle aspect ratio changes to update the projection matrix
    this._renderer.width = width;
    this._renderer.height = height;

    this._redraw = true;
  },

  /**
   * Highlights a specific node.
   *
   * @param {Element} aNode
   *                  the html node to be highlighted
   * @param {String} aFlags
   *                 flags specifying highlighting options
   */
  highlightNode: function TVP_highlightNode(aNode, aFlags)
  {
    this.highlightNodeFor(this._traverseData.nodes.indexOf(aNode), aFlags);
  },

  /**
   * Picks a stacked dom node at the x and y screen coordinates and highlights
   * the selected node in the mesh.
   *
   * @param {Number} x
   *                 the current horizontal coordinate of the mouse
   * @param {Number} y
   *                 the current vertical coordinate of the mouse
   * @param {Object} aProperties
   *                 an object containing the following properties:
   *      {Function} onpick: function to be called after picking succeeded
   *      {Function} onfail: function to be called after picking failed
   */
  highlightNodeAt: function TVP_highlightNodeAt(x, y, aProperties)
  {
    // make sure the properties parameter is a valid object
    aProperties = aProperties || {};

    // try to pick a mesh node using the current x, y coordinates
    this.pickNode(x, y, {

      /**
       * Mesh picking failed (nothing was found for the picked point).
       */
      onfail: function TVP_onHighlightFail()
      {
        this.highlightNodeFor(-1);

        if ("function" === typeof aProperties.onfail) {
          aProperties.onfail();
        }
      }.bind(this),

      /**
       * Mesh picking succeeded.
       *
       * @param {Object} aIntersection
       *                 object containing the intersection details
       */
      onpick: function TVP_onHighlightPick(aIntersection)
      {
        this.highlightNodeFor(aIntersection.index);

        if ("function" === typeof aProperties.onpick) {
          aProperties.onpick();
        }
      }.bind(this)
    });
  },

  /**
   * Sets the corresponding highlight coordinates and color based on the
   * information supplied.
   *
   * @param {Number} aNodeIndex
   *                 the index of the node in the this._traverseData array
   * @param {String} aFlags
   *                 flags specifying highlighting options
   */
  highlightNodeFor: function TVP_highlightNodeFor(aNodeIndex, aFlags)
  {
    this._redraw = true;

    // if the node was already selected, don't do anything
    if (this._currentSelection === aNodeIndex) {
      return;
    }

    // if an invalid or nonexisted node is specified, disable the highlight
    if (aNodeIndex < 0) {
      this._currentSelection = -1;
      this._highlight.disabled = true;

      Services.obs.notifyObservers(this.contentWindow, this.NOTIFICATIONS.UNHIGHLIGHTING, null);
      return;
    }

    let highlight = this._highlight;
    let info = this._traverseData.info[aNodeIndex];
    let style = TiltVisualizerStyle.nodes;

    highlight.disabled = false;
    highlight.fill = style[info.name] || style.highlight.defaultFill;
    highlight.stroke = style.highlight.defaultStroke;
    highlight.strokeWeight = style.highlight.defaultStrokeWeight;

    let x = info.coord.left;
    let y = info.coord.top;
    let w = info.coord.width;
    let h = info.coord.height;
    let z = info.coord.depth + info.coord.thickness;

    vec3.set([x,     y,     z], highlight.v0);
    vec3.set([x + w, y,     z], highlight.v1);
    vec3.set([x + w, y + h, z], highlight.v2);
    vec3.set([x,     y + h, z], highlight.v3);

    this._currentSelection = aNodeIndex;

    // if something is highlighted, make sure it's inside the current viewport;
    // the point which should be moved into view is considered the center [x, y]
    // position along the top edge of the currently selected node

    if (aFlags && aFlags.indexOf("moveIntoView") !== -1)
    {
      this.controller.arcball.moveIntoView(vec3.lerp(
        vec3.scale(this._highlight.v0, this.transforms.zoom, []),
        vec3.scale(this._highlight.v1, this.transforms.zoom, []), 0.5));
    }

    Services.obs.notifyObservers(this.contentWindow, this.NOTIFICATIONS.HIGHLIGHTING, null);
  },

  /**
   * Deletes a node from the visualization mesh.
   *
   * @param {Number} aNodeIndex
   *                 the index of the node in the this._traverseData array;
   *                 if not specified, it will default to the current selection
   */
  deleteNode: function TVP_deleteNode(aNodeIndex)
  {
    // we probably don't want to delete the html or body node.. just sayin'
    if ((aNodeIndex = aNodeIndex || this._currentSelection) < 1) {
      return;
    }

    let renderer = this._renderer;

    let groupIndex = parseInt(aNodeIndex / MAX_GROUP_NODES);
    let nodeIndex = parseInt((aNodeIndex + (groupIndex ? 1 : 0)) % MAX_GROUP_NODES);
    let group = this._meshStacks[groupIndex];
    let vertices = group.vertices.components;

    for (let i = 0, k = 36 * nodeIndex; i < 36; i++) {
      vertices[i + k] = 0;
    }

    group.vertices = new renderer.VertexBuffer(vertices, 3);
    this._highlight.disabled = true;
    this._redraw = true;

    Services.obs.notifyObservers(this.contentWindow, this.NOTIFICATIONS.NODE_REMOVED, null);
  },

  /**
   * Picks a stacked dom node at the x and y screen coordinates and issues
   * a callback function with the found intersection.
   *
   * @param {Number} x
   *                 the current horizontal coordinate of the mouse
   * @param {Number} y
   *                 the current vertical coordinate of the mouse
   * @param {Object} aProperties
   *                 an object containing the following properties:
   *      {Function} onpick: function to be called at intersection
   *      {Function} onfail: function to be called if no intersections
   */
  pickNode: function TVP_pickNode(x, y, aProperties)
  {
    // make sure the properties parameter is a valid object
    aProperties = aProperties || {};

    // if the mesh wasn't created yet, don't continue picking
    if (!this._meshStacks || !this._meshWireframe) {
      return;
    }

    let worker = new ChromeWorker(TILT_PICKER);

    worker.addEventListener("message", function TVP_onMessage(event) {
      if (event.data) {
        if ("function" === typeof aProperties.onpick) {
          aProperties.onpick(event.data);
        }
      } else {
        if ("function" === typeof aProperties.onfail) {
          aProperties.onfail();
        }
      }
    }, false);

    let zoom = this._getPageZoom();
    let width = this._renderer.width * zoom;
    let height = this._renderer.height * zoom;
    x *= zoom;
    y *= zoom;

    // create a ray following the mouse direction from the near clipping plane
    // to the far clipping plane, to check for intersections with the mesh,
    // and do all the heavy lifting in a separate thread
    worker.postMessage({
      vertices: this._meshData.allVertices,

      // create the ray destined for 3D picking
      ray: vec3.createRay([x, y, 0], [x, y, 1], [0, 0, width, height],
        this._meshStacks.mvMatrix,
        this._meshStacks.projMatrix)
    });
  },

  /**
   * Delegate translation method, used by the controller.
   *
   * @param {Array} aTranslation
   *                the new translation on the [x, y, z] axis
   */
  setTranslation: function TVP_setTranslation(aTranslation)
  {
    let x = aTranslation[0];
    let y = aTranslation[1];
    let z = aTranslation[2];
    let transforms = this.transforms;

    // only update the translation if it's not already set
    if (transforms.translation[0] !== x ||
        transforms.translation[1] !== y ||
        transforms.translation[2] !== z) {

      vec3.set(aTranslation, transforms.translation);
      this._redraw = true;
    }
  },

  /**
   * Delegate rotation method, used by the controller.
   *
   * @param {Array} aQuaternion
   *                the rotation quaternion, as [x, y, z, w]
   */
  setRotation: function TVP_setRotation(aQuaternion)
  {
    let x = aQuaternion[0];
    let y = aQuaternion[1];
    let z = aQuaternion[2];
    let w = aQuaternion[3];
    let transforms = this.transforms;

    // only update the rotation if it's not already set
    if (transforms.rotation[0] !== x ||
        transforms.rotation[1] !== y ||
        transforms.rotation[2] !== z ||
        transforms.rotation[3] !== w) {

      quat4.set(aQuaternion, transforms.rotation);
      this._redraw = true;
    }
  },

  /**
   * Handles notifications at specific frame counts.
   */
  _handleKeyframeNotifications: function TV__handleKeyframeNotifications()
  {
    if (!TiltVisualizer.Prefs.introTransition && !this._isExecutingDestruction) {
      this._time = INTRO_TRANSITION_DURATION;
    }
    if (!TiltVisualizer.Prefs.outroTransition && this._isExecutingDestruction) {
      this._time = OUTRO_TRANSITION_DURATION;
    }

    if (this._time >= INTRO_TRANSITION_DURATION &&
       !this._isInitializationFinished &&
       !this._isExecutingDestruction) {

      this._isInitializationFinished = true;
      Services.obs.notifyObservers(this.contentWindow, this.NOTIFICATIONS.INITIALIZED, null);

      if ("function" === typeof this._onInitializationFinished) {
        this._onInitializationFinished();
      }
    }

    if (this._time >= OUTRO_TRANSITION_DURATION &&
       !this._isDestructionFinished &&
        this._isExecutingDestruction) {

      this._isDestructionFinished = true;
      Services.obs.notifyObservers(this.contentWindow, this.NOTIFICATIONS.BEFORE_DESTROYED, null);

      if ("function" === typeof this._onDestructionFinished) {
        this._onDestructionFinished();
      }
    }
  },

  /**
   * Starts executing the destruction sequence and issues a callback function
   * when finished.
   *
   * @param {Function} aCallback
   *                   the destruction finished callback
   */
  executeDestruction: function TV_executeDestruction(aCallback)
  {
    if (!this._isExecutingDestruction) {
      this._isExecutingDestruction = true;
      this._onDestructionFinished = aCallback;

      // if we execute the destruction after the initialization finishes,
      // proceed normally; otherwise, skip everything and immediately issue
      // the callback

      if (this._time > OUTRO_TRANSITION_DURATION) {
        this._time = 0;
        this._redraw = true;
      } else {
        aCallback();
      }
    }
  },

  /**
   * Checks if this object was initialized properly.
   *
   * @return {Boolean} true if the object was initialized properly
   */
  isInitialized: function TVP_isInitialized()
  {
    return this._renderer && this._renderer.context;
  },

  /**
   * Function called when this object is destroyed.
   */
  _finalize: function TVP__finalize()
  {
    TiltUtils.destroyObject(this._visualizationProgram);
    TiltUtils.destroyObject(this._texture);

    if (this._meshStacks) {
      this._meshStacks.forEach(function(group) {
        TiltUtils.destroyObject(group.vertices);
        TiltUtils.destroyObject(group.texCoord);
        TiltUtils.destroyObject(group.color);
        TiltUtils.destroyObject(group.indices);
      });
    }
    if (this._meshWireframe) {
      this._meshWireframe.forEach(function(group) {
        TiltUtils.destroyObject(group.indices);
      });
    }

    TiltUtils.destroyObject(this._renderer);

    // Closing the tab would result in contentWindow being a dead object,
    // so operations like removing event listeners won't work anymore.
    if (this.contentWindow == this.chromeWindow.content) {
      this.contentWindow.removeEventListener("resize", this._onResize, false);
    }
  }
};

/**
 * A mouse and keyboard controller implementation.
 *
 * @param {HTMLCanvasElement} aCanvas
 *                            the visualization canvas element
 * @param {TiltVisualizer.Presenter} aPresenter
 *                                   the presenter instance to control
 */
TiltVisualizer.Controller = function TV_Controller(aCanvas, aPresenter)
{
  /**
   * A canvas overlay on which mouse and keyboard event listeners are attached.
   */
  this.canvas = aCanvas;

  /**
   * Save a reference to the presenter to modify its model-view transforms.
   */
  this.presenter = aPresenter;
  this.presenter.controller = this;

  /**
   * The initial controller dimensions and offset, in pixels.
   */
  this._zoom = aPresenter.transforms.zoom;
  this._left = (aPresenter.contentWindow.pageXOffset || 0) * this._zoom;
  this._top = (aPresenter.contentWindow.pageYOffset || 0) * this._zoom;
  this._width = aCanvas.width;
  this._height = aCanvas.height;

  /**
   * Arcball used to control the visualization using the mouse.
   */
  this.arcball = new TiltVisualizer.Arcball(
    this.presenter.chromeWindow, this._width, this._height, 0,
    [
      this._width + this._left < aPresenter._maxTextureSize ? -this._left : 0,
      this._height + this._top < aPresenter._maxTextureSize ? -this._top : 0
    ]);

  /**
   * Object containing the rotation quaternion and the translation amount.
   */
  this._coordinates = null;

  // bind the owner object to the necessary functions
  TiltUtils.bindObjectFunc(this, "_update");
  TiltUtils.bindObjectFunc(this, "^_on");

  // add the necessary event listeners
  this.addEventListeners();

  // attach this controller's update function to the presenter ondraw event
  this.presenter._controllerUpdate = this._update;
};

TiltVisualizer.Controller.prototype = {

  /**
   * Adds events listeners required by this controller.
   */
  addEventListeners: function TVC_addEventListeners()
  {
    let canvas = this.canvas;
    let presenter = this.presenter;

    // bind commonly used mouse and keyboard events with the controller
    canvas.addEventListener("mousedown", this._onMouseDown, false);
    canvas.addEventListener("mouseup", this._onMouseUp, false);
    canvas.addEventListener("mousemove", this._onMouseMove, false);
    canvas.addEventListener("mouseover", this._onMouseOver, false);
    canvas.addEventListener("mouseout", this._onMouseOut, false);
    canvas.addEventListener("MozMousePixelScroll", this._onMozScroll, false);
    canvas.addEventListener("keydown", this._onKeyDown, false);
    canvas.addEventListener("keyup", this._onKeyUp, false);
    canvas.addEventListener("keypress", this._onKeyPress, true);
    canvas.addEventListener("blur", this._onBlur, false);

    // handle resize events to change the arcball dimensions
    presenter.contentWindow.addEventListener("resize", this._onResize, false);
  },

  /**
   * Removes all added events listeners required by this controller.
   */
  removeEventListeners: function TVC_removeEventListeners()
  {
    let canvas = this.canvas;
    let presenter = this.presenter;

    canvas.removeEventListener("mousedown", this._onMouseDown, false);
    canvas.removeEventListener("mouseup", this._onMouseUp, false);
    canvas.removeEventListener("mousemove", this._onMouseMove, false);
    canvas.removeEventListener("mouseover", this._onMouseOver, false);
    canvas.removeEventListener("mouseout", this._onMouseOut, false);
    canvas.removeEventListener("MozMousePixelScroll", this._onMozScroll, false);
    canvas.removeEventListener("keydown", this._onKeyDown, false);
    canvas.removeEventListener("keyup", this._onKeyUp, false);
    canvas.removeEventListener("keypress", this._onKeyPress, true);
    canvas.removeEventListener("blur", this._onBlur, false);

    // Closing the tab would result in contentWindow being a dead object,
    // so operations like removing event listeners won't work anymore.
    if (presenter.contentWindow == presenter.chromeWindow.content) {
      presenter.contentWindow.removeEventListener("resize", this._onResize, false);
    }
  },

  /**
   * Function called each frame, updating the visualization camera transforms.
   *
   * @param {Number} aTime
   *                 total time passed since rendering started
   * @param {Number} aDelta
   *                 the current animation frame delta
   */
  _update: function TVC__update(aTime, aDelta)
  {
    this._time = aTime;
    this._coordinates = this.arcball.update(aDelta);

    this.presenter.setRotation(this._coordinates.rotation);
    this.presenter.setTranslation(this._coordinates.translation);
  },

  /**
   * Called once after every time a mouse button is pressed.
   */
  _onMouseDown: function TVC__onMouseDown(e)
  {
    e.target.focus();
    e.preventDefault();
    e.stopPropagation();

    if (this._time < MOUSE_INTRO_DELAY) {
      return;
    }

    // calculate x and y coordinates using using the client and target offset
    let button = e.which;
    this._downX = e.clientX - e.target.offsetLeft;
    this._downY = e.clientY - e.target.offsetTop;

    this.arcball.mouseDown(this._downX, this._downY, button);
  },

  /**
   * Called every time a mouse button is released.
   */
  _onMouseUp: function TVC__onMouseUp(e)
  {
    e.preventDefault();
    e.stopPropagation();

    if (this._time < MOUSE_INTRO_DELAY) {
      return;
    }

    // calculate x and y coordinates using using the client and target offset
    let button = e.which;
    let upX = e.clientX - e.target.offsetLeft;
    let upY = e.clientY - e.target.offsetTop;

    // a click in Tilt is issued only when the mouse pointer stays in
    // relatively the same position
    if (Math.abs(this._downX - upX) < MOUSE_CLICK_THRESHOLD &&
        Math.abs(this._downY - upY) < MOUSE_CLICK_THRESHOLD) {

      this.presenter.highlightNodeAt(upX, upY);
    }

    this.arcball.mouseUp(upX, upY, button);
  },

  /**
   * Called every time the mouse moves.
   */
  _onMouseMove: function TVC__onMouseMove(e)
  {
    e.preventDefault();
    e.stopPropagation();

    if (this._time < MOUSE_INTRO_DELAY) {
      return;
    }

    // calculate x and y coordinates using using the client and target offset
    let moveX = e.clientX - e.target.offsetLeft;
    let moveY = e.clientY - e.target.offsetTop;

    this.arcball.mouseMove(moveX, moveY);
  },

  /**
   * Called when the mouse leaves the visualization bounds.
   */
  _onMouseOver: function TVC__onMouseOver(e)
  {
    e.preventDefault();
    e.stopPropagation();

    this.arcball.mouseOver();
  },

  /**
   * Called when the mouse leaves the visualization bounds.
   */
  _onMouseOut: function TVC__onMouseOut(e)
  {
    e.preventDefault();
    e.stopPropagation();

    this.arcball.mouseOut();
  },

  /**
   * Called when the mouse wheel is used.
   */
  _onMozScroll: function TVC__onMozScroll(e)
  {
    e.preventDefault();
    e.stopPropagation();

    this.arcball.zoom(e.detail);
  },

  /**
   * Called when a key is pressed.
   */
  _onKeyDown: function TVC__onKeyDown(e)
  {
    let code = e.keyCode || e.which;

    if (!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
      e.preventDefault();
      e.stopPropagation();
      this.arcball.keyDown(code);
    } else {
      this.arcball.cancelKeyEvents();
    }
  },

  /**
   * Called when a key is released.
   */
  _onKeyUp: function TVC__onKeyUp(e)
  {
    let code = e.keyCode || e.which;

    if (code === e.DOM_VK_X) {
      this.presenter.deleteNode();
    }
    if (code === e.DOM_VK_F) {
      let highlight = this.presenter._highlight;
      let zoom = this.presenter.transforms.zoom;

      this.arcball.moveIntoView(vec3.lerp(
        vec3.scale(highlight.v0, zoom, []),
        vec3.scale(highlight.v1, zoom, []), 0.5));
    }
    if (!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
      e.preventDefault();
      e.stopPropagation();
      this.arcball.keyUp(code);
    }
  },

  /**
   * Called when a key is pressed.
   */
  _onKeyPress: function TVC__onKeyPress(e)
  {
    if (e.keyCode === e.DOM_VK_ESCAPE) {
      let {TiltManager} = require("devtools/tilt/tilt");
      let tilt =
        TiltManager.getTiltForBrowser(this.presenter.chromeWindow);
      e.preventDefault();
      e.stopPropagation();
      tilt.destroy(tilt.currentWindowId, true);
    }
  },

  /**
   * Called when the canvas looses focus.
   */
  _onBlur: function TVC__onBlur(e) {
    this.arcball.cancelKeyEvents();
  },

  /**
   * Called when the content window of the current browser is resized.
   */
  _onResize: function TVC__onResize(e)
  {
    let zoom = this.presenter._getPageZoom();
    let width = e.target.innerWidth * zoom;
    let height = e.target.innerHeight * zoom;

    this.arcball.resize(width, height);
  },

  /**
   * Checks if this object was initialized properly.
   *
   * @return {Boolean} true if the object was initialized properly
   */
  isInitialized: function TVC_isInitialized()
  {
    return this.arcball ? true : false;
  },

  /**
   * Function called when this object is destroyed.
   */
  _finalize: function TVC__finalize()
  {
    TiltUtils.destroyObject(this.arcball);
    TiltUtils.destroyObject(this._coordinates);

    this.removeEventListeners();
    this.presenter.controller = null;
    this.presenter._controllerUpdate = null;
  }
};

/**
 * This is a general purpose 3D rotation controller described by Ken Shoemake
 * in the Graphics Interface ’92 Proceedings. It features good behavior
 * easy implementation, cheap execution.
 *
 * @param {Window} aChromeWindow
 *                 a reference to the top-level window
 * @param {Number} aWidth
 *                 the width of canvas
 * @param {Number} aHeight
 *                 the height of canvas
 * @param {Number} aRadius
 *                 optional, the radius of the arcball
 * @param {Array} aInitialTrans
 *                optional, initial vector translation
 * @param {Array} aInitialRot
 *                optional, initial quaternion rotation
 */
TiltVisualizer.Arcball = function TV_Arcball(
  aChromeWindow, aWidth, aHeight, aRadius, aInitialTrans, aInitialRot)
{
  /**
   * Save a reference to the top-level window to set/remove intervals.
   */
  this.chromeWindow = aChromeWindow;

  /**
   * Values retaining the current horizontal and vertical mouse coordinates.
   */
  this._mousePress = vec3.create();
  this._mouseRelease = vec3.create();
  this._mouseMove = vec3.create();
  this._mouseLerp = vec3.create();
  this._mouseButton = -1;

  /**
   * Object retaining the current pressed key codes.
   */
  this._keyCode = {};

  /**
   * The vectors representing the mouse coordinates mapped on the arcball
   * and their perpendicular converted from (x, y) to (x, y, z) at specific
   * events like mousePressed and mouseDragged.
   */
  this._startVec = vec3.create();
  this._endVec = vec3.create();
  this._pVec = vec3.create();

  /**
   * The corresponding rotation quaternions.
   */
  this._lastRot = quat4.create();
  this._deltaRot = quat4.create();
  this._currentRot = quat4.create(aInitialRot);

  /**
   * The current camera translation coordinates.
   */
  this._lastTrans = vec3.create();
  this._deltaTrans = vec3.create();
  this._currentTrans = vec3.create(aInitialTrans);
  this._zoomAmount = 0;

  /**
   * Additional rotation and translation vectors.
   */
  this._additionalRot = vec3.create();
  this._additionalTrans = vec3.create();
  this._deltaAdditionalRot = quat4.create();
  this._deltaAdditionalTrans = vec3.create();

  // load the keys controlling the arcball
  this._loadKeys();

  // set the current dimensions of the arcball
  this.resize(aWidth, aHeight, aRadius);
};

TiltVisualizer.Arcball.prototype = {

  /**
   * Call this function whenever you need the updated rotation quaternion
   * and the zoom amount. These values will be returned as "rotation" and
   * "translation" properties inside an object.
   *
   * @param {Number} aDelta
   *                 the current animation frame delta
   *
   * @return {Object} the rotation quaternion and the translation amount
   */
  update: function TVA_update(aDelta)
  {
    let mousePress = this._mousePress;
    let mouseRelease = this._mouseRelease;
    let mouseMove = this._mouseMove;
    let mouseLerp = this._mouseLerp;
    let mouseButton = this._mouseButton;

    // smoothly update the mouse coordinates
    mouseLerp[0] += (mouseMove[0] - mouseLerp[0]) * ARCBALL_SENSITIVITY;
    mouseLerp[1] += (mouseMove[1] - mouseLerp[1]) * ARCBALL_SENSITIVITY;

    // cache the interpolated mouse coordinates
    let x = mouseLerp[0];
    let y = mouseLerp[1];

    // the smoothed arcball rotation may not be finished when the mouse is
    // pressed again, so cancel the rotation if other events occur or the
    // animation finishes
    if (mouseButton === 3 || x === mouseRelease[0] && y === mouseRelease[1]) {
      this._rotating = false;
    }

    let startVec = this._startVec;
    let endVec = this._endVec;
    let pVec = this._pVec;

    let lastRot = this._lastRot;
    let deltaRot = this._deltaRot;
    let currentRot = this._currentRot;

    // left mouse button handles rotation
    if (mouseButton === 1 || this._rotating) {
      // the rotation doesn't stop immediately after the left mouse button is
      // released, so add a flag to smoothly continue it until it ends
      this._rotating = true;

      // find the sphere coordinates of the mouse positions
      this._pointToSphere(x, y, this.width, this.height, this.radius, endVec);

      // compute the vector perpendicular to the start & end vectors
      vec3.cross(startVec, endVec, pVec);

      // if the begin and end vectors don't coincide
      if (vec3.length(pVec) > 0) {
        deltaRot[0] = pVec[0];
        deltaRot[1] = pVec[1];
        deltaRot[2] = pVec[2];

        // in the quaternion values, w is cosine (theta / 2),
        // where theta is the rotation angle
        deltaRot[3] = -vec3.dot(startVec, endVec);
      } else {
        // return an identity rotation quaternion
        deltaRot[0] = 0;
        deltaRot[1] = 0;
        deltaRot[2] = 0;
        deltaRot[3] = 1;
      }

      // calculate the current rotation based on the mouse click events
      quat4.multiply(lastRot, deltaRot, currentRot);
    } else {
      // save the current quaternion to stack rotations
      quat4.set(currentRot, lastRot);
    }

    let lastTrans = this._lastTrans;
    let deltaTrans = this._deltaTrans;
    let currentTrans = this._currentTrans;

    // right mouse button handles panning
    if (mouseButton === 3) {
      // calculate a delta translation between the new and old mouse position
      // and save it to the current translation
      deltaTrans[0] = mouseMove[0] - mousePress[0];
      deltaTrans[1] = mouseMove[1] - mousePress[1];

      currentTrans[0] = lastTrans[0] + deltaTrans[0];
      currentTrans[1] = lastTrans[1] + deltaTrans[1];
    } else {
      // save the current panning to stack translations
      lastTrans[0] = currentTrans[0];
      lastTrans[1] = currentTrans[1];
    }

    let zoomAmount = this._zoomAmount;
    let keyCode = this._keyCode;

    // mouse wheel handles zooming
    deltaTrans[2] = (zoomAmount - currentTrans[2]) * ARCBALL_ZOOM_STEP;
    currentTrans[2] += deltaTrans[2];

    let additionalRot = this._additionalRot;
    let additionalTrans = this._additionalTrans;
    let deltaAdditionalRot = this._deltaAdditionalRot;
    let deltaAdditionalTrans = this._deltaAdditionalTrans;

    let rotateKeys = this.rotateKeys;
    let panKeys = this.panKeys;
    let zoomKeys = this.zoomKeys;
    let resetKey = this.resetKey;

    // handle additional rotation and translation by the keyboard
    if (keyCode[rotateKeys.left]) {
      additionalRot[0] -= ARCBALL_SENSITIVITY * ARCBALL_ROTATION_STEP;
    }
    if (keyCode[rotateKeys.right]) {
      additionalRot[0] += ARCBALL_SENSITIVITY * ARCBALL_ROTATION_STEP;
    }
    if (keyCode[rotateKeys.up]) {
      additionalRot[1] += ARCBALL_SENSITIVITY * ARCBALL_ROTATION_STEP;
    }
    if (keyCode[rotateKeys.down]) {
      additionalRot[1] -= ARCBALL_SENSITIVITY * ARCBALL_ROTATION_STEP;
    }
    if (keyCode[panKeys.left]) {
      additionalTrans[0] -= ARCBALL_SENSITIVITY * ARCBALL_TRANSLATION_STEP;
    }
    if (keyCode[panKeys.right]) {
      additionalTrans[0] += ARCBALL_SENSITIVITY * ARCBALL_TRANSLATION_STEP;
    }
    if (keyCode[panKeys.up]) {
      additionalTrans[1] -= ARCBALL_SENSITIVITY * ARCBALL_TRANSLATION_STEP;
    }
    if (keyCode[panKeys.down]) {
      additionalTrans[1] += ARCBALL_SENSITIVITY * ARCBALL_TRANSLATION_STEP;
    }
    if (keyCode[zoomKeys["in"][0]] ||
        keyCode[zoomKeys["in"][1]] ||
        keyCode[zoomKeys["in"][2]]) {
      this.zoom(-ARCBALL_TRANSLATION_STEP);
    }
    if (keyCode[zoomKeys["out"][0]] ||
        keyCode[zoomKeys["out"][1]]) {
      this.zoom(ARCBALL_TRANSLATION_STEP);
    }
    if (keyCode[zoomKeys["unzoom"]]) {
      this._zoomAmount = 0;
    }
    if (keyCode[resetKey]) {
      this.reset();
    }

    // update the delta key rotations and translations
    deltaAdditionalRot[0] +=
      (additionalRot[0] - deltaAdditionalRot[0]) * ARCBALL_SENSITIVITY;
    deltaAdditionalRot[1] +=
      (additionalRot[1] - deltaAdditionalRot[1]) * ARCBALL_SENSITIVITY;
    deltaAdditionalRot[2] +=
      (additionalRot[2] - deltaAdditionalRot[2]) * ARCBALL_SENSITIVITY;

    deltaAdditionalTrans[0] +=
      (additionalTrans[0] - deltaAdditionalTrans[0]) * ARCBALL_SENSITIVITY;
    deltaAdditionalTrans[1] +=
      (additionalTrans[1] - deltaAdditionalTrans[1]) * ARCBALL_SENSITIVITY;

    // create an additional rotation based on the key events
    quat4.fromEuler(
      deltaAdditionalRot[0],
      deltaAdditionalRot[1],
      deltaAdditionalRot[2], deltaRot);

    // create an additional translation based on the key events
    vec3.set([deltaAdditionalTrans[0], deltaAdditionalTrans[1], 0], deltaTrans);

    // handle the reset animation steps if necessary
    if (this._resetInProgress) {
      this._nextResetStep(aDelta || 1);
    }

    // return the current rotation and translation
    return {
      rotation: quat4.multiply(deltaRot, currentRot),
      translation: vec3.add(deltaTrans, currentTrans)
    };
  },

  /**
   * Function handling the mouseDown event.
   * Call this when the mouse was pressed.
   *
   * @param {Number} x
   *                 the current horizontal coordinate of the mouse
   * @param {Number} y
   *                 the current vertical coordinate of the mouse
   * @param {Number} aButton
   *                 which mouse button was pressed
   */
  mouseDown: function TVA_mouseDown(x, y, aButton)
  {
    // save the mouse down state and prepare for rotations or translations
    this._mousePress[0] = x;
    this._mousePress[1] = y;
    this._mouseButton = aButton;
    this._cancelReset();
    this._save();

    // find the sphere coordinates of the mouse positions
    this._pointToSphere(
      x, y, this.width, this.height, this.radius, this._startVec);

    quat4.set(this._currentRot, this._lastRot);
  },

  /**
   * Function handling the mouseUp event.
   * Call this when a mouse button was released.
   *
   * @param {Number} x
   *                 the current horizontal coordinate of the mouse
   * @param {Number} y
   *                 the current vertical coordinate of the mouse
   */
  mouseUp: function TVA_mouseUp(x, y)
  {
    // save the mouse up state and prepare for rotations or translations
    this._mouseRelease[0] = x;
    this._mouseRelease[1] = y;
    this._mouseButton = -1;
  },

  /**
   * Function handling the mouseMove event.
   * Call this when the mouse was moved.
   *
   * @param {Number} x
   *                 the current horizontal coordinate of the mouse
   * @param {Number} y
   *                 the current vertical coordinate of the mouse
   */
  mouseMove: function TVA_mouseMove(x, y)
  {
    // save the mouse move state and prepare for rotations or translations
    // only if the mouse is pressed
    if (this._mouseButton !== -1) {
      this._mouseMove[0] = x;
      this._mouseMove[1] = y;
    }
  },

  /**
   * Function handling the mouseOver event.
   * Call this when the mouse enters the context bounds.
   */
  mouseOver: function TVA_mouseOver()
  {
    // if the mouse just entered the parent bounds, stop the animation
    this._mouseButton = -1;
  },

  /**
   * Function handling the mouseOut event.
   * Call this when the mouse leaves the context bounds.
   */
  mouseOut: function TVA_mouseOut()
  {
    // if the mouse leaves the parent bounds, stop the animation
    this._mouseButton = -1;
  },

  /**
   * Function handling the arcball zoom amount.
   * Call this, for example, when the mouse wheel was scrolled or zoom keys
   * were pressed.
   *
   * @param {Number} aZoom
   *                 the zoom direction and speed
   */
  zoom: function TVA_zoom(aZoom)
  {
    this._cancelReset();
    this._zoomAmount = TiltMath.clamp(this._zoomAmount - aZoom,
      ARCBALL_ZOOM_MIN, ARCBALL_ZOOM_MAX);
  },

  /**
   * Function handling the keyDown event.
   * Call this when a key was pressed.
   *
   * @param {Number} aCode
   *                 the code corresponding to the key pressed
   */
  keyDown: function TVA_keyDown(aCode)
  {
    this._cancelReset();
    this._keyCode[aCode] = true;
  },

  /**
   * Function handling the keyUp event.
   * Call this when a key was released.
   *
   * @param {Number} aCode
   *                 the code corresponding to the key released
   */
  keyUp: function TVA_keyUp(aCode)
  {
    this._keyCode[aCode] = false;
  },

  /**
   * Maps the 2d coordinates of the mouse location to a 3d point on a sphere.
   *
   * @param {Number} x
   *                 the current horizontal coordinate of the mouse
   * @param {Number} y
   *                 the current vertical coordinate of the mouse
   * @param {Number} aWidth
   *                 the width of canvas
   * @param {Number} aHeight
   *                 the height of canvas
   * @param {Number} aRadius
   *                 optional, the radius of the arcball
   * @param {Array} aSphereVec
   *                a 3d vector to store the sphere coordinates
   */
  _pointToSphere: function TVA__pointToSphere(
    x, y, aWidth, aHeight, aRadius, aSphereVec)
  {
    // adjust point coords and scale down to range of [-1..1]
    x = (x - aWidth * 0.5) / aRadius;
    y = (y - aHeight * 0.5) / aRadius;

    // compute the square length of the vector to the point from the center
    let normal = 0;
    let sqlength = x * x + y * y;

    // if the point is mapped outside of the sphere
    if (sqlength > 1) {
      // calculate the normalization factor
      normal = 1 / Math.sqrt(sqlength);

      // set the normalized vector (a point on the sphere)
      aSphereVec[0] = x * normal;
      aSphereVec[1] = y * normal;
      aSphereVec[2] = 0;
    } else {
      // set the vector to a point mapped inside the sphere
      aSphereVec[0] = x;
      aSphereVec[1] = y;
      aSphereVec[2] = Math.sqrt(1 - sqlength);
    }
  },

  /**
   * Cancels all pending transformations caused by key events.
   */
  cancelKeyEvents: function TVA_cancelKeyEvents()
  {
    this._keyCode = {};
  },

  /**
   * Cancels all pending transformations caused by mouse events.
   */
  cancelMouseEvents: function TVA_cancelMouseEvents()
  {
    this._rotating = false;
    this._mouseButton = -1;
  },

  /**
   * Incremental translation method.
   *
   * @param {Array} aTranslation
   *                the translation ammount on the [x, y] axis
   */
  translate: function TVP_translate(aTranslation)
  {
    this._additionalTrans[0] += aTranslation[0];
    this._additionalTrans[1] += aTranslation[1];
  },

  /**
   * Incremental rotation method.
   *
   * @param {Array} aRotation
   *                the rotation ammount along the [x, y, z] axis
   */
  rotate: function TVP_rotate(aRotation)
  {
    // explicitly rotate along y, x, z values because they're eulerian angles
    this._additionalRot[0] += TiltMath.radians(aRotation[1]);
    this._additionalRot[1] += TiltMath.radians(aRotation[0]);
    this._additionalRot[2] += TiltMath.radians(aRotation[2]);
  },

  /**
   * Moves a target point into view only if it's outside the currently visible
   * area bounds (in which case it also resets any additional transforms).
   *
   * @param {Arary} aPoint
   *                the [x, y] point which should be brought into view
   */
  moveIntoView: function TVA_moveIntoView(aPoint) {
    let visiblePointX = -(this._currentTrans[0] + this._additionalTrans[0]);
    let visiblePointY = -(this._currentTrans[1] + this._additionalTrans[1]);

    if (aPoint[1] - visiblePointY - MOVE_INTO_VIEW_ACCURACY > this.height ||
        aPoint[1] - visiblePointY + MOVE_INTO_VIEW_ACCURACY < 0 ||
        aPoint[0] - visiblePointX > this.width ||
        aPoint[0] - visiblePointX < 0) {
      this.reset([0, -aPoint[1]]);
    }
  },

  /**
   * Resize this implementation to use different bounds.
   * This function is automatically called when the arcball is created.
   *
   * @param {Number} newWidth
   *                 the new width of canvas
   * @param {Number} newHeight
   *                 the new  height of canvas
   * @param {Number} newRadius
   *                 optional, the new radius of the arcball
   */
  resize: function TVA_resize(newWidth, newHeight, newRadius)
  {
    if (!newWidth || !newHeight) {
      return;
    }

    // set the new width, height and radius dimensions
    this.width = newWidth;
    this.height = newHeight;
    this.radius = newRadius ? newRadius : Math.min(newWidth, newHeight);
    this._save();
  },

  /**
   * Starts an animation resetting the arcball transformations to identity.
   *
   * @param {Array} aFinalTranslation
   *                optional, final vector translation
   * @param {Array} aFinalRotation
   *                optional, final quaternion rotation
   */
  reset: function TVA_reset(aFinalTranslation, aFinalRotation)
  {
    if ("function" === typeof this._onResetStart) {
      this._onResetStart();
      this._onResetStart = null;
    }

    this.cancelMouseEvents();
    this.cancelKeyEvents();
    this._cancelReset();

    this._save();
    this._resetFinalTranslation = vec3.create(aFinalTranslation);
    this._resetFinalRotation = quat4.create(aFinalRotation);
    this._resetInProgress = true;
  },

  /**
   * Cancels the current arcball reset animation if there is one.
   */
  _cancelReset: function TVA__cancelReset()
  {
    if (this._resetInProgress) {
      this._resetInProgress = false;
      this._save();

      if ("function" === typeof this._onResetFinish) {
        this._onResetFinish();
        this._onResetFinish = null;
        this._onResetStep = null;
      }
    }
  },

  /**
   * Executes the next step in the arcball reset animation.
   *
   * @param {Number} aDelta
   *                 the current animation frame delta
   */
  _nextResetStep: function TVA__nextResetStep(aDelta)
  {
    // a very large animation frame delta (in case of seriously low framerate)
    // would cause all the interpolations to become highly unstable
    aDelta = TiltMath.clamp(aDelta, 1, 100);

    let fNearZero = EPSILON * EPSILON;
    let fInterpLin = ARCBALL_RESET_LINEAR_FACTOR * aDelta;
    let fInterpSph = ARCBALL_RESET_SPHERICAL_FACTOR;
    let fTran = this._resetFinalTranslation;
    let fRot = this._resetFinalRotation;

    let t = vec3.create(fTran);
    let r = quat4.multiply(quat4.inverse(quat4.create(this._currentRot)), fRot);

    // reset the rotation quaternion and translation vector
    vec3.lerp(this._currentTrans, t, fInterpLin);
    quat4.slerp(this._currentRot, r, fInterpSph);

    // also reset any additional transforms by the keyboard or mouse
    vec3.scale(this._additionalTrans, fInterpLin);
    vec3.scale(this._additionalRot, fInterpLin);
    this._zoomAmount *= fInterpLin;

    // clear the loop if the all values are very close to zero
    if (vec3.length(vec3.subtract(this._lastRot, fRot, [])) < fNearZero &&
        vec3.length(vec3.subtract(this._deltaRot, fRot, [])) < fNearZero &&
        vec3.length(vec3.subtract(this._currentRot, fRot, [])) < fNearZero &&
        vec3.length(vec3.subtract(this._lastTrans, fTran, [])) < fNearZero &&
        vec3.length(vec3.subtract(this._deltaTrans, fTran, [])) < fNearZero &&
        vec3.length(vec3.subtract(this._currentTrans, fTran, [])) < fNearZero &&
        vec3.length(this._additionalRot) < fNearZero &&
        vec3.length(this._additionalTrans) < fNearZero) {

      this._cancelReset();
    }

    if ("function" === typeof this._onResetStep) {
      this._onResetStep();
    }
  },

  /**
   * Loads the keys to control this arcball.
   */
  _loadKeys: function TVA__loadKeys()
  {
    this.rotateKeys = {
      "up": Ci.nsIDOMKeyEvent["DOM_VK_W"],
      "down": Ci.nsIDOMKeyEvent["DOM_VK_S"],
      "left": Ci.nsIDOMKeyEvent["DOM_VK_A"],
      "right": Ci.nsIDOMKeyEvent["DOM_VK_D"],
    };
    this.panKeys = {
      "up": Ci.nsIDOMKeyEvent["DOM_VK_UP"],
      "down": Ci.nsIDOMKeyEvent["DOM_VK_DOWN"],
      "left": Ci.nsIDOMKeyEvent["DOM_VK_LEFT"],
      "right": Ci.nsIDOMKeyEvent["DOM_VK_RIGHT"],
    };
    this.zoomKeys = {
      "in": [
        Ci.nsIDOMKeyEvent["DOM_VK_I"],
        Ci.nsIDOMKeyEvent["DOM_VK_ADD"],
        Ci.nsIDOMKeyEvent["DOM_VK_EQUALS"],
      ],
      "out": [
        Ci.nsIDOMKeyEvent["DOM_VK_O"],
        Ci.nsIDOMKeyEvent["DOM_VK_SUBTRACT"],
      ],
      "unzoom": Ci.nsIDOMKeyEvent["DOM_VK_0"]
    };
    this.resetKey = Ci.nsIDOMKeyEvent["DOM_VK_R"];
  },

  /**
   * Saves the current arcball state, typically after resize or mouse events.
   */
  _save: function TVA__save()
  {
    if (this._mousePress) {
      let x = this._mousePress[0];
      let y = this._mousePress[1];

      this._mouseMove[0] = x;
      this._mouseMove[1] = y;
      this._mouseRelease[0] = x;
      this._mouseRelease[1] = y;
      this._mouseLerp[0] = x;
      this._mouseLerp[1] = y;
    }
  },

  /**
   * Function called when this object is destroyed.
   */
  _finalize: function TVA__finalize()
  {
    this._cancelReset();
  }
};

/**
 * Tilt configuration preferences.
 */
TiltVisualizer.Prefs = {

  /**
   * Specifies if Tilt is enabled or not.
   */
  get enabled()
  {
    return this._enabled;
  },

  set enabled(value)
  {
    TiltUtils.Preferences.set("enabled", "boolean", value);
    this._enabled = value;
  },

  get introTransition()
  {
    return this._introTransition;
  },

  set introTransition(value)
  {
    TiltUtils.Preferences.set("intro_transition", "boolean", value);
    this._introTransition = value;
  },

  get outroTransition()
  {
    return this._outroTransition;
  },

  set outroTransition(value)
  {
    TiltUtils.Preferences.set("outro_transition", "boolean", value);
    this._outroTransition = value;
  },

  /**
   * Loads the preferences.
   */
  load: function TVC_load()
  {
    let prefs = TiltVisualizer.Prefs;
    let get = TiltUtils.Preferences.get;

    prefs._enabled = get("enabled", "boolean");
    prefs._introTransition = get("intro_transition", "boolean");
    prefs._outroTransition = get("outro_transition", "boolean");
  }
};

/**
 * A custom visualization shader.
 *
 * @param {Attribute} vertexPosition: the vertex position
 * @param {Attribute} vertexTexCoord: texture coordinates used by the sampler
 * @param {Attribute} vertexColor: specific [r, g, b] color for each vertex
 * @param {Uniform} mvMatrix: the model view matrix
 * @param {Uniform} projMatrix: the projection matrix
 * @param {Uniform} sampler: the texture sampler to fetch the pixels from
 */
TiltVisualizer.MeshShader = {

  /**
   * Vertex shader.
   */
  vs: [
    "attribute vec3 vertexPosition;",
    "attribute vec2 vertexTexCoord;",
    "attribute vec3 vertexColor;",

    "uniform mat4 mvMatrix;",
    "uniform mat4 projMatrix;",

    "varying vec2 texCoord;",
    "varying vec3 color;",

    "void main() {",
    "  gl_Position = projMatrix * mvMatrix * vec4(vertexPosition, 1.0);",
    "  texCoord = vertexTexCoord;",
    "  color = vertexColor;",
    "}"
  ].join("\n"),

  /**
   * Fragment shader.
   */
  fs: [
    "#ifdef GL_ES",
    "precision lowp float;",
    "#endif",

    "uniform sampler2D sampler;",

    "varying vec2 texCoord;",
    "varying vec3 color;",

    "void main() {",
    "  if (texCoord.x < 0.0) {",
    "    gl_FragColor = vec4(color, 1.0);",
    "  } else {",
    "    gl_FragColor = vec4(texture2D(sampler, texCoord).rgb, 1.0);",
    "  }",
    "}"
  ].join("\n")
};
