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

// 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;
    }
}

class MeshData {
    constructor(
        public mesh: Mesh,
        public m2wMatrix: mat4,
        public m2vMatrix: mat4,
        public buffer: WebGLMeshBuffers,
        public opacity: number,
        public filled: boolean,
        public wireframe: boolean,
        public viewDistance: number) { }

    public Render(gl: WebGL2RenderingContext, v2sMatrix: mat4) {
        // Optionally turn off back-face culling
        if (this.mesh.doubleSided)
            gl.disable(gl.CULL_FACE);
        else
            gl.enable(gl.CULL_FACE);

        // Render
        this.buffer.RenderBuffer(v2sMatrix, this.m2vMatrix, this.opacity, this.filled, this.wireframe);
    }

    public ComputeCentroid(): vec3 {
        let centroid = vec3.create();
        const norm = 1.0 / this.mesh.CountVertices();
        for (let i = 0, j = 0; i < this.mesh.CountVertices(); i++, j += this.mesh.ElementsPerVertex) {
            let p = vec3.transformMat4(vec3.create(),
                <vec3>this.mesh.vertexBuffer.subarray(j, j + 3),
                this.m2wMatrix);
            vec3.scale(p, p, norm)
            vec3.add(centroid, centroid, p);
        }

        if (this.mesh.CountInstances() == 1) {
            return centroid;
        }

        let instanceCentroid = vec3.create();
        const instanceNorm = 1.0 / this.mesh.CountInstances();
        for (let i = 0, j = 0; i < this.mesh.CountInstances(); i++, j += this.mesh.ElementsPerInstance) {
            let p = vec3.add(vec3.create(),
                <vec3>this.mesh.instanceBuffer.subarray(j, j + 3),
                instanceCentroid);
            vec3.scale(p, p, instanceNorm);
            vec3.add(instanceCentroid, instanceCentroid, p);
        }

        return instanceCentroid;
    }
}

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

    // Dictionary from layerId to layer settings (wireframe, filled, opacity)
    layerSettings: { [layerId: string]: { [key: string]: number | boolean } } = {};
    layerIds: string[] = [];

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

    // Mesh picker
    setFocusToPicked: boolean;
    pickPoint: vec2;
    meshPicker: MeshPicker;

    // 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 = vec3.fromValues(0.0, 0.0, 0.0);
    cameraRotationalVelocity = vec2.fromValues(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("webgl2");

            // 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, "defaultVertex", "defaultFragment");

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

        // Create picker
        this.meshPicker = new MeshPicker(this.gl, this.width, this.height);

        // 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);
    }

    HandlePointerMoveWithTwist(point: vec2, twistAngle: number, event: PointerEvent) {
        const countPointers = this.pointerCoords.size;
        if (countPointers == 0) return;

        const old = this.pointerCoords.get(event.pointerId);
        let delta = vec2.subtract(vec2.create(), point, old);
        if (event.altKey) {
            vec2.scale(delta, delta, this.pointerAltKeyMultiplier);
        }

        if (this.FirstPerson) {
            const init = this.initPointerCoords.get(event.pointerId);
            let diff = vec2.subtract(vec2.create(), point, init);

            const length = vec2.length(diff);
            const deadZone = 10;
            if (length > deadZone) {
                let scale = Math.min(2, (length - deadZone) / deadZone);
                vec2.scale(diff, diff, scale / length);
            }
            else {
                diff = vec2.create();
            }

            vec2.scale(diff, diff, this.pointerRotationSpeed);
            this.SetCameraRotationalVelocity(diff);
        }
        else if (countPointers == 2) {
            // Deal with pinch-zoom
            const pinchZoom = this.PinchZoom(point, event);

            // Change in distances
            var zOld = this.GetCurrentFocusPointInViewSpace()[2];
            var zNew = zOld / pinchZoom.distanceRatio;
            this.TranslateCamera(vec3.fromValues(0.0, 0.0, zNew - zOld));

            const focusDelta = this.ComputeFocusPointRelativeViewSpaceTranslation(old, point);
            this.TranslateCamera(focusDelta);

            this.RotateCamera(0.0, 0.0, -pinchZoom.angleDelta);
        }
        else {
            // Deal with basic events
            if (event.ctrlKey) {
                // Treat as twist of camera
                this.RotateCamera(0.0, 0.0, twistAngle);
            }
            else if (this.showFocusPoint) {
                // Translate the 3D center of rotation
                this.SetFocusPointPositionFromPixelCoordinates(point);
            }
            else if (event.shiftKey) // Treat as translation of camera
            {
                const focusDelta = this.ComputeFocusPointRelativeViewSpaceTranslation(old, point);
                this.TranslateCamera(focusDelta);
            }
            else if (countPointers == 1) // Treat as rotation of camera about center of rotation
            {
                vec2.scale(delta, delta, this.pointerRotationSpeed);
                // NB y and x are deliberately crossed over
                this.RotateCamera(delta[1], delta[0], 0.0);
            }
        }

        super.HandlePointerMove(point, event);
    }

    HandlePointerUp(event: PointerEvent) {
        this.SetCameraRotationalVelocity(vec2.create());
        super.HandlePointerUp(event);
    }

    HandleMouseWheel(event: WheelEvent) {
        let deltaZ = -event.deltaY * this.mouseWheelTranslationSpeed;

        if (event.altKey) {
            deltaZ *= this.pointerAltKeyMultiplier;
        }

        var delta = vec3.fromValues(0.0, 0.0, deltaZ);

        this.TranslateCamera(delta);
    }

    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): [boolean, boolean] {
        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;
        this.layerIds = [null];
        for (let layerId in layerSettings) {
            this.layerIds.push(layerId);
        }

        // 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] = 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] = 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(point: vec2, event: PointerEvent): number {
        const old = this.pointerCoords.get(event.pointerId);
        if (old == undefined) {
            return 0;
        }

        // Compute projection of focal point into canvas image
        var focusPointImage = vec3.create();
        vec3.transformMat4(focusPointImage, this.GetCurrentFocusPointInViewSpace(), this.v2sMatrix);
        const focus = vec2.fromValues((focusPointImage[0] + 1.0) * 0.5 * this.width,
            (-focusPointImage[1] + 1.0) * 0.5 * this.height);

        // Compute rotation angle
        const angleInitial = Math.atan2(old[1] - focus[1], old[0] - focus[0]);
        const angleNew = Math.atan2(point[1] - focus[1], point[0] - focus[0]);
        return angleInitial - angleNew;
    }

    SetFocusPointPositionFromPixelCoordinates(pixel: vec2) {
        if (this.lockViewXY || this.lockViewOrientation) return;

        this.pickPoint = pixel;
        this.setFocusToPicked = true;
    }

    ComputeFocusPointRelativeViewSpaceTranslation(old: vec2, point: vec2) {
        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 * (old[0] / clientRect.width - 0.5), 2.0 * (0.5 - old[1] / 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 * (point[0] / clientRect.width - 0.5), 2.0 * (0.5 - point[1] / 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);

        // 3 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(rotate: vec2) {
        vec2.copy(this.cameraRotationalVelocity, rotate);
    }

    SwivelCamera(rotate: vec2) {
        this.onCameraTrack = false;

        let transform = mat4.create();
        mat4.rotateY(transform, transform, rotate[0]);
        mat4.rotateX(transform, transform, rotate[1]);

        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;
    }

    ToggleLayerFilled(index: number) {
        if (index >= this.layerIds.length) {
            return
        }

        let layerId = this.layerIds[index];
        let filled = this.ShowLayerFilled(layerId);
        this.SetLayerFilled(layerId, !filled);
    }

    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);
            }
        }

        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() {
        const gl = this.gl;
        const v2sMatrix = this.v2sMatrix;

        if (this.onCameraTrack) {
            this.SetCamera(this.frameCameraParams[this.currentFrameIndex])
        }

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


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

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

        // Get list of meshes to draw
        let opaqueMeshData: MeshData[] = [];
        let transparentMeshData: MeshData[] = [];
        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(new MeshData(mesh, meshInstance.transform, m2vMatrix, buffer, opacity, filled, wireframe, viewDistance));
                }
                else {
                    opaqueMeshData.push(new MeshData(mesh, meshInstance.transform, m2vMatrix, buffer, 1.0, filled, wireframe, 0));
                }
            }
        }

        // Draw opaque meshes
        gl.depthMask(true);
        gl.disable(gl.BLEND);

        opaqueMeshData.forEach(data => data.Render(gl, v2sMatrix));

        // Draw transparent meshes, sorted by decreasing distance from camera
        gl.depthMask(false);
        gl.enable(gl.BLEND);
        var sortedTransparentMeshData = transparentMeshData.sort((a, b) => a.viewDistance - b.viewDistance);
        sortedTransparentMeshData.forEach(data => data.Render(gl, v2sMatrix));

        // Show focus point
        if (this.showFocusPoint) {
            gl.disable(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;
        }

        if (this.setFocusToPicked && opaqueMeshData.length > 0) {
            let buffers: [WebGLMeshBuffers, mat4][] = [];
            for (let i = 0; i < opaqueMeshData.length; i++) {
                let meshData = opaqueMeshData[i];
                if (meshData.mesh.cameraSpace) {
                    continue;
                }

                meshData.buffer.id = i + 1;
                buffers.push([meshData.buffer, meshData.m2vMatrix]);
            }
            const picked = this.meshPicker.Pick(gl, buffers, this.pickPoint, v2sMatrix)
            if (picked == 0) {
                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 * (this.pickPoint[0] / clientRect.width - 0.5),
                    2.0 * (0.5 - this.pickPoint[1] / 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);
            } else {
                const pickedCentroid = opaqueMeshData[picked - 1].ComputeCentroid();
                var focusPoint = this.currentFocusPoints[this.currentFrameIndex];
                focusPoint[0] = pickedCentroid[0];
                focusPoint[1] = pickedCentroid[1];
                focusPoint[2] = pickedCentroid[2];
            }

            this.setFocusToPicked = false;
        }
    }
}
