import * as $ from "jquery"
import {mat3, mat4, vec3, vec4, quat} from "gl-matrix";
import Misc from "./Misc"
import {ObjectCache, CanvasBase} from "./CanvasBase"
import Mesh from "./Mesh";
import ShaderProgram from "./Shaders";
import WebGLMeshBuffers from "./WebGLMeshBuffers";

// Mesh representing a single cube used to show the user where the focus point
// Generated by "ts_assets.py"
var FocusPointMeshDefinition = {
    "Color": "eAFjYGiwZwCDBnsACIIBfwMAAAAB",
    "IndexBufferType": "UInt16",
    "LineBuffer": "eAEDAAAAAAEAAAAAAg==",
    "PrimitiveType": "SingleColorMesh",
    "TriangleBuffer": "eAENxEkCQDAQALDYKYqi/v9Tc0hotIRObzDGo8lskeJktdnlODucLiUubo9XjavPDycwAZkMAAAAAw==",
    "VertexBuffer": "eAF1kYENACEIA9nsV3OzX+0fBa2lkJDoaVqqZvba6aiR+0dwZx1XOsCHr7NAR/K/Cw+9ji/TOMdGjhmYKx3gM08W6EjuuZjnOzT3p23605ybU67ClQ7k2t4wz8XpHwu/dD4ziz2hGAAAAAY="
};

// Default camera values
var DefaultCamera =
{
    "WorldToCamera": mat4.fromValues(1.0, 0.0, 0.0, 0.0,
                                     0.0, 1.0, 0.0, 0.0,
                                     0.0, 0.0, 1.0, 0.0,
                                     0.0, 0.0, -4.0, 1.0),
    "FoVYDegrees" : 45.0
};

var DefaultNearCropDistance = 0.01;
var DefaultFarCropDistance = 20.0;
var DegreesToRadians = Math.PI / 180.0;

// Default shading parameters
var DefaultShading =
{
    "BackgroundColor" : [0.0, 0.0, 0.0, 1.0],
    "AmbientLightColor" : [0.5, 0.5, 0.5],
    "DirectionalLightColor" : [0.5, 0.5, 0.5],
    "DirectionalLightDir" : [2.0, -1.0, 1.0]
};

// Represents an instantiation of a mesh in a frame
class MeshInstance
{
    meshId : string;
    transform : mat4;

    constructor(meshId : string, transform : mat4)
    {
        this.meshId = meshId;
        this.transform = transform;
    }
}

export default class Canvas3D extends CanvasBase
{
    allMeshes : any = null // Maps from meshIds to mesh objects
    gl : WebGLRenderingContext;
    sp : ShaderProgram;

    // Dictionary from layerId to layer settings (wireframe, filled, opacity)
    layerSettings = {};

    // Shading parameters
    bgColor : vec4;
    ambientLightColor : vec3;
    directionalLightColor : vec3;
    directionalLightDir : vec3;
    globalFill = true;
    globalWireframe = false;
    globalOpacity = 1.0;

    // Frame instances
    frameInstances : MeshInstance[][] = []; // [frameIndex][mesh instance within frame]

    // Mesh buffers *for the current frame*
    meshBuffers : any = {}; // The webgl mesh buffers for the currently selected frame: dictionary from meshId to webGLMeshBuffer

    // Pointer speeds
    pointerAltKeyMultiplier = 0.2;
    pointerRotationSpeed = 0.01;
    mouseWheelTranslationSpeed = 0.005;
    keyDownSpeed = 0.1;
    thumbStickDeadZoneAmount = 0.2;
    thumbStickRotationSpeed = 0.1;
    thumbStickTranslationSpeed = 0.01;
    touchpadAsButtonThreshold = 0.5;
    buttonTranslationSpeed = 0.01;
    buttonScaleSpeed = 1.01;

    // first person mode
    cameraVelocity = [0.0, 0.0, 0.0];
    cameraRotationalVelocity = [0.0, 0.0];
    dirKeyPresses = {
        "w": 0,
        "a": 0,
        "s": 0,
        "d": 0,
        "q": 0,
        "e": 0
    }

    // Focus points for user interaction: center of rotation and used for locking view
    globalFocusPoint : Float32Array = new Float32Array([0.0, 0.0, 0.0]);
    initialFocusPoints : Float32Array[] = []; // Per-frame focus points (initial version - used for reset camera)
    currentFocusPoints : Float32Array[] = []; // Per-frame focus points (current version - used for display)
    focusPointMeshBuffer : WebGLMeshBuffers;
    showFocusPoint : boolean = false;

    // Copy of last set camera config
    globalCameraParams : Object = null;
    frameCameraParams : Object[] = []; // Per-frame cameras
    onCameraTrack : boolean = false;

    // Frame layer settings
    frameLayerSettings : Object[] = [];

    // Lock view settings
    lockViewXY = false;
    lockViewOrientation = false;

    // Orbit settings
    orbitCamera = false;
    lastOrbitTime : Date = null;

    // View matrices
    w2vMatrix : mat4; // World to view (i.e. camera)
    v2sMatrix : mat4; // View to screen (i.e. projection)

    constructor(canvasId : string, public frameRate : number, public width : number, public height : number, allMeshes : any, objectCache : ObjectCache, public SetStatus : (status : string) => void, public SetWarning : (message : string) => void, public RequestRedraw : () => void, public ReportFrameIdChange : (canvasId : string, frameId : string) => void, public SimulateKeyPress : (canvasId : string, key : string) => void)
    {
        // Base class constructor
        super(canvasId, frameRate, width, height, objectCache, SetStatus, SetWarning, RequestRedraw, ReportFrameIdChange);

        // Store meshes
        this.allMeshes = allMeshes;

        this.dropdown.style.visibility = "visible";

        // Init gl
        this.InitializeWebGL();
        window.addEventListener("webglcontextlost", event => { this.TearDownWebGL(); event.preventDefault(); });
        window.addEventListener("webglcontextrestored", event => { this.InitializeWebGL(); event.preventDefault(); });

        // Set default scene values
        this.SetCamera(DefaultCamera);
        this.globalCameraParams = DefaultCamera;
        this.onCameraTrack = true;
        this.SetShading(DefaultShading);

        this.cameraModeDisplay.style.visibility = "visible";

        // Create dropdown
        this.SetLayerSettings({});        

        // Start render loop
        this.StartRenderLoop();
    }

    InitializeWebGL()
    {
        // Init gl
        try
        {
            // Get gl context
            this.gl = this.htmlCanvas.getContext("webgl");

            // Add support for uint draw_elements
            this.gl.getExtension("OES_element_index_uint");
        }
        catch (e) { }
        if (this.gl == null)
        {
            this.SetWarning("Could not init WebGL");
            return;
        }

        // Create shader program
        this.sp = new ShaderProgram(this.gl);

        // Create focus point cuboid
        var focusPointMesh = Mesh.Parse(FocusPointMeshDefinition);
        this.focusPointMeshBuffer = new WebGLMeshBuffers(this.gl, this.sp, focusPointMesh);

        // Recreate buffers
        this.PrepareBuffers();
    }

    TearDownWebGL()
    {
        this.gl = null;
        this.sp = null;
        if (this.focusPointMeshBuffer != null)
            this.focusPointMeshBuffer.Finalize();
        this.focusPointMeshBuffer = null;
        this.PrepareBuffers();
    }

    private updateCameraVelocityValue(keyPlus: string, keyMinus: string, index: number)
    {
        if (this.dirKeyPresses[keyPlus] > this.dirKeyPresses[keyMinus])
        {
            this.cameraVelocity[index] = this.keyDownSpeed
        }
        else if(this.dirKeyPresses[keyMinus] > 0)
        {
            this.cameraVelocity[index] = -this.keyDownSpeed;
        }
        else
        {
            this.cameraVelocity[index] = 0;
        }                   
    }

    private updateCameraVelocity()
    {
        this.updateCameraVelocityValue("w", "s", 0);
        this.updateCameraVelocityValue("a", "d", 1);
        this.updateCameraVelocityValue("q", "e", 2);
    }

    HandleKeyUp(key: string)
    {
        key = key.toLowerCase();
        if(this.firstPerson && key in this.dirKeyPresses)
        {
            this.dirKeyPresses[key] = 0;
            this.updateCameraVelocity();
        }
        else
        {
            for(let key in this.dirKeyPresses)
            {
                this.dirKeyPresses[key] = 0;
            }
        }
    }

    HandleKeyDown(key : string)
    {
        var result = super.HandleKeyDown(key);
        if (result[0]) return result; // Already handled

        var handled = true;
        key = key.toLowerCase();
        switch(key)
        {
            case "r":
                this.ResetView();
                break;
            case "l":
                this.ToggleLockView(true, false);
                break;
            case "o":
                this.ToggleLockView(false, true);
                break;
            case "\\":
                this.ToggleOrbitCamera();
                break;
            case "capslock":
                this.showFocusPoint = !this.showFocusPoint;
                break;
            default:
                handled = false;
                break;
        }

        if(this.firstPerson && key in this.dirKeyPresses)
        {
            this.dirKeyPresses[key] = Date.now();
            this.updateCameraVelocity();
        }

        return [handled, false];
    }

    // Adds UI for the control of certain layers
    SetLayerSettings(layerSettings : any)
    {
        // Delete any previous controls
        while (this.dropdownTable.childElementCount > 2)
            this.dropdownTable.removeChild(this.dropdownTable.lastChild);

        this.layerSettings = layerSettings;

        // Add header row
        var BorderStyle = "1px solid #cccccc";
        var headerRow = document.createElement("tr");
        headerRow.style.borderBottom = BorderStyle;
        for(var headerName of ["Wire", "Fill", "Opacity", "Layer Id"])
        {
            var headerItem = document.createElement("th");
            headerItem.className = "scenepic-dropdown-header";
            headerItem.innerHTML = headerName;
            headerRow.appendChild(headerItem);
        }
        this.dropdownTable.appendChild(headerRow);

        // Create row helper function
        var createRow = (id, label) =>
        {
            // Create wireframe checkbox
            var checkboxWireframe = document.createElement("input");
            checkboxWireframe.type = "checkbox";
            checkboxWireframe.checked = this.ShowLayerWireframe(id);
            checkboxWireframe.className = "scenepic-table-control";
            checkboxWireframe.addEventListener("change", event => { this.SetLayerWireframe(id, checkboxWireframe.checked); this.PrepareBuffers(); event.stopPropagation(); });

            // Create fill checkbox
            var checkboxFill = document.createElement("input");
            checkboxFill.type = "checkbox";
            checkboxFill.checked = this.ShowLayerFilled(id);
            checkboxFill.addEventListener("change", event => { this.SetLayerFilled(id, checkboxFill.checked); this.PrepareBuffers(); event.stopPropagation(); });

            // Create opacity slider
            var sliderOpacity = document.createElement("input");
            sliderOpacity.type = "range";
            sliderOpacity.min = "0";
            sliderOpacity.max = "100";
            sliderOpacity.value = String(100.0 * this.GetLayerOpacity(id));
            sliderOpacity.addEventListener("change", event => { this.SetLayerOpacity(id, Number(sliderOpacity.value) / 100.0); this.PrepareBuffers(); event.stopPropagation(); })
            sliderOpacity.style.width = "50px";
            sliderOpacity.style.height = "10px";

            // Add layer label
            var labelLayer = document.createElement("label");
            labelLayer.appendChild(document.createTextNode(label));

            return [checkboxWireframe, checkboxFill, sliderOpacity, labelLayer];
        };

        var addRow = (rowItems, border) =>
        {
            // Create table row
            var tr = document.createElement("tr");
            if (border)
                tr.style.borderTop = BorderStyle;
            var addControl = (el, className) =>
            {
                var td = document.createElement("td");
                td.appendChild(el);
                td.className = className;
                tr.appendChild(td);
            };
            addControl(rowItems[0], "scenepic-table-control");
            addControl(rowItems[1], "scenepic-table-control");
            addControl(rowItems[2], "scenepic-table-control");
            addControl(rowItems[3], "scenepic-table-layerid");
            this.dropdownTable.appendChild(tr);
        }

        // Add new controls
        Object.keys(layerSettings).forEach(id => addRow(createRow(id, id), false));
        addRow(createRow("<<<GLOBAL>>>", "Global"), true);
    }

    ConfigureUserInterface(command : any)
    {
        if ("PointerAltKeyMultiplier" in command) this.pointerAltKeyMultiplier = command["PointerAltKeyMultiplier"];
        if ("PointerRotationSpeed" in command) this.pointerRotationSpeed = command["PointerRotationSpeed"];
        if ("MouseWheelTranslationSpeed" in command) this.mouseWheelTranslationSpeed = command["MouseWheelTranslationSpeed"];
        if ("KeyDownSpeed" in command) this.keyDownSpeed = command["KeyDownSpeed"];
        if ("LayerDropdownVisibility" in command) this.dropdown.style.visibility = command["LayerDropdownVisibility"]
    }

    ParseFocusPoint(command : any)
    {
        var position = Misc.Base64ToFloat32Array(command["Position"]);
        if ("OrientationAxisAngle" in command)
        {
            var focusPoint = new Float32Array(6);
            focusPoint.set(position, 0);
            focusPoint.set(Misc.Base64ToFloat32Array(command["OrientationAxisAngle"]), 3);
            return focusPoint;
        }
        else
        {
            return position;
        }
    }

    SetGlobalCamera(value : Object)
    {
        if (value == null) return;

        this.globalCameraParams = value;
        for (var frameIndex = 0; frameIndex < this.frameCameraParams.length; frameIndex++)
            this.SetPerFrameCamera(frameIndex, value);

        this.SetCamera(value);
    }

    // Execute a single canvas command
    ExecuteCanvasCommand(command : any)
    {
        switch(command["CommandType"])
        {
            case "ConfigureUserInterface":
                this.ConfigureUserInterface(command);
                break;

            case "SetCamera":
                var value = command["Value"];
                this.SetGlobalCamera(value);
                break;

            case "SetFocusPoint":
                var focusPoint = this.ParseFocusPoint(command);
                this.SetGlobalFocusPoint(focusPoint);
                break;

            case "SetShading":
                var value = command["Value"];
                this.SetShading(value);
                break;

            case "SetLayerSettings":
                var layerSettings = Misc.GetDefault(command, "Value", null);
                if (layerSettings != null)
                    this.SetLayerSettings(layerSettings);
                break;

            default:
                super.ExecuteCanvasCommand(command);
                break;
        }
    }

    // Execute a single frame command
    ExecuteFrameCommand(command : any, frameIndex : number)
    {
        switch(command["CommandType"])
        {
            case "SetFocusPoint":
                var focusPoint = this.ParseFocusPoint(command);
                this.SetPerFrameFocusPoint(frameIndex, focusPoint);
                break;

            case "SetCamera":
                var value = command["Value"];
                this.SetPerFrameCamera(frameIndex, value);
                break;

            case "AddMesh":
                var meshId = command["MeshId"];
                var transform = <mat4>Misc.Base64ToFloat32Array(Misc.GetDefault(command, "Transform", mat4.create()));
                this.AddMesh(frameIndex, meshId, transform);
                break;

            case "RemoveMesh":
                var meshId = command["MeshId"];
                this.RemoveMesh(frameIndex, meshId);
                break;

            case "SetLayerSettings":
                var layerSettings = Misc.GetDefault(command, "Value", null);
                if (layerSettings != null){
                    this.SetPerFrameLayerSettings(frameIndex, layerSettings);
                    break;
                }

            default:
                super.ExecuteFrameCommand(command, frameIndex);
                break;
        }
    }

    SetCamera(params : Object)
    {
        if (params == null) return;

        if (params.hasOwnProperty("WorldToCamera")){
            this.w2vMatrix = <mat4>Misc.Base64ToFloat32Array(params["WorldToCamera"]);
            this.v2sMatrix = <mat4>Misc.Base64ToFloat32Array(params["Projection"]);
        }
        else{
            console.warn("The legacy SetCamera command is deprecated.")
            var camCenter = <vec3>Misc.Base64ToFloat32Array(params["Center"]);
            var camLookAt = <vec3>Misc.Base64ToFloat32Array(params["LookAt"]);
            var camUpDir = <vec3>Misc.Base64ToFloat32Array(params["UpDir"]);
            this.w2vMatrix = mat4.create();
            this.v2sMatrix = mat4.create();
            mat4.lookAt(this.w2vMatrix, camCenter, camLookAt, camUpDir);
            mat4.perspective(this.v2sMatrix, params["FoVYDegrees"] * DegreesToRadians, this.width / this.height, DefaultNearCropDistance, DefaultFarCropDistance);
        }
    }

    ResetView()
    {
        this.onCameraTrack = true;
        var currentFrame = this.currentFrameIndex;
        this.SetCamera(this.frameCameraParams[currentFrame]);
        this.currentFocusPoints[currentFrame] = <vec3>new Float32Array(this.initialFocusPoints[currentFrame]);
    }

    ToggleLockView(translation : boolean, orientation : boolean)
    {
        if (translation) this.lockViewXY = !this.lockViewXY;
        if (orientation) this.lockViewOrientation = !this.lockViewOrientation;
    }

    ToggleOrbitCamera()
    {
        this.orbitCamera = !this.orbitCamera;
        this.lastOrbitTime = null;
    }

    SetPerFrameFocusPoint(frameIndex : number, focusPoint : Float32Array)
    {
        if (focusPoint == null) return;

        this.initialFocusPoints[frameIndex] = focusPoint; // For reset support
        this.currentFocusPoints[frameIndex] = <vec3>new Float32Array(focusPoint); // Copy
    }

    SetPerFrameCamera(frameIndex : number, value : Object)
    {
        if ( value == null) return;

        this.frameCameraParams[frameIndex] = value; // For reset support
    }

    SetPerFrameLayerSettings(frameIndex : number, value : Object)
    {
        if ( value == null) return;
    
        this.frameLayerSettings[frameIndex] = value;
    }

    SetGlobalFocusPoint(focusPoint : Float32Array)
    {
        if (focusPoint == null) return;

        this.globalFocusPoint = focusPoint;
        for (var frameIndex = 0; frameIndex < this.initialFocusPoints.length; frameIndex++)
            this.SetPerFrameFocusPoint(frameIndex, focusPoint); // Nice side-effect: object is aliased so will stay shared when user moves focus point
    }

    SetShading(params : Object)
    {
        // Background color
        this.bgColor = <vec4>Misc.Base64ToFloat32Array(params["BackgroundColor"]);

        // Lighting
        this.ambientLightColor = <vec3>Misc.Base64ToFloat32Array(params["AmbientLightColor"]);
        this.directionalLightColor = <vec3>Misc.Base64ToFloat32Array(params["DirectionalLightColor"]);
        this.directionalLightDir = <vec3>Misc.Base64ToFloat32Array(params["DirectionalLightDir"]);
        vec3.normalize(this.directionalLightDir, this.directionalLightDir);
    }

    AllocateFrame()
    {
        this.frameInstances.push([]);
        this.initialFocusPoints.push(this.globalFocusPoint);
        this.currentFocusPoints.push(this.globalFocusPoint);
        this.frameCameraParams.push(this.globalCameraParams);
    }

    DeallocateFrame(frameIndex : number)
    {
        this.frameInstances[frameIndex] = [];
    }

    AddMesh(frameIndex : number, meshId : string, meshTransform : mat4)
    {
        // Add the mesh instance
        var instance = new MeshInstance(meshId, meshTransform);
        var instances = this.frameInstances[frameIndex];
        instances.push(instance);

        let mesh = <Mesh>this.allMeshes[meshId];
        if(mesh.layerId != null && !(mesh.layerId in this.layerSettings)){
            this.layerSettings[mesh.layerId] = {};
            this.SetLayerSettings(this.layerSettings);
        }

        // Update display if this is the selected mesh
        if (this.currentFrameIndex == frameIndex)
        {
            if (!(meshId in this.allMeshes))
                this.SetWarning("Mesh " + meshId + " does not exist!");

            // Rebuild buffers as needed
            this.PrepareBuffers();
        }
    }

    RemoveMesh(frameIndex : number, meshId : string)
    {
        // Add the mesh id
        var instances = this.frameInstances[frameIndex];
        var meshIndex = 0;
        var edited = false;
        while (meshIndex < instances.length)
        {
            if (instances[meshIndex].meshId == meshId)
            {
                // Remove
                instances.splice(meshIndex, 1);
                edited = true;
            }
            else
            {
                meshIndex++;
            }
        }

        // Update display if this is the selected mesh
        if (edited && this.currentFrameIndex == frameIndex)
            this.PrepareBuffers();
    }

    NotifyMeshUpdated(meshId : string)
    {
        if (this.frameInstances.length == 0) return; // No frames
        var currentFrameInstances = this.frameInstances[this.currentFrameIndex];
        for(var instance of currentFrameInstances)
        {
            if (instance.meshId == meshId)
            {
                // Clear up any existing buffers
                if (meshId in this.meshBuffers && this.meshBuffers[meshId] != null)
                this.meshBuffers[meshId].Finalize();

                // Recreate new buffers using updated mesh data
                this.meshBuffers[meshId] = this.GetMeshBuffers(this.allMeshes[meshId]);
            }
        }
    }

    private GetMeshBuffers(mesh : Mesh)
    {
        var textureId = mesh.textureId;
        var textureSrc = null;
        if (textureId != null)
        {
            var textureSrc = this.objectCache.GetObject(textureId);
            if (textureSrc == null)
                return null; // Not able to create buffers yet
        }
        return new WebGLMeshBuffers(this.gl, this.sp, mesh, textureSrc);
    }

    NotifyTextureUpdated(textureId : string)
    {
        if (this.frameInstances.length == 0) return; // No frames
        var currentFrameInstances = this.frameInstances[this.currentFrameIndex];
        for(var instance of currentFrameInstances)
        {
            var meshId = instance.meshId;
            var mesh = <Mesh>this.allMeshes[meshId];

            if (mesh.textureId == textureId)
            {
                if (this.meshBuffers[meshId] != null)
                    this.meshBuffers[meshId].Finalize(); // Clear up existing buffers
                this.meshBuffers[meshId] = this.GetMeshBuffers(mesh); // Recreate
            }
        }
    }

    PrepareBuffers() : void
    {
        super.PrepareBuffers();
        // Get new set of meshIds
        var meshIds : string[] = [];
        if (this.gl != null && this.currentFrameIndex < this.frameInstances.length) // Check for e.g. webgl context lost
        {
            for(var instance of this.frameInstances[this.currentFrameIndex])
                meshIds.push(instance.meshId);
        }

        // Get set of meshIds that need turning in to buffers
        var meshIdsToAdd : string[] = [];
        for(var meshId of meshIds)
        {
            var mesh = <Mesh>this.allMeshes[meshId];
            if (mesh != null && this.IsLayerVisible(mesh.layerId) && !(meshId in this.meshBuffers)) // Visible and not already cached
            {
                meshIdsToAdd.push(meshId);

                // Request texture image be cached
                if (mesh.textureId != null)
                    this.objectCache.AddUser(mesh.textureId);
            }
        }

        // Finalize meshIds not currently used
        for(var meshId in this.meshBuffers)
        {
            var mesh = <Mesh>this.allMeshes[meshId];
            if (!this.IsLayerVisible(mesh.layerId) || meshIds.indexOf(meshId) == -1) // Invisible or no longer present
            {
                if (meshId in this.meshBuffers)
                {
                    if (this.meshBuffers[meshId] != null)
                        this.meshBuffers[meshId].Finalize();
                    delete this.meshBuffers[meshId];
                }
                // Remove user of texture image from cache
                if (mesh.textureId != null)
                    this.objectCache.RemoveUser(mesh.textureId);
            }
        }

        // Loop over meshes
        for(var meshId of meshIdsToAdd)
        {
            var mesh = <Mesh>this.allMeshes[meshId];
            this.meshBuffers[meshId] = this.GetMeshBuffers(mesh);
        }
    }

    GetCurrentFocusPointInViewSpace()
    {
        var focusPoint = this.currentFocusPoints[this.currentFrameIndex];
        var focusPointPosition = <vec3>focusPoint.subarray(0, 3);
        var focusPointView = vec3.create();
        vec3.transformMat4(focusPointView, focusPointPosition, this.w2vMatrix);
        return focusPointView;
    }

    ComputeCameraTwist(oldX : number, oldY : number, newX : number, newY : number)
    {
        // Compute projection of focal point into canvas image
        var focusPointImage = vec3.create();
        vec3.transformMat4(focusPointImage, this.GetCurrentFocusPointInViewSpace(), this.v2sMatrix);
        var focusX = (focusPointImage[0] + 1.0) * 0.5 * this.width;
        var focusY = (-focusPointImage[1] + 1.0) * 0.5 * this.height;

        // Compute rotation angle
        var angleInitial = Math.atan2(oldY - focusY, oldX - focusX);
        var angleNew = Math.atan2(newY - focusY, newX - focusX);
        return angleInitial - angleNew;
    }

    SetFocusPointPositionFromPixelCoordinates(pixelX : number, pixelY : number)
    {
        if (this.lockViewXY || this.lockViewOrientation) return;

        var focusPointView = this.GetCurrentFocusPointInViewSpace();

        var s2vMatrix = mat4.create();
        var v2wMatrix = mat4.create();
        mat4.invert(s2vMatrix, this.v2sMatrix);
        mat4.invert(v2wMatrix, this.w2vMatrix);

        // Convert from pixel to screen coordinates
        var clientRect = this.htmlCanvas.getBoundingClientRect();
        var screen = vec3.fromValues(2.0 * (pixelX / clientRect.width - 0.5), 2.0 * (0.5 - pixelY / clientRect.height), 1.0);

        // Convert from screen to view coordinates
        var view = vec3.create();
        vec3.transformMat4(view, screen, s2vMatrix);
        vec3.scale(view, view, focusPointView[2] / view[2]) // Fix z value to existing focus point z value

        // Convert from view to world coordinates
        var focusPoint = this.currentFocusPoints[this.currentFrameIndex];
        var focusPointPosition = <vec3>focusPoint.subarray(0, 3);
        vec3.transformMat4(focusPointPosition, view, v2wMatrix);
    }

    ComputeFocusPointRelativeViewSpaceTranslation(oldX : number, oldY : number, newX : number, newY : number)
    {
        var focusPointZ = Math.abs(this.GetCurrentFocusPointInViewSpace()[2]);

        var clientRect = this.htmlCanvas.getBoundingClientRect();
        var s2vMatrix = mat4.create();
        mat4.invert(s2vMatrix, this.v2sMatrix);

        var oldScreen = vec3.fromValues(2.0 * (oldX / clientRect.width - 0.5), 2.0 * (0.5 - oldY / clientRect.height), 1.0);
        var oldView = vec3.create();
        vec3.transformMat4(oldView, oldScreen, s2vMatrix);
        vec3.scale(oldView, oldView, focusPointZ / oldView[2]) // Fix z

        var newScreen = vec3.fromValues(2.0 * (newX / clientRect.width - 0.5), 2.0 * (0.5 - newY / clientRect.height), 1.0);
        var newView = vec3.create();
        vec3.transformMat4(newView, newScreen, s2vMatrix);
        vec3.scale(newView, newView, focusPointZ / newView[2]) // Fix z

        return vec3.fromValues(oldView[0] - newView[0], oldView[1] - newView[1], 0.0); // oldView[2] - newView[2] should be == 0.0
    }

    RotateCamera(rotateAboutX : number, rotateAboutY : number, rotateAboutZ : number)
    {
        this.onCameraTrack = false;
        var delta = vec3.fromValues(rotateAboutX, rotateAboutY, rotateAboutZ);

        // Compute focus point location in view space
        var focusPointView = this.GetCurrentFocusPointInViewSpace();

        // Compute transformation matrix
        var transform = mat4.create();

        // Translate to be centered on focus point
        mat4.translate(transform, transform, focusPointView);

        // Apply rotation
        mat4.rotateZ(transform, transform, delta[2]); // Rotate Z
        mat4.rotateY(transform, transform, delta[1]); // Rotate Y
        mat4.rotateX(transform, transform, delta[0]); // Rotate X

        // Translate back
        vec3.negate(focusPointView, focusPointView);
        mat4.translate(transform, transform, focusPointView);

        // Apply change to w2v matrix
        mat4.multiply(this.w2vMatrix, transform, this.w2vMatrix);
    }

    SetCameraRotationalVelocity(rotateLeft: number, rotateDown: number)
    {
        this.cameraRotationalVelocity[0] = rotateLeft;
        this.cameraRotationalVelocity[1] = rotateDown;
    }

    SwivelCamera(rotateLeft: number, rotateDown: number)
    {
        this.onCameraTrack = false;

        let transform = mat4.create();
        mat4.rotateY(transform, transform, rotateLeft);
        mat4.rotateX(transform, transform, rotateDown);
        
        mat4.multiply(this.w2vMatrix, transform, this.w2vMatrix);
    }

    MoveCamera(moveForward: number, moveRight: number, moveUp: number)
    {
        this.onCameraTrack = false;

        let transform = mat4.create();
        mat4.fromTranslation(transform, [moveRight, moveUp, moveForward])
        mat4.multiply(this.w2vMatrix, transform, this.w2vMatrix);
    }

    ScaleCamera(factor : number)
    {
        this.onCameraTrack = false;

        // Compute focus point location in view space
        var focusPointView = this.GetCurrentFocusPointInViewSpace();

        // Compute transformation matrix
        var transform = mat4.create();

        // Translate to be centered on focus point
        mat4.translate(transform, transform, focusPointView);

        // Apply scale factor
        mat4.scale(transform, transform, [factor, factor, factor]);

        // Translate back
        vec3.negate(focusPointView, focusPointView);
        mat4.translate(transform, transform, focusPointView);

        // Apply change to w2v matrix
        mat4.multiply(this.w2vMatrix, transform, this.w2vMatrix);
    }

    TranslateCamera(delta : vec3)
    {
        this.onCameraTrack = false;

        // Compute translation matrix
        var transform = mat4.create();
        mat4.fromTranslation(transform, delta);

        // Translate
        mat4.multiply(this.w2vMatrix, transform, this.w2vMatrix);
    }

    IsLayerVisible(layerId : string) : boolean
    {
        var showFilled = this.ShowLayerFilled(layerId) && this.globalFill;
        var showWireframe = this.ShowLayerWireframe(layerId) || (this.ShowLayerFilled(layerId) && this.globalWireframe);
        var opacity = this.GetLayerOpacity(layerId) * this.globalOpacity;
        return (showFilled || showWireframe) && opacity > 0.0;
    }

    ShowLayerFilled(layerId : string) : boolean
    {
        if (layerId == "<<<GLOBAL>>>")
            return this.globalFill;
        if (layerId == null)
            return true;
        if (!(layerId in this.layerSettings))
            return true;
        if ("filled" in this.layerSettings[layerId] && !this.layerSettings[layerId]["filled"])
            return false;
        return true;
    }

    SetLayerFilled(layerId : string, filled : boolean)
    {
        if (layerId == null)
            return;
        if (layerId == "<<<GLOBAL>>>")
        {
            this.globalFill = filled;
        }
        else
        {
            if (!(layerId in this.layerSettings))
                this.layerSettings[layerId] = {};
            this.layerSettings[layerId]["filled"] = filled;
        }

        // Prepare buffers and force redraw
        this.PrepareBuffers();
    }

    ShowLayerWireframe(layerId : string) : boolean
    {
        if (layerId == "<<<GLOBAL>>>")
            return this.globalWireframe;
        if (layerId == null)
            return false;
        if (!(layerId in this.layerSettings))
            return false;
        if ("wireframe" in this.layerSettings[layerId] && this.layerSettings[layerId]["wireframe"])
            return true;
        return false;
    }

    SetLayerWireframe(layerId : string, wireframe : boolean)
    {
        if (layerId == null)
            return;

        if (layerId == "<<<GLOBAL>>>")
        {
            this.globalWireframe = wireframe;
        }
        else
        {
            if (!(layerId in this.layerSettings))
                this.layerSettings[layerId] = {};
            this.layerSettings[layerId]["wireframe"] = wireframe;
        }

        // Prepare buffers and force redraw
        this.PrepareBuffers();
    }

    GetLayerOpacity(layerId : string)
    {
        if (layerId == "<<<GLOBAL>>>")
            return this.globalOpacity;
        if (layerId == null || !(layerId in this.layerSettings) || !("opacity" in this.layerSettings[layerId]))
            return 1.0; // Opaque
        else
            return <number>this.layerSettings[layerId]["opacity"];
    }

    SetLayerOpacity(layerId : string, opacity : number)
    {
        if (layerId == null)
            return;

        if (layerId == "<<<GLOBAL>>>")
        {
            this.globalOpacity = opacity;
        }
        else
        {
            if (!(layerId in this.layerSettings))
                this.layerSettings[layerId] = {};
            this.layerSettings[layerId]["opacity"] = opacity;
        }

        // Prepare buffers and force redraw
        this.PrepareBuffers();
    }

    GetLayerRenderOrder(layerId : string)
    {
        if (layerId == null || !(layerId in this.layerSettings) || !("renderOrder" in this.layerSettings[layerId]))
            return 1e3; // Last layer - bit of a hack
        else
            return <number>this.layerSettings[layerId]["renderOrder"];
    }

    AllMeshBuffersReady()
    {
        if (this.frameInstances.length > 0)
        {
            for(var instance of this.frameInstances[this.currentFrameIndex])
            {
                // Look up mesh
                var meshId = instance.meshId;
                var mesh = <Mesh>this.allMeshes[meshId];

                // Check mesh layer visibility
                if (mesh == null || !this.IsLayerVisible(mesh.layerId))
                    continue;

                // Check buffer is ready
                if (this.meshBuffers[meshId] == null)
                    return false; // Don't draw anything until all buffers ready (to prevent possible flicker)
            }
        }
        return true;
    }

    // Override base class implementation to deal with focus point lock
    ShowFrame(frameIndex : number)
    {
        frameIndex = Math.min(frameIndex, this.frameIds.length - 1); // Just in case

        // Get old and new focus points
        var oldFocusPoint = this.currentFocusPoints[this.currentFrameIndex];
        var newFocusPoint = this.currentFocusPoints[frameIndex];

        if (this.frameLayerSettings[frameIndex])
        {
            let layerSettings = this.frameLayerSettings[frameIndex];
            for(let layerId in layerSettings)
            {
                this.SetLayerFilled(layerId, layerSettings[layerId]["filled"]);
                this.SetLayerWireframe(layerId, layerSettings[layerId]["wireframe"]);
                this.SetLayerOpacity(layerId, layerSettings[layerId]["opacity"]);
                if ( "renderOrder" in layerSettings )
                {
                    this.layerSettings[layerId]["renderOrder"] = layerSettings[layerId]["renderOrder"]
                }

                this.PrepareBuffers();
            }
        }

        var w2vMatrixOld = mat4.create();
        mat4.copy(w2vMatrixOld, this.w2vMatrix);

        // Lock camera rotation to focus point?
        if (this.lockViewOrientation && oldFocusPoint.length == 6 && newFocusPoint.length == 6) // Does the focus point contain an axis angle rotation?
        {
            var aaToMat = (axisAngle : vec3) => { var angle = vec3.length(axisAngle); var axis = vec3.create(); vec3.normalize(axis, axisAngle); var rotation = mat4.create(); mat4.fromRotation(rotation, angle, axis); return rotation; }

            var oldRotation = aaToMat(<vec3>oldFocusPoint.subarray(3));
            var newRotation = aaToMat(<vec3>newFocusPoint.subarray(3));

            // Compute delta
            var deltaRotation = oldRotation;
            mat4.invert(newRotation, newRotation);
            mat4.multiply(deltaRotation, deltaRotation, newRotation);

            // Apply delta
            mat4.multiply(this.w2vMatrix, this.w2vMatrix, deltaRotation);
        }

        // Lock camera translation to focus point?
        if (this.lockViewXY || this.lockViewOrientation)
        {
            var oldFPView = vec3.create();
            vec3.transformMat4(oldFPView, <vec3>oldFocusPoint.subarray(0, 3), w2vMatrixOld);

            var newFPView = vec3.create();
            vec3.transformMat4(newFPView, <vec3>newFocusPoint.subarray(0, 3), this.w2vMatrix);

            // Compute delta
            var delta = vec3.create();
            vec3.subtract(delta, oldFPView, newFPView);

            // Apply delta
            var deltaMat = mat4.create();
            mat4.fromTranslation(deltaMat, delta);
            mat4.multiply(this.w2vMatrix, deltaMat, this.w2vMatrix);
        }

        // Call base class
        return super.ShowFrame(frameIndex);
    }

    Render()
    {
        // Check for gl to be ready
        if (this.gl == null)
            return;

        // Wait until all resources are ready
        if (!this.AllMeshBuffersReady())
            return;

        if(this.firstPerson)
        {
            this.MoveCamera(this.cameraVelocity[0], this.cameraVelocity[1], this.cameraVelocity[2]);
            if(this.cameraRotationalVelocity[0] != 0 || this.cameraRotationalVelocity[1] != 0)
            {
                this.SwivelCamera(this.cameraRotationalVelocity[0], this.cameraRotationalVelocity[1]);
            }
        }

        this.gl.clearColor(this.bgColor[0], this.bgColor[1], this.bgColor[2], this.bgColor[3]);
        this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT); // Applies to whole canvas
        this.gl.viewport(0, 0, this.htmlCanvas.width, this.htmlCanvas.height);
        this.RenderViewport();
    }

    // Render scene method with given w2v and v2s matrices
    RenderViewport()
    {
        if (this.onCameraTrack){
            this.SetCamera(this.frameCameraParams[this.currentFrameIndex])
        }

        // Get copy of w2vMatrix
        var w2vMatrix = mat4.create();
        mat4.copy(w2vMatrix, this.w2vMatrix);

        // Compute w2vMatrix and v2sMatrix
        var v2sMatrix = this.v2sMatrix;

        // Set up rendering parameters
        this.gl.enable(this.gl.DEPTH_TEST);
        this.gl.cullFace(this.gl.BACK);
        this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);

        // Set lighting
        this.gl.uniform3fv(this.sp.ambientLightColorPtr, this.ambientLightColor);
        this.gl.uniform3fv(this.sp.directionalLightColorPtr, this.directionalLightColor);
        this.gl.uniform3fv(this.sp.directionalLightDirPtr, this.directionalLightDir);

        // Get list of meshes to draw
        var opaqueMeshData = [];
        var transparentMeshData = [];
        if (this.frameInstances.length > 0)
        {
            var instances = this.frameInstances[this.currentFrameIndex];

            for(var meshInstance of instances) // Loop over mesh instances
            {
                // Look up mesh
                var meshId = meshInstance.meshId;
                var mesh = <Mesh>this.allMeshes[meshId];
                if (mesh == null) continue;

                // Check mesh layer visibility and opacity
                var filled = this.ShowLayerFilled(mesh.layerId) && this.globalFill;
                var wireframe = this.ShowLayerWireframe(mesh.layerId) || (this.ShowLayerFilled(mesh.layerId) && this.globalWireframe);
                var opacity = this.GetLayerOpacity(mesh.layerId) * this.globalOpacity;
                if (!(filled || wireframe) || opacity == 0.0)
                    continue;

                // Get buffer
                var buffer = <WebGLMeshBuffers>this.meshBuffers[meshId];
                if (buffer == null) continue;

                // Compute per-mesh transform
                var m2vMatrix = mat4.create();
                if (mesh.cameraSpace)
                    mat4.copy(m2vMatrix, meshInstance.transform);
                else
                    mat4.multiply(m2vMatrix, w2vMatrix, meshInstance.transform);

                // Set properties for labels
                if (mesh.isLabel)
                {
                    var textureSrc = this.objectCache.GetObject(mesh.textureId);
                    if (textureSrc != null)
                    {
                        buffer.labelWidthNormalized = textureSrc.desiredWidthPixels / this.htmlCanvas.width;
                        buffer.labelHeightNormalized = textureSrc.desiredHeightPixels / this.htmlCanvas.height;

                        buffer.labelTranslateScreenX = textureSrc.proportionTranslateScreenX * buffer.labelWidthNormalized;
                        buffer.labelTranslateScreenY = textureSrc.proportionTranslateScreenY * buffer.labelHeightNormalized;
                        buffer.labelTranslateWorldX = Misc.Sign(textureSrc.proportionTranslateScreenX) * textureSrc.offsetDistance;
                        buffer.labelTranslateWorldY = Misc.Sign(textureSrc.proportionTranslateScreenY) * textureSrc.offsetDistance;
                    }
                }

                // Is this transparent or not?
                if (opacity < 1.0 || (mesh.textureId != null && (mesh.useTextureAlpha || mesh.isLabel)))
                {
                    // Transform center of mass to view coordinates to get z-ordering
                    var viewCoM = vec3.create();
                    vec3.transformMat4(viewCoM, mesh.centerOfMass, m2vMatrix);
                    var viewDistance = viewCoM[2];

                    // Additionally sort using render order which overrides view distance
                    var renderOrder = this.GetLayerRenderOrder(mesh.layerId);
                    viewDistance += renderOrder * 1e4; // 1e4 is hack to force rendering in batches according to render order.  Will break down if viewDistance can be larger than 1e4.

                    transparentMeshData.push([mesh, m2vMatrix, buffer, opacity, filled, wireframe, viewDistance]);
                }
                else
                {
                    opaqueMeshData.push([mesh, m2vMatrix, buffer, 1.0, filled, wireframe]);
                }
            }
        }

        // Draw opaque meshes
        this.gl.depthMask(true);
        this.gl.disable(this.gl.BLEND);
        for(var data of opaqueMeshData)
        {
            this.RenderMesh(v2sMatrix, data[0], data[1], data[2], data[3], data[4], data[5]);
        }

        // Draw transparent meshes, sorted by decreasing distance from camera
        this.gl.depthMask(false);
        this.gl.enable(this.gl.BLEND);
        var sortedTransparentMeshData = transparentMeshData.sort((a,b) => a[6] - b[6]);
        for(var data of sortedTransparentMeshData)
        {
            this.RenderMesh(v2sMatrix, data[0], data[1], data[2], data[3], data[4], data[5]);
        }

        // Show focus point
        if (this.showFocusPoint)
        {
            this.gl.disable(this.gl.DEPTH_TEST);
            var focusPoint = this.currentFocusPoints[this.currentFrameIndex];
            var focusPointPosition = <vec3>focusPoint.subarray(0, 3);
            var focusPointView = vec3.create();
            vec3.transformMat4(focusPointView, focusPointPosition, w2vMatrix);
            var size = 0.02 * Math.abs(focusPointView[2]);
            mat4.fromRotationTranslationScale(this.focusPointMeshBuffer.m2wMatrix, quat.create(), focusPointPosition, <vec3>new Float32Array([size, size, size]));
            this.focusPointMeshBuffer.RenderBuffer(v2sMatrix, w2vMatrix, 1.0, true, false);
        }

        // If enabled, orbit the camera
        if (this.orbitCamera)
        {
            // Adjust for any framerate changes
            var now = new Date();
            if (this.lastOrbitTime != null)
            {
                var deltaTime = now.getTime() - this.lastOrbitTime.getTime();
                this.RotateCamera(0.0, 0.0025 * deltaTime, 0.0); // Rotate about y axis
            }
            this.lastOrbitTime = now;
        }
    }

    RenderMesh(v2sMatrix : mat4, mesh : Mesh, m2vMatrix : mat4, buffer : WebGLMeshBuffers, opacity : number, renderFilled : boolean, renderWireframe : boolean)
    {
        // Optionally turn off back-face culling
        if (mesh.doubleSided)
            this.gl.disable(this.gl.CULL_FACE);
        else
            this.gl.enable(this.gl.CULL_FACE);

        // Render
        buffer.RenderBuffer(v2sMatrix, m2vMatrix, opacity, renderFilled, renderWireframe);
    }
}
