# Copyright (c) 2017 Anki, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License in the file LICENSE.txt or at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

'''This module provides a 3D visualizer for Cozmo's world state.

It uses PyOpenGL, a Python OpenGL 3D graphics library which is available on most
platforms. It also depends on the Pillow library for image processing.

The easiest way to make use of this viewer is to call :func:`cozmo.run_program`
with `use_3d_viewer=True` or :func:`cozmo.run.connect_with_3dviewer`.

Warning:
    This package requires Python to have the PyOpenGL package installed, along
    with an implementation of GLUT (OpenGL Utility Toolkit).

    To install the Python packages do ``pip3 install --user 'cozmo[3dviewer]'``

    On Windows and Linux you must also install freeglut (macOS / OSX has one
    preinstalled).

    On Linux: ``sudo apt-get install freeglut3``

    On Windows: Go to http://freeglut.sourceforge.net/ to get a ``freeglut.dll``
    file. It's included in any of the `Windows binaries` downloads. Place the DLL
    next to your Python script, or install it somewhere in your PATH to allow any
    script to use it."
'''


# __all__ should order by constants, event classes, other classes, functions.
__all__ = ['DynamicTexture', 'LoadedObjFile', 'OpenGLViewer', 'OpenGLWindow',
           'RenderableObject',
           'LoadMtlFile']


import collections
import math
import time
from pkg_resources import resource_stream

from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *

from PIL import Image

from .exceptions import InvalidOpenGLGlutImplementation, RobotBusy
from . import logger
from . import nav_memory_map
from . import objects
from . import robot
from . import util
from . import world


# Check if OpenGL imported correctly and bound to a valid GLUT implementation


def _glut_install_instructions():
    if sys.platform.startswith('linux'):
        return "Install freeglut: `sudo apt-get install freeglut3`"
    elif sys.platform.startswith('darwin'):
        return "GLUT should already be installed by default on macOS!"
    elif sys.platform in ('win32', 'cygwin'):
        return "Install freeglut: You can download it from http://freeglut.sourceforge.net/ \n"\
            "You just need the `freeglut.dll` file, from any of the 'Windows binaries' downloads. "\
            "Place the DLL next to your Python script, or install it somewhere in your PATH "\
            "to allow any script to use it."
    else:
        return "(Instructions unknown for platform %s)" % sys.platform


def _verify_glut_init():
    # According to the documentation, just checking bool(glutInit) is supposed to be enough
    # However on Windows with no GLUT DLL that can still pass, even if calling the method throws a null function error.
    if bool(glutInit):
        try:
            glutInit()
            return True
        except OpenGL.error.NullFunctionError as e:
            pass

    return False


if not _verify_glut_init():
    raise InvalidOpenGLGlutImplementation(_glut_install_instructions())


_resource_package = __name__  # All resources are in subdirectories from this file's location


# Global viewer instance
opengl_viewer = None  # type: OpenGLViewer


class DynamicTexture:
    """Wrapper around An OpenGL Texture that can be dynamically updated."""

    def __init__(self):
        self._texId =  glGenTextures(1)
        self._width = None
        self._height = None
        # Bind an ID for this texture
        glBindTexture(GL_TEXTURE_2D, self._texId)
        # Use bilinear filtering if the texture has to be scaled
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)

    def bind(self):
        """Bind the texture for rendering."""
        glBindTexture(GL_TEXTURE_2D, self._texId)

    def update(self, pil_image: Image.Image):
        """Update the texture to contain the provided image.

        Args:
            pil_image (PIL.Image.Image): The image to write into the texture.
        """
        # Ensure the image is in RGBA format and convert to the raw RGBA bytes.
        image_width, image_height = pil_image.size
        image = pil_image.convert("RGBA").tobytes("raw", "RGBA")

        # Bind the texture so that it can be modified.
        self.bind()
        if (self._width==image_width) and (self._height==image_height):
            # Same size - just need to update the texels.
            glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, image_width, image_height,
                            GL_RGBA, GL_UNSIGNED_BYTE, image)
        else:
            # Different size than the last frame (e.g. the Window is resizing)
            # Create a new texture of the correct size.
            glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image_width, image_height,
                         0, GL_RGBA, GL_UNSIGNED_BYTE, image)

        self._width = image_width
        self._height = image_height


def LoadMtlFile(filename):
    """Load a .mtl material file, and return the contents as a dictionary.

    Supports the subset of MTL required for the Cozmo 3D viewer assets.

    Args:
        filename (str): The filename of the file to load.

    Returns:
        dict: A dictionary mapping named MTL attributes to values.
    """
    contents = {}
    current_mtl = None

    resource_path = '/'.join(('assets', filename))  # Note: Deliberately not os.path.join, for use with pkg_resources
    file_data = resource_stream(_resource_package, resource_path)

    for line in file_data:
        line = line.decode("utf-8")  # Convert bytes line to a string
        if line.startswith('#'):
            # ignore comments in the file
            continue
        values = line.split()
        if not values:
            # ignore empty lines
            continue
        attribute_name = values[0]
        if attribute_name == 'newmtl':
            # Create a new empty material
            current_mtl = contents[values[1]] = {}
        elif current_mtl is None:
            raise ValueError("mtl file must start with newmtl statement")
        elif attribute_name == 'map_Kd':
            # Diffuse texture map - load the image into memory
            image_name = values[1]
            image_resource_path = '/'.join(('assets', image_name))  # Note: Deliberately not os.path.join, for use with pkg_resources
            image_file_data = resource_stream(_resource_package, image_resource_path)
            with Image.open(image_file_data) as image:
                image_width, image_height = image.size
                image = image.convert("RGBA").tobytes("raw", "RGBA")

            # Bind the image as a texture that can be used for rendering
            texture_id =  glGenTextures(1)
            current_mtl['texture_Kd'] = texture_id

            glBindTexture(GL_TEXTURE_2D, texture_id)
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
            glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
            glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image_width, image_height,
                         0, GL_RGBA, GL_UNSIGNED_BYTE, image)
        else:
            # Store the values for this attribute as a list of float values
            current_mtl[attribute_name] = list(map(float, values[1:]))
    # File loaded successfully - return the contents
    return contents


class LoadedObjFile:
    """The loaded / parsed contents of a 3D Wavefront OBJ file.

    This is the intermediary step between the file on the disk, and a renderable
    3D object. It supports the subset of the OBJ file that was used in the
    Cozmo and Cube assets, and does not attempt to exhaustively support every
    possible setting.

    Args:
        filename (str): The filename of the OBJ file to load.
    """
    def __init__(self, filename):
        # list: The vertices (each vertex stored as list of 3 floats).
        self.vertices = []
        # list: The vertex normals (each normal stored as list of 3 floats).
        self.normals = []
        # list: The texture coordinates (each coordinate stored as list of 2 floats).
        self.tex_coords = []
        # dict: The faces for each mesh, indexed by mesh name.
        self.mesh_faces = {}

        # dict: A dictionary mapping named MTL attributes to values.
        self.mtl = None

        group_name = None
        material = None

        resource_path = '/'.join(('assets', filename))  # Note: Deliberately not os.path.join, for use with pkg_resources
        file_data = resource_stream(_resource_package, resource_path)

        for line in file_data:
            line = line.decode("utf-8")  # Convert bytes to string
            if line.startswith('#'):
                # ignore comments in the file
                continue

            values = line.split()
            if not values:
                # ignore empty lines
                continue

            if values[0] == 'v':
                # vertex position
                v = list(map(float, values[1:4]))
                self.vertices.append(v)
            elif values[0] == 'vn':
                # vertex normal
                v = list(map(float, values[1:4]))
                self.normals.append(v)
            elif values[0] == 'vt':
                # texture coordinate
                self.tex_coords.append(list(map(float, values[1:3])))
            elif values[0] in ('usemtl', 'usemat'):
                # material
                material = values[1]
            elif values[0] == 'mtllib':
                # material library (a filename)
                self.mtl = LoadMtlFile(values[1])
            elif values[0] == 'f':
                # A face made up of 3 or 4 vertices - e.g. `f v1 v2 v3` or `f v1 v2 v3 v4`
                # where each vertex definition is multiple indexes seperated by
                # slashes and can follow the following formats:
                # position_index
                # position_index/tex_coord_index
                # position_index/tex_coord_index/normal_index
                # position_index//normal_index

                positions = []
                tex_coords = []
                normals = []

                for vertex in values[1:]:
                    vertex_components = vertex.split('/')

                    positions.append(int(vertex_components[0]))

                    # There's only a texture coordinate if there's at least 2 entries and the 2nd entry is non-zero length
                    if len(vertex_components) >= 2 and len(vertex_components[1]) > 0:
                        tex_coords.append(int(vertex_components[1]))
                    else:
                        # OBJ file indexing starts at 1, so use 0 to indicate no entry
                        tex_coords.append(0)

                    # There's only a normal if there's at least 2 entries and the 2nd entry is non-zero length
                    if len(vertex_components) >= 3 and len(vertex_components[2]) > 0:
                        normals.append(int(vertex_components[2]))
                    else:
                        # OBJ file indexing starts at 1, so use 0 to indicate no entry
                        normals.append(0)

                try:
                    mesh_face = self.mesh_faces[group_name]
                except KeyError:
                    # Create a new mesh group
                    self.mesh_faces[group_name] = []
                    mesh_face = self.mesh_faces[group_name]

                mesh_face.append((positions, normals, tex_coords, material))
            elif values[0] == 'o':
                # object name - ignore
                pass
            elif values[0] == 'g':
                # group name (for a sub-mesh)
                group_name = values[1]
            elif values[0] == 's':
                # smooth shading (1..20, and 'off') - ignore
                pass
            else:
                logger.warning("LoadedObjFile Ignoring unhandled type '%s' in line %s",
                               values[0], values)


class RenderableObject:
    """Container for an object that can be rendered via OpenGL.

    Can contain multiple meshes, for e.g. articulated objects.

    Args:
        object_data (LoadedObjFile): The object data (vertices, faces, etc.)
            to generate the renderable object from.
        override_mtl (dict): An optional material to use as an override instead
            of the material specified in the data. This allows one OBJ file
            to be used to create multiple objects with different materials
            and textures. Use :meth:`LoadMtlFile` to generate a dict from a
            MTL file.
    """
    def __init__(self, object_data: LoadedObjFile, override_mtl=None):
        #: dict: The individual meshes, indexed by name, for this object.
        self.meshes = {}
        mtl_dict = override_mtl if (override_mtl is not None) else object_data.mtl

        def _as_rgba(color):
            if len(color) >= 4:
                return color
            else:
                # RGB - add alpha defaulted to 1
                return color + [1.0]

        for key in object_data.mesh_faces:
            new_gl_list = glGenLists(1)
            glNewList(new_gl_list, GL_COMPILE)

            self.meshes[key] = new_gl_list

            part_faces = object_data.mesh_faces[key]

            glEnable(GL_TEXTURE_2D)
            glFrontFace(GL_CCW)

            for face in part_faces:
                vertices, normals, texture_coords, material = face

                mtl = mtl_dict[material]
                if 'texture_Kd' in mtl:
                    # use diffuse texture map
                    glBindTexture(GL_TEXTURE_2D, mtl['texture_Kd'])
                else:
                    # No texture map
                    glBindTexture(GL_TEXTURE_2D, 0)

                # Diffuse light
                mtl_kd_rgba = _as_rgba(mtl['Kd'])
                glColor(mtl_kd_rgba)

                # Ambient light
                if 'Ka' in mtl:
                    mtl_ka_rgba = _as_rgba(mtl['Ka'])
                    glMaterialfv(GL_FRONT, GL_AMBIENT, mtl_ka_rgba)
                    glMaterialfv(GL_FRONT, GL_DIFFUSE, mtl_kd_rgba)
                else:
                    glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, mtl_kd_rgba);

                # Specular light
                if 'Ks' in mtl:
                    mtl_ks_rgba = _as_rgba(mtl['Ks'])
                    glMaterialfv(GL_FRONT, GL_SPECULAR, mtl_ks_rgba);
                    if 'Ns' in mtl:
                        specular_exponent = mtl['Ns']
                        glMaterialfv(GL_FRONT, GL_SHININESS, specular_exponent);

                # Polygon (N verts) with optional normals and tex coords
                glBegin(GL_POLYGON)
                for i in range(len(vertices)):
                    normal_index = normals[i]
                    if normal_index > 0:
                        glNormal3fv(object_data.normals[normal_index - 1])
                    tex_coord_index = texture_coords[i]
                    if tex_coord_index > 0:
                        glTexCoord2fv( object_data.tex_coords[tex_coord_index - 1])
                    glVertex3fv(object_data.vertices[vertices[i] - 1])
                glEnd()

            glDisable(GL_TEXTURE_2D)
            glEndList()

    def draw_all(self):
        """Draw all of the meshes."""
        for mesh in self.meshes.values():
            glCallList(mesh)


def _make_unit_cube():
    """Make a unit-size cube, with normals, centered at the origin"""
    new_gl_list = glGenLists(1)
    glNewList(new_gl_list, GL_COMPILE)

    # build each of the 6 faces
    for face_index in range(6):
        # calculate normal and vertices for this face
        vertex_normal = [0.0, 0.0, 0.0]
        vertex_pos_options1 = [-1.0, 1.0,  1.0, -1.0]
        vertex_pos_options2 = [ 1.0, 1.0, -1.0, -1.0]
        face_index_even = ((face_index % 2) == 0)
        # odd and even faces point in opposite directions
        normal_dir = 1.0 if face_index_even else -1.0
        if face_index < 2:
            # -X and +X faces (vert positions differ in Y,Z)
            vertex_normal[0] = normal_dir
            v1i = 1
            v2i = 2
        elif face_index < 4:
            # -Y and +Y faces (vert positions differ in X,Z)
            vertex_normal[1] = normal_dir
            v1i = 0
            v2i = 2
        else:
            # -Z and +Z faces (vert positions differ in X,Y)
            vertex_normal[2] = normal_dir
            v1i = 0
            v2i = 1

        vertex_pos = list(vertex_normal)

        # Polygon (N verts) with optional normals and tex coords
        glBegin(GL_POLYGON)
        for vert_index in range(4):
            vertex_pos[v1i] = vertex_pos_options1[vert_index]
            vertex_pos[v2i] = vertex_pos_options2[vert_index]
            glNormal3fv(vertex_normal)
            glVertex3fv(vertex_pos)
        glEnd()

    glEndList()

    return new_gl_list


class OpenGLWindow():
    """A Window displaying an OpenGL viewport.

    Args:
        x (int): The initial x coordinate of the window in pixels.
        y (int): The initial y coordinate of the window in pixels.
        width (int): The initial height of the window in pixels.
        height (int): The initial height of the window in pixels.
        window_name (str): The name / title for the window.
        is_3d (bool): True to create a Window for 3D rendering.
    """
    def __init__(self, x, y, width, height, window_name, is_3d):
        self._pos = (x, y)
        #: int: The width of the window
        self.width = width
        #: int: The height of the window
        self.height = height
        self._gl_window = None
        self._window_name = window_name
        self._is_3d = is_3d

    def init_display(self):
        """Initialze the OpenGL display parts of the Window.

        Warning:
            Must be called on the same thread as OpenGL (usually the main thread),
            and after glutInit().
        """
        glutInitWindowSize(self.width, self.height)
        glutInitWindowPosition(*self._pos)
        self.gl_window = glutCreateWindow(self._window_name)

        if self._is_3d:
            glClearColor(0, 0, 0, 0)
            glEnable(GL_DEPTH_TEST)
            glShadeModel(GL_SMOOTH)

        glutReshapeFunc(self._reshape)

    def _reshape(self, width, height):
        # Called from OpenGL whenever this window is resized.
        self.width = width
        self.height = height
        glViewport(0, 0, width, height)


class RobotRenderFrame():
    """Minimal copy of a Robot's state for 1 frame of rendering."""
    def __init__(self, robot):
        self.pose = robot.pose
        self.head_angle = robot.head_angle
        self.lift_position = robot.lift_position


class ObservableElementRenderFrame():
    """Minimal copy of a Cube's state for 1 frame of rendering."""
    def __init__(self, element):
        self.pose = element.pose
        self.is_visible = element.is_visible
        self.last_observed_time = element.last_observed_time

    @property
    def time_since_last_seen(self):
        # Equivalent of ObservableElement's method
        '''float: time since this element was last seen (math.inf if never)'''
        if self.last_observed_time is None:
            return math.inf
        return time.time() - self.last_observed_time


class CubeRenderFrame(ObservableElementRenderFrame):
    """Minimal copy of a Cube's state for 1 frame of rendering."""
    def __init__(self, cube):
        super().__init__(cube)


class FaceRenderFrame(ObservableElementRenderFrame):
    """Minimal copy of a Face's state for 1 frame of rendering."""
    def __init__(self, face):
        super().__init__(face)


class CustomObjectRenderFrame(ObservableElementRenderFrame):
    """Minimal copy of a CustomObject's state for 1 frame of rendering."""
    def __init__(self, obj, is_fixed):
        if is_fixed:
            # Not an observable, so init directly
            self.pose = obj.pose
            self.is_visible = None
            self.last_observed_time = None
        else:
            super().__init__(obj)

        self.is_fixed = is_fixed
        self.x_size_mm = obj.x_size_mm
        self.y_size_mm = obj.y_size_mm
        self.z_size_mm = obj.z_size_mm


class WorldRenderFrame():
    """Minimal copy of the World's state for 1 frame of rendering."""
    def __init__(self, robot):
        world = robot.world

        self.robot_frame = RobotRenderFrame(robot)

        self.cube_frames = []
        for i in range(3):
            cube_id = objects.LightCubeIDs[i]
            cube = world.get_light_cube(cube_id)
            if cube is None:
                self.cube_frames.append(None)
            else:
                self.cube_frames.append(CubeRenderFrame(cube))

        self.face_frames = []
        for face in world._faces.values():
            # Ignore faces that have a newer version (with updated id)
            # or if they haven't been seen in a while).
            if not face.has_updated_face_id and (face.time_since_last_seen < 60):
                self.face_frames.append(FaceRenderFrame(face))

        self.custom_object_frames = []
        for obj in world._objects.values():
            is_custom = isinstance(obj, objects.CustomObject)
            is_fixed = isinstance(obj, objects.FixedCustomObject)
            if is_custom or is_fixed:
                self.custom_object_frames.append(CustomObjectRenderFrame(obj, is_fixed))


class RobotControlIntents():
    """Input intents for controlling the robot.

    These are sent from the OpenGL thread, and consumed by the SDK thread for
    issuing movement commands on Cozmo (to provide a remote-control interface).
    """
    def __init__(self, left_wheel_speed=0.0, right_wheel_speed=0.0,
                 lift_speed=0.0, head_speed=0.0):
        self.left_wheel_speed = left_wheel_speed
        self.right_wheel_speed = right_wheel_speed
        self.lift_speed = lift_speed
        self.head_speed = head_speed


class OpenGLViewer():
    """OpenGL based 3D Viewer.

    Handles rendering of both a 3D world view and a 2D camera window.

    Args:
        enable_camera_view (bool): True to also open a 2nd window to display
            the live camera view.
    """
    def __init__(self, enable_camera_view):
        # Queues from SDK thread to OpenGL thread
        self._img_queue = collections.deque(maxlen=1)
        self._nav_memory_map_queue = collections.deque(maxlen=1)
        self._world_frame_queue = collections.deque(maxlen=1)
        # Queue from OpenGL thread to SDK thread
        self._input_intent_queue = collections.deque(maxlen=1)

        self._last_robot_control_intents = RobotControlIntents()

        self._is_keyboard_control_enabled = False

        self._image_handler = None
        self._nav_map_handler = None
        self._robot_state_handler = None
        self._exit_requested = False

        global opengl_viewer
        if opengl_viewer is not None:
            logger.error("Multiple OpenGLViewer instances not expected: "
                         "OpenGL / GLUT only supports running 1 blocking instance on the main thread.")
        opengl_viewer = self

        self.main_window = OpenGLWindow(0, 0, 800, 600,
                                        b"Cozmo 3D Visualizer", is_3d=True)

        self._camera_view_texture = None  # type: DynamicTexture
        self.viewer_window = None  # type: OpenGLWindow
        if enable_camera_view:
            self.viewer_window = OpenGLWindow(self.main_window.width, 0, 640, 480,
                                              b"Cozmo CameraFeed", is_3d=False)

        self.cozmo_object = None  # type: RenderableObject
        self.cube_objects = []

        self._latest_world_frame = None  # type: WorldRenderFrame
        self._nav_memory_map_display_list = None

        # Keyboard
        self._is_key_pressed = {}
        self._is_alt_down = False
        self._is_ctrl_down = False
        self._is_shift_down = False

        # Mouse
        self._is_mouse_down = {}
        self._mouse_pos = None  # type: util.Vector2

        # Camera position and orientation defined by a look-at positions
        # and a pitch/and yaw to rotate around that along with a distance
        self._camera_look_at = util.Vector3(100.0, -25.0, 0.0)
        self._camera_pitch = math.radians(40)
        self._camera_yaw = math.radians(270)
        self._camera_distance = 500.0
        self._camera_pos = util.Vector3(0, 0, 0)
        self._camera_up = util.Vector3(0.0, 0.0, 1.0)
        self._calculate_camera_pos()

    def _request_exit(self):
        self._exit_requested = True
        if bool(glutLeaveMainLoop):
            glutLeaveMainLoop()

    def _calculate_camera_pos(self):
        # Calculate camera position based on look-at, distance and angles
        cos_pitch = math.cos(self._camera_pitch)
        sin_pitch = math.sin(self._camera_pitch)
        cos_yaw = math.cos(self._camera_yaw)
        sin_yaw = math.sin(self._camera_yaw)
        cam_distance = self._camera_distance
        cam_look_at = self._camera_look_at

        self._camera_pos._x = cam_look_at.x + (cam_distance * cos_pitch * cos_yaw)
        self._camera_pos._y = cam_look_at.y + (cam_distance * cos_pitch * sin_yaw)
        self._camera_pos._z = cam_look_at.z + (cam_distance * sin_pitch)

    def _update_modifier_keys(self):
        modifiers = glutGetModifiers()
        self._is_alt_down = (modifiers & GLUT_ACTIVE_ALT != 0)
        self._is_ctrl_down = (modifiers & GLUT_ACTIVE_CTRL != 0)
        self._is_shift_down = (modifiers & GLUT_ACTIVE_SHIFT != 0)

    def _update_intents_for_robot(self):
        # Update driving intents based on current input, and pass to SDK thread
        # so that it can pass the input on to the robot.
        def get_intent_direction(key1, key2):
            # Helper for keyboard inputs that have 1 positive and 1 negative input
            pos_key = self._is_key_pressed.get(key1, False)
            neg_key = self._is_key_pressed.get(key2, False)
            return pos_key - neg_key

        drive_dir = get_intent_direction(b'w', b's')
        turn_dir = get_intent_direction(b'd', b'a')
        lift_dir = get_intent_direction(b'r', b'f')
        head_dir = get_intent_direction(b't', b'g')

        if drive_dir < 0:
            # It feels more natural to turn the opposite way when reversing
            turn_dir = -turn_dir

        # Scale drive speeds with SHIFT (faster) and ALT (slower)
        if self._is_shift_down:
            speed_scalar = 2.0
        elif self._is_alt_down:
            speed_scalar = 0.5
        else:
            speed_scalar = 1.0

        drive_speed = 75.0 * speed_scalar
        turn_speed = 100.0 * speed_scalar

        left_wheel_speed = (drive_dir * drive_speed) + (turn_speed * turn_dir)
        right_wheel_speed = (drive_dir * drive_speed) - (turn_speed * turn_dir)
        lift_speed = 4.0 * lift_dir * speed_scalar
        head_speed = head_dir * speed_scalar

        control_intents = RobotControlIntents(left_wheel_speed, right_wheel_speed,
                                              lift_speed, head_speed)
        self._input_intent_queue.append(control_intents)

    def _idle(self):
        if self._is_keyboard_control_enabled:
            self._update_intents_for_robot()
        glutPostRedisplay()

    def _visible(self, vis):
        # Called from OpenGL when visibility changes (windows are either visible
        # or completely invisible/hidden)
        if vis == GLUT_VISIBLE:
            glutIdleFunc(self._idle)
        else:
            glutIdleFunc(None)

    def _draw_memory_map(self):
        # Update the renderable map if new data is available, and
        # render the latest map received.
        new_nav_memory_map = None
        try:
            new_nav_memory_map = self._nav_memory_map_queue.popleft()
        except IndexError:
            # no new nav map - queue is empty
            pass

        # Rebuild the renderable map if it has changed
        if new_nav_memory_map is not None:
            cen = new_nav_memory_map.center
            half_size = new_nav_memory_map.size * 0.5

            if self._nav_memory_map_display_list is None:
                self._nav_memory_map_display_list = glGenLists(1)
            glNewList(self._nav_memory_map_display_list, GL_COMPILE)

            glPushMatrix()

            color_light_gray = (0.65, 0.65, 0.65)
            glColor3f(*color_light_gray)
            glBegin(GL_LINE_STRIP)
            glVertex3f(cen.x + half_size, cen.y + half_size, cen.z)  # TL
            glVertex3f(cen.x + half_size, cen.y - half_size, cen.z)  # TR
            glVertex3f(cen.x - half_size, cen.y - half_size, cen.z)  # BR
            glVertex3f(cen.x - half_size, cen.y + half_size, cen.z)  # BL
            glVertex3f(cen.x + half_size, cen.y + half_size,
                       cen.z)  # TL (close loop)
            glEnd()

            def color_for_content(content):
                nct = nav_memory_map.NodeContentTypes
                colors = {nct.Unknown.id: (0.3, 0.3, 0.3),         # dark gray
                          nct.ClearOfObstacle.id: (0.0, 1.0, 0.0), # green
                          nct.ClearOfCliff.id: (0.0, 0.5, 0.0),    # dark green
                          nct.ObstacleCube.id: (1.0, 0.0, 0.0),    # red
                          nct.ObstacleCharger.id: (1.0, 0.5, 0.0), # orange
                          nct.Cliff.id: (0.0, 0.0, 0.0),           # black
                          nct.VisionBorder.id: (1.0, 1.0, 0.0)     # yellow
                          }

                col = colors.get(content.id)
                if col is None:
                    logger.error("Unhandled content type %s" % str(content))
                    col = (1.0, 1.0, 1.0)  # white
                return col

            fill_z = cen.z - 0.4

            def _recursive_draw(grid_node: nav_memory_map.NavMemoryMapGridNode):
                if grid_node.children is not None:
                    for child in grid_node.children:
                        _recursive_draw(child)
                else:
                    # leaf node - render as a quad
                    map_alpha = 0.5
                    cen = grid_node.center
                    half_size = grid_node.size * 0.5

                    # Draw outline
                    glColor4f(*color_light_gray, 1.0)  # fully opaque
                    glBegin(GL_LINE_STRIP)
                    glVertex3f(cen.x + half_size, cen.y + half_size, cen.z)
                    glVertex3f(cen.x + half_size, cen.y - half_size, cen.z)
                    glVertex3f(cen.x - half_size, cen.y - half_size, cen.z)
                    glVertex3f(cen.x - half_size, cen.y + half_size, cen.z)
                    glVertex3f(cen.x + half_size, cen.y + half_size, cen.z)
                    glEnd()

                    # Draw filled contents
                    glColor4f(*color_for_content(grid_node.content), map_alpha)
                    glBegin(GL_TRIANGLE_STRIP)
                    glVertex3f(cen.x + half_size, cen.y + half_size, fill_z)
                    glVertex3f(cen.x + half_size, cen.y - half_size, fill_z)
                    glVertex3f(cen.x - half_size, cen.y + half_size, fill_z)
                    glVertex3f(cen.x - half_size, cen.y - half_size, fill_z)
                    glEnd()

            _recursive_draw(new_nav_memory_map.root_node)

            glPopMatrix()
            glEndList()
        else:
            # The source data hasn't changed - keep using the same call list
            pass

        if self._nav_memory_map_display_list is not None:
            glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
            glEnable(GL_BLEND)
            glPushMatrix()
            glCallList(self._nav_memory_map_display_list)
            glPopMatrix()


    def _draw_cozmo(self, robot_frame):
        if self.cozmo_object is None:
            return

        robot_pose = robot_frame.pose
        robot_head_angle = robot_frame.head_angle
        robot_lift_position = robot_frame.lift_position

        # Angle of the lift in the object's initial default pose.
        LIFT_ANGLE_IN_DEFAULT_POSE = -11.36

        robot_matrix = robot_pose.to_matrix()
        head_angle = robot_head_angle.degrees
        # Get the angle of Cozmo's lift for rendering - we subtract the angle
        # of the lift in the default pose in the object, and apply the inverse
        # rotation
        lift_angle = -(robot_lift_position.angle.degrees - LIFT_ANGLE_IN_DEFAULT_POSE)

        glPushMatrix()
        glEnable(GL_LIGHTING)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glEnable(GL_BLEND)

        glMultMatrixf(robot_matrix.in_row_order)

        robot_scale_amt = 10.0  # cm to mm
        glScalef(robot_scale_amt, robot_scale_amt, robot_scale_amt)
        glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)

        # Pivot offset for where the fork rotates around itself
        FORK_PIVOT_X = 3.0
        FORK_PIVOT_Z = 3.4

        # Offset for the axel that the upper arm rotates around.
        UPPER_ARM_PIVOT_X = -3.73
        UPPER_ARM_PIVOT_Z = 4.47

        # Offset for the axel that the lower arm rotates around.
        LOWER_ARM_PIVOT_X = -3.74
        LOWER_ARM_PIVOT_Z = 3.27

        # Offset for the pivot that the head rotates around.
        HEAD_PIVOT_X = -1.1
        HEAD_PIVOT_Z = 4.75

        # Render the static body meshes - first the main body:
        glCallList(self.cozmo_object.meshes["body_geo"])
        # Render the left treads and wheels
        glCallList(self.cozmo_object.meshes["trackBase_L_geo"])
        glCallList(self.cozmo_object.meshes["wheel_BL_geo"])
        glCallList(self.cozmo_object.meshes["wheel_FL_geo"])
        glCallList(self.cozmo_object.meshes["tracks_L_geo"])
        # Render the right treads and wheels
        glCallList(self.cozmo_object.meshes["trackBase_R_geo"])
        glCallList(self.cozmo_object.meshes["wheel_BR_geo"])
        glCallList(self.cozmo_object.meshes["wheel_FR_geo"])
        glCallList(self.cozmo_object.meshes["tracks_R_geo"])

        # Render the fork at the front (but not the arms)
        glPushMatrix()
        # The fork rotates first around upper arm (to get it to the correct position).
        glTranslatef(UPPER_ARM_PIVOT_X, 0.0, UPPER_ARM_PIVOT_Z)
        glRotatef(lift_angle, 0, 1, 0)
        glTranslatef(-UPPER_ARM_PIVOT_X, 0.0, -UPPER_ARM_PIVOT_Z)
        # The fork then rotates back around itself as it always hangs vertically.
        glTranslatef(FORK_PIVOT_X, 0.0, FORK_PIVOT_Z)
        glRotatef(-lift_angle, 0, 1, 0)
        glTranslatef(-FORK_PIVOT_X, 0.0, -FORK_PIVOT_Z)
        # Render
        glCallList(self.cozmo_object.meshes["fork_geo"])
        glPopMatrix()

        # Render the upper arms:
        glPushMatrix()
        # Rotate the upper arms around the upper arm joint
        glTranslatef(UPPER_ARM_PIVOT_X, 0.0, UPPER_ARM_PIVOT_Z)
        glRotatef(lift_angle, 0, 1, 0)
        glTranslatef(-UPPER_ARM_PIVOT_X, 0.0, -UPPER_ARM_PIVOT_Z)
        # Render
        glCallList(self.cozmo_object.meshes["uprArm_L_geo"])
        glCallList(self.cozmo_object.meshes["uprArm_geo"])
        glPopMatrix()

        # Render the lower arms:
        glPushMatrix()
        # Rotate the lower arms around the lower arm joint
        glTranslatef(LOWER_ARM_PIVOT_X, 0.0, LOWER_ARM_PIVOT_Z)
        glRotatef(lift_angle, 0, 1, 0)
        glTranslatef(-LOWER_ARM_PIVOT_X, 0.0, -LOWER_ARM_PIVOT_Z)
        # Render
        glCallList(self.cozmo_object.meshes["lwrArm_L_geo"])
        glCallList(self.cozmo_object.meshes["lwrArm_R_geo"])
        glPopMatrix()

        # Render the head:
        glPushMatrix()
        # Rotate the head around the pivot
        glTranslatef(HEAD_PIVOT_X, 0.0, HEAD_PIVOT_Z)
        glRotatef(-head_angle, 0, 1, 0)
        glTranslatef(-HEAD_PIVOT_X, 0.0, -HEAD_PIVOT_Z)
        # Render all of the head meshes
        glCallList(self.cozmo_object.meshes["head_geo"])
        # Screen
        glCallList(self.cozmo_object.meshes["backScreen_mat"])
        glCallList(self.cozmo_object.meshes["screenEdge_geo"])
        glCallList(self.cozmo_object.meshes["overscan_1_geo"])
        # Eyes
        glCallList(self.cozmo_object.meshes["eye_L_geo"])
        glCallList(self.cozmo_object.meshes["eye_R_geo"])
        # Eyelids
        glCallList(self.cozmo_object.meshes["eyeLid_R_top_geo"])
        glCallList(self.cozmo_object.meshes["eyeLid_L_top_geo"])
        glCallList(self.cozmo_object.meshes["eyeLid_L_btm_geo"])
        glCallList(self.cozmo_object.meshes["eyeLid_R_btm_geo"])
        # Face cover (drawn last as it's translucent):
        glCallList(self.cozmo_object.meshes["front_Screen_geo"])
        glPopMatrix()

        glDisable(GL_LIGHTING)
        glPopMatrix()


    def _draw_unit_cube(self, color, draw_solid):
        glColor(color)

        if draw_solid:
            ambient_color = [color[0]*0.1, color[1]*0.1, color[2]*0.1, 1.0]
        else:
            ambient_color = color
        glMaterialfv(GL_FRONT, GL_AMBIENT, ambient_color)
        glMaterialfv(GL_FRONT, GL_DIFFUSE, color)
        glMaterialfv(GL_FRONT, GL_SPECULAR,  color)

        glMaterialfv(GL_FRONT, GL_SHININESS, 10.0);

        if draw_solid:
            glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
        else:
            glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)

        glCallList(self.unit_cube)


    def _display_3d_view(self, window):
        glutSetWindow(window.gl_window)

        # Clear the screen and the depth buffer
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        # Set up the projection matrix
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()
        fov = 45.0
        aspect_ratio = window.width / window.height
        near_clip_plane = 1.0
        far_clip_plane = 1000.0
        gluPerspective(fov, aspect_ratio, near_clip_plane, far_clip_plane)

        # Switch to model matrix for rendering everything
        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()

        # Add a light near the origin
        light_ambient = [1.0, 1.0, 1.0, 1.0]
        light_diffuse = [1.0, 1.0, 1.0, 1.0]
        light_specular = [1.0, 1.0, 1.0, 1.0]
        glLightfv(GL_LIGHT0, GL_AMBIENT, light_ambient)
        glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse)
        glLightfv(GL_LIGHT0, GL_SPECULAR, light_specular)
        light_pos = [0, 20, 10, 1]
        glLightfv(GL_LIGHT0, GL_POSITION, light_pos)
        glEnable(GL_LIGHT0)

        glScalef(0.1, 0.1, 0.1)  # mm to cm

        # Orient the camera
        self._calculate_camera_pos()

        gluLookAt(*self._camera_pos.x_y_z,
                  *self._camera_look_at.x_y_z,
                  *self._camera_up.x_y_z)

        # Update the latest world frame if there is a new one available
        try:
            world_frame = self._world_frame_queue.popleft()  # type: WorldRenderFrame
            self._latest_world_frame = world_frame
        except IndexError:
            world_frame = self._latest_world_frame
            pass

        if world_frame is not None:
            glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
            glEnable(GL_LIGHTING)
            glEnable(GL_NORMALIZE)  # to re-scale scaled normals

            robot_frame = world_frame.robot_frame
            robot_pose = robot_frame.pose

            # Render the cubes
            for i in range(3):
                cube_obj = self.cube_objects[i]
                cube_frame = world_frame.cube_frames[i]
                if cube_frame is None:
                    continue

                cube_pose = cube_frame.pose
                if cube_pose is not None and cube_pose.is_comparable(robot_pose):
                    glPushMatrix()

                    # TODO if cube_pose.is_accurate is False, render half-translucent?
                    #  (This would require using a shader, or having duplicate objects)

                    cube_matrix = cube_pose.to_matrix()
                    glMultMatrixf(cube_matrix.in_row_order)

                    # Cube is drawn slightly larger than the 10mm to 1 cm scale, as the model looks small otherwise
                    cube_scale_amt = 10.7
                    glScalef(cube_scale_amt, cube_scale_amt, cube_scale_amt)

                    cube_obj.draw_all()
                    glPopMatrix()

            glBindTexture(GL_TEXTURE_2D, 0)

            for face in world_frame.face_frames:
                face_pose = face.pose
                if face_pose is not None and face_pose.is_comparable(robot_pose):
                    glPushMatrix()
                    face_matrix = face_pose.to_matrix()
                    glMultMatrixf(face_matrix.in_row_order)

                    # Approximate size of a head
                    glScalef(100, 25, 100)

                    FACE_OBJECT_COLOR = [0.5, 0.5, 0.5, 1.0]
                    draw_solid = face.time_since_last_seen < 30
                    self._draw_unit_cube(FACE_OBJECT_COLOR, draw_solid)

                    glPopMatrix()

            for obj in world_frame.custom_object_frames:
                obj_pose = obj.pose
                if obj_pose is not None and obj_pose.is_comparable(robot_pose):
                    glPushMatrix()
                    obj_matrix = obj_pose.to_matrix()
                    glMultMatrixf(obj_matrix.in_row_order)

                    glScalef(obj.x_size_mm * 0.5,
                             obj.y_size_mm * 0.5,
                             obj.z_size_mm * 0.5)

                    # Only draw solid object for observable custom objects

                    if obj.is_fixed:
                        # fixed objects are drawn as transparent outlined boxes to make
                        # it clearer that they have no effect on vision.
                        FIXED_OBJECT_COLOR = [1.0, 0.7, 0.0, 1.0]
                        self._draw_unit_cube(FIXED_OBJECT_COLOR, False)
                    else:
                        CUSTOM_OBJECT_COLOR = [1.0, 0.3, 0.3, 1.0]
                        self._draw_unit_cube(CUSTOM_OBJECT_COLOR, True)

                    glPopMatrix()

            glDisable(GL_LIGHTING)

            self._draw_cozmo(robot_frame)

        # Draw the (translucent) nav map last so it's sorted correctly against opaque geometry
        self._draw_memory_map()

        glutSwapBuffers()

    def _display_camera_view(self, window):
        glutSetWindow(window.gl_window)

        if self._camera_view_texture is None:
            self._camera_view_texture = DynamicTexture()

        target_width = window.width
        target_height = window.height
        target_aspect = 320 / 240  # (Camera-feed resolution and aspect ratio)
        max_u = 1.0
        max_v = 1.0
        if (target_width / target_height) < target_aspect:
            target_height = target_width / target_aspect
            max_v *= target_height / window.height
        elif (target_width / target_height) > target_aspect:
            target_width = target_height * target_aspect
            max_u *= target_width / window.width
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        glEnable(GL_TEXTURE_2D)

        # Try getting a new image if one has been added
        image = None
        try:
            image = self._img_queue.popleft()
        except IndexError:
            # no new image - queue is empty
            pass

        if image:
            # There's a new image - update the texture
            self._camera_view_texture.update(image)
        else:
            # keep using the most recent texture
            self._camera_view_texture.bind()

        # Display the image as a tri-strip with 4 vertices
        glBegin(GL_TRIANGLE_STRIP)
        # (0,0) = Top Left, (1,1) = Bottom Right
        # left, bottom
        glTexCoord2f(0.0, 1.0)
        glVertex2f(-max_u, -max_v)
        # right, bottom
        glTexCoord2f(1.0, 1.0)
        glVertex2f(max_u, -max_v)
        # left, top
        glTexCoord2f(0.0, 0.0)
        glVertex2f(-max_u, max_v)
        # right, top
        glTexCoord2f(1.0, 0.0)
        glVertex2f(max_u, max_v)
        glEnd()

        glDisable(GL_TEXTURE_2D)

        glutSwapBuffers()

    def _display(self):
        try:
            self._display_3d_view(self.main_window)

            if self.viewer_window:
                self._display_camera_view(self.viewer_window)
        except KeyboardInterrupt:
            logger.info("_display caught KeyboardInterrupt - exitting")
            self._request_exit()

    def _key_byte_to_lower(self, key):
        # Convert bytes-object (representing keyboard character) to lowercase equivalent
        if (key >= b'A') and (key <= b'Z'):
            lowercase_key = ord(key) - ord(b'A') + ord(b'a')
            lowercase_key = bytes([lowercase_key])
            return lowercase_key
        return key

    def _on_key_up(self, key, x, y):
        key = self._key_byte_to_lower(key)
        self._update_modifier_keys()
        self._is_key_pressed[key] = False

    def _on_key_down(self, key, x, y):
        key = self._key_byte_to_lower(key)
        self._update_modifier_keys()
        self._is_key_pressed[key] = True

        if ord(key) == 9:  # Tab
            # Set Look-At point to current robot position
            world_frame = self._latest_world_frame
            if world_frame is not None:
                robot_pos = world_frame.robot_frame.pose.position
                self._camera_look_at.set_to(robot_pos)
        elif ord(key) == 27:  # Escape key
            self._request_exit()

    def _on_special_key_up(self, key, x, y):
        self._update_modifier_keys()

    def _on_special_key_down(self, key, x, y):
        self._update_modifier_keys()

    def _on_mouse_button(self, button, state, x, y):
        # Don't update modifier keys- reading modifier keys is unreliable
        # from _on_mouse_button (for LMB down/up), only SHIFT key seems to read there
        #self._update_modifier_keys()
        is_down = (state == GLUT_DOWN)
        self._is_mouse_down[button] = is_down
        self._mouse_pos = util.Vector2(x, y)

    def _on_mouse_move_internal(self, x, y, is_active):
        # is_active is True if this is not passive (i.e. a mouse button was down)
        last_mouse_pos = self._mouse_pos
        self._mouse_pos = util.Vector2(x, y)
        if last_mouse_pos is None:
            # First mouse update - ignore (we need a delta of mouse positions)
            return

        left_button = self._is_mouse_down.get(GLUT_LEFT_BUTTON, False)
        # For laptop and other 1-button mouse users, treat 'x' key as a right mouse button too
        right_button = (self._is_mouse_down.get(GLUT_RIGHT_BUTTON, False) or
                        self._is_key_pressed.get(b'x', False))

        MOUSE_SPEED_SCALAR = 1.0  # general scalar for all mouse movement sensitivity
        MOUSE_ROTATE_SCALAR = 0.025  # additional scalar for rotation sensitivity
        mouse_delta = (self._mouse_pos - last_mouse_pos) * MOUSE_SPEED_SCALAR

        if left_button and right_button:
            # Move up/down
            self._camera_look_at._z -= mouse_delta.y
        elif right_button:
            # Move forward/back and left/right
            pitch = self._camera_pitch
            yaw = self._camera_yaw
            camera_offset = util.Vector3(math.cos(yaw), math.sin(yaw), math.sin(pitch))

            heading = math.atan2(camera_offset.y, camera_offset.x)

            half_pi = math.pi * 0.5
            self._camera_look_at._x += mouse_delta.x * math.cos(heading + half_pi)
            self._camera_look_at._y += mouse_delta.x * math.sin(heading + half_pi)

            self._camera_look_at._x += mouse_delta.y * math.cos(heading)
            self._camera_look_at._y += mouse_delta.y * math.sin(heading)
        elif left_button:
            if self._is_key_pressed.get(b'z', False):
                # Zoom in/out
                self._camera_distance = max(0.1, self._camera_distance + mouse_delta.y)
            else:
                # Adjust the Camera pitch and yaw
                self._camera_pitch = (self._camera_pitch - (mouse_delta.y * MOUSE_ROTATE_SCALAR))
                self._camera_yaw = (self._camera_yaw + (mouse_delta.x * MOUSE_ROTATE_SCALAR)) % (2.0 * math.pi)
                # Clamp pitch to slightyly less than pi/2 to avoid lock/errors at full up/down
                max_rotation = math.pi * 0.49
                self._camera_pitch = max(-max_rotation, min(max_rotation, self._camera_pitch))

    def _on_mouse_move(self, x, y):
        # Mouse movement when at least one button down
        self._on_mouse_move_internal(x, y, True)

    def _on_mouse_move_passive(self, x, y):
        # Mouse movement when no button down
        self._on_mouse_move_internal(x, y, False)

    def init_display(self):
        glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH)

        self.main_window.init_display()

        glutDisplayFunc(self._display)  # Note: both windows call the same DisplayFunc
        glutKeyboardFunc(self._on_key_down)
        glutSpecialFunc(self._on_special_key_down)

        # [Keyboard/Special]Up methods aren't supported on some old GLUT implementations
        has_keyboard_up = False
        has_special_up = False
        try:
            if bool(glutKeyboardUpFunc):
                glutKeyboardUpFunc(self._on_key_up)
                has_keyboard_up = True
            if bool(glutSpecialUpFunc):
                glutSpecialUpFunc(self._on_special_key_up)
                has_special_up = True
        except OpenGL.error.NullFunctionError:
            # Methods aren't available on this GLUT version
            pass

        if not has_keyboard_up or not has_special_up:
            # Warn on old GLUT implementations that don't implement much of the interface.
            logger.warning("Warning: Old GLUT implementation detected - keyboard remote control of Cozmo disabled."
                            "We recommend installing freeglut. %s", _glut_install_instructions())
            self._is_keyboard_control_enabled = False
        else:
            self._is_keyboard_control_enabled = True

        glutMouseFunc(self._on_mouse_button)
        glutMotionFunc(self._on_mouse_move)
        glutPassiveMotionFunc(self._on_mouse_move_passive)

        glutIdleFunc(self._idle)
        glutVisibilityFunc(self._visible)

        # Load 3D objects

        _cozmo_obj = LoadedObjFile("cozmo.obj")
        self.cozmo_object = RenderableObject(_cozmo_obj)

        # Load the cubes, reusing the same file geometry for all 3.
        _cube_obj = LoadedObjFile("cube.obj")
        self.cube_objects.append(RenderableObject(_cube_obj))
        self.cube_objects.append(RenderableObject(_cube_obj, override_mtl=LoadMtlFile("cube2.mtl")))
        self.cube_objects.append(RenderableObject(_cube_obj, override_mtl=LoadMtlFile("cube3.mtl")))

        self.unit_cube = _make_unit_cube()

        if self.viewer_window:
            self.viewer_window.init_display()
            glutDisplayFunc(self._display)  # Note: both windows call the same DisplayFunc

    def mainloop(self):
        self.init_display()

        # use a non-blocking update loop if possible to make exit conditions
        # easier (not supported on all GLUT versions).
        if bool(glutCheckLoop):
            while not self._exit_requested:
                glutCheckLoop()
        else:
            # This blocks until quit
            glutMainLoop()

        if self._exit_requested:
            # Pass the keyboard interrupt on to SDK so that it can close cleanly
            raise KeyboardInterrupt

    async def connect(self, sdk_conn):
        sdk_robot = await sdk_conn.wait_for_robot()

        # Note: OpenGL and SDK are on different threads, so we deliberately don't
        # store a reference to the robot here, as we should only access it from
        # events called on the SDK thread (where we can then thread-safely move
        # the data into OpenGL)

        self._robot_state_handler = sdk_robot.world.add_event_handler(
            robot.EvtRobotStateUpdated, self.on_robot_state_update)

        if self.viewer_window is not None:
            # Automatically enable camera stream when viewer window is used.
            sdk_robot.camera.image_stream_enabled = True
            self._image_handler = sdk_robot.world.add_event_handler(
                world.EvtNewCameraImage, self.on_new_camera_image)
        # Automatically enable streaming of the nav memory map when using the
        # viewer (can be overridden by user application after connection).
        sdk_robot.world.request_nav_memory_map(0.5)
        self._nav_map_handler = sdk_robot.world.add_event_handler(
            nav_memory_map.EvtNewNavMemoryMap, self.on_new_nav_memory_map)

    def disconnect(self):
        """Called from the SDK when the program is complete and it's time to exit."""
        if self._image_handler:
            self._image_handler.disable()
            self._image_handler = None
        if self._nav_map_handler:
            self._nav_map_handler.disable()
            self._nav_map_handler = None
        if self._robot_state_handler:
            self._robot_state_handler.disable()
            self._robot_state_handler = None
        if not self._exit_requested:
            self._request_exit()

    def _update_robot_remote_control(self, robot):
        # Called on SDK thread, for controlling robot from input intents
        # pushed from the OpenGL thread.
        try:
            input_intents = self._input_intent_queue.popleft()  # type: RobotControlIntents
        except IndexError:
            # no new input intents - do nothing
            return

        # Track last-used intents so that we only issue motor controls
        # if different from the last frame (to minimize it fighting with an SDK
        # program controlling the robot):
        old_intents = self._last_robot_control_intents
        self._last_robot_control_intents = input_intents

        if robot.is_on_charger:
            # Cozmo is stuck on the charger
            if input_intents.left_wheel_speed > 0 and input_intents.right_wheel_speed > 0:
                # User is trying to drive forwards (off the charger) - issue an explicit drive off action
                try:
                    # don't wait for action to complete
                    robot.drive_off_charger_contacts(in_parallel=True)
                except RobotBusy:
                    # Robot is busy doing another action - try again next time we get a drive impulse
                    pass

        if ((old_intents.left_wheel_speed != input_intents.left_wheel_speed) or
            (old_intents.right_wheel_speed != input_intents.right_wheel_speed)):
            robot.drive_wheel_motors(input_intents.left_wheel_speed,
                                     input_intents.right_wheel_speed,
                                     input_intents.left_wheel_speed * 4,
                                     input_intents.right_wheel_speed * 4)

        if (old_intents.lift_speed != input_intents.lift_speed):
            robot.move_lift(input_intents.lift_speed)

        if (old_intents.head_speed != input_intents.head_speed):
            robot.move_head(input_intents.head_speed)

    def on_robot_state_update(self, evt, *, robot, **kw):
        # Called from SDK whenever the robot state is updated (so i.e. every engine tick).
        # Note: This is called from the SDK thread, so only access safe things
        # We can safely capture any robot and world state here, and push to OpenGL
        # (main) thread via a thread-safe queue.
        world_frame = WorldRenderFrame(robot)
        self._world_frame_queue.append(world_frame)

        # We update remote control of the robot here too as it's the one
        # method that's called frequently on the SDK thread.
        self._update_robot_remote_control(robot)

    def on_new_camera_image(self, evt, *, image, **kw):
        # Called from SDK whenever a new image is available
        # Note: This is called from the SDK thread, so only access safe things:
        # viewer_window will already be created, and reading width/height is safe
        # (worst case it'll be a frame old, or e.g just width/height updated)
        fit_size=(self.viewer_window.width, self.viewer_window.height)
        annotated_image = image.annotate_image(fit_size=fit_size)
        self._img_queue.append(annotated_image)

    def on_new_nav_memory_map(self, evt, *, nav_memory_map, **kw):
        # Called from SDK whenever a new nav memory map is available
        # Note: This is called from the SDK thread, so only access safe things
        self._nav_memory_map_queue.append(nav_memory_map)
