class GraphicsCanvas2D: """ Set up the scene with initial conditions. - White background - Width, height - Title, caption - Axes drawn (if applicable) :param height: Height of the canvas on screen (Pixels), defaults to 360. :type height: `int`, optional :param width: Width of the canvas on screen (Pixels), defaults to 640. :type width: `int`, optional :param title: Title of the plot. Gets displayed above canvas, defaults to ''. :type title: `str`, optional :param caption: Caption (subtitle) of the plot. Gets displayed below the canvas, defaults to ''. :type caption: `str`, optional :param grid: Whether a grid should be displayed in the plot, defaults to `True`. :type grid: `bool`, optional """ def __init__(self, height=360, width=640, title='', caption='', grid=True): # Private lists self.__line_styles = [ '', # None '-', # Solid (default) '--', # Dashes ':', # Dotted '-.', # Dash-dot ] self.__marker_styles = [ '+', # Plus 'o', # Circle '*', # Star '.', # Dot 'x', # Cross 's', # Square 'd', # Diamond '^', # Up triangle 'v', # Down triangle '<', # Left triangle '>', # Right triangle 'p', # Pentagon 'h', # Hexagon ] self.__colour_styles = [ 'r', # Red 'g', # Green 'b', # Blue 'y', # Yellow 'c', # Cyan 'm', # Magenta 'k', # Black (default) 'w', # White ] self.__colour_dictionary = { 'r': color.red.value, 'g': color.green.value, 'b': color.blue.value, 'c': color.cyan.value, 'y': color.yellow.value, 'm': color.magenta.value, 'k': color.black.value, 'w': color.white.value } # Create a new independent scene self.scene = canvas() # Apply the settings self.scene.background = color.white self.scene.width = width self.scene.height = height self.scene.autoscale = False # Disable default controls self.scene.userpan = True # Keep shift+mouse panning (key overwritten) self.scene.userzoom = True # Keep zoom controls (scrollwheel) self.scene.userspin = False # Remove ctrl+mouse enabled to rotate self.__grid_visibility = grid self.__camera_lock = False # Apply HTML title/caption if title != '': self.scene.title = title self.__default_caption = caption if caption != '': self.scene.caption = caption # Rotate the camera # convert_grid_to_z_up(self.scene) # Any time a key or mouse is held down, run the callback function rate(30) # 30Hz self.scene.bind('keydown', self.__handle_keyboard_inputs) # Create the grid, and display if wanted self.__graphics_grid = GraphicsGrid(self.scene) # Toggle grid to 2D self.__graphics_grid.toggle_2d_3d() # Lock the grid # self.__graphics_grid.set_relative(False) # Turn off grid if applicable if not self.__grid_visibility: self.__graphics_grid.set_visibility(False) # Reset the camera to known spot self.__reset_camera() self.__graphics_grid.update_grid() ####################################### # Canvas Management ####################################### # TODO def clear_scene(self): pass def grid_visibility(self, is_visible): """ Update the grid visibility in the scene :param is_visible: Whether the grid should be visible or not :type is_visible: `bool` """ self.__graphics_grid.set_visibility(is_visible) ####################################### # UI Management ####################################### # TODO def __setup_ui_controls(self): pass def __handle_keyboard_inputs(self): """ Pans amount dependent on distance between camera and focus point. Closer = smaller pan amount A = move left (pan) D = move right (pan) W = move up (pan) S = move down (pan) <- = rotate left along camera axes (rotate) -> = rotate right along camera axes (rotate) ^ = rotate up along camera axes (rotate) V = rotate down along camera axes (rotate) Q = roll left (rotate) E = roll right (rotate) ctrl + LMB = rotate (Default Vpython) """ # If camera lock, just skip the function if self.__camera_lock: return # Constants pan_amount = 0.02 # units rot_amount = 1.0 # deg # Current settings cam_distance = self.scene.camera.axis.mag cam_pos = vector(self.scene.camera.pos) cam_focus = vector(self.scene.center) # Weird manipulation to get correct vector directions. (scene.camera.up always defaults to world up) cam_axis = (vector(self.scene.camera.axis)) # X cam_side_axis = self.scene.camera.up.cross(cam_axis) # Y cam_up = cam_axis.cross(cam_side_axis) # Z cam_up.mag = cam_axis.mag # Get a list of keys keys = keysdown() # Userpan uses ctrl, so skip this check to avoid changing camera pose while shift is held if 'shift' in keys: return ################################################################################################################ # PANNING # Check if the keys are pressed, update vectors as required # Changing camera position updates the scene center to follow same changes if 'w' in keys: cam_pos = cam_pos + cam_up * pan_amount if 's' in keys: cam_pos = cam_pos - cam_up * pan_amount if 'a' in keys: cam_pos = cam_pos + cam_side_axis * pan_amount if 'd' in keys: cam_pos = cam_pos - cam_side_axis * pan_amount # Update camera position before rotation (to keep pan and rotate separate) self.scene.camera.pos = cam_pos ################################################################################################################ # Camera Roll # If only one rotation key is pressed if 'q' in keys and 'e' not in keys: # Rotate camera up cam_up = cam_up.rotate(angle=-radians(rot_amount), axis=cam_axis) # Set magnitude as it went to inf cam_up.mag = cam_axis.mag # Set self.scene.up = cam_up # If only one rotation key is pressed if 'e' in keys and 'q' not in keys: # Rotate camera up cam_up = cam_up.rotate(angle=radians(rot_amount), axis=cam_axis) # Set magnitude as it went to inf cam_up.mag = cam_axis.mag # Set self.scene.up = cam_up ################################################################################################################ # CAMERA ROTATION d = cam_distance move_dist = sqrt(d ** 2 + d ** 2 - 2 * d * d * cos(radians(rot_amount))) # SAS Cosine # If only left not right key if 'left' in keys and 'right' not in keys: # Calculate distance to translate cam_pos = cam_pos + norm(cam_side_axis) * move_dist # Calculate new camera axis cam_axis = -(cam_pos - cam_focus) if 'right' in keys and 'left' not in keys: cam_pos = cam_pos - norm(cam_side_axis) * move_dist cam_axis = -(cam_pos - cam_focus) if 'up' in keys and 'down' not in keys: cam_pos = cam_pos + norm(cam_up) * move_dist cam_axis = -(cam_pos - cam_focus) if 'down' in keys and 'up' not in keys: cam_pos = cam_pos - norm(cam_up) * move_dist cam_axis = -(cam_pos - cam_focus) # Update camera position and axis self.scene.camera.pos = cam_pos self.scene.camera.axis = cam_axis def __reset_camera(self): """ Reset the camera to a known position """ # Reset Camera self.scene.camera.pos = vector(5, 5, 12) # Hover above (5, 5, 0) # Ever so slightly off focus, to ensure grid is rendered in the right region # (if directly at, draws numbers wrong spots) self.scene.camera.axis = vector(-0.001, -0.001, -12) # Focus on (5, 5, 0) self.scene.up = y_axis_vector ####################################### # Drawing Functions ####################################### def __draw_path(self, x_path, y_path, opt_line, opt_marker, opt_colour, thickness=0.05): """ Draw a line from point to point in the 2D path :param x_path: The x path to draw on the canvas :type x_path: `list` :param y_path: The y path to draw on the canvas :type y_path: `list` :param opt_line: The line option argument :type opt_line: `str` :param opt_marker: The marker option argument :type opt_marker: `str` :param opt_colour: The colour option argument :type opt_colour: `str` :param thickness: Thickness of the line :type thickness: `float` :raises ValueError: Invalid line type given """ # Get colour colour = self.__get_colour_from_string(opt_colour) # For every point in the list, draw a line to the next one (excluding last point) for point in range(0, len(x_path)): # Get point 1 x1 = x_path[point] y1 = y_path[point] p1 = vector(x1, y1, 0) # If at end / only coordinate - draw a marker if point == len(x_path) - 1: create_marker(self.scene, x1, y1, opt_marker, colour) return # Get point 2 x2 = x_path[point + 1] y2 = y_path[point + 1] p2 = vector(x2, y2, 0) if opt_line == '': # Only one marker to avoid double-ups create_marker(self.scene, x1, y1, opt_marker, colour) elif opt_line == '-': create_line(p1, p2, self.scene, colour=colour, thickness=thickness) # Only one marker to avoid double-ups create_marker(self.scene, x1, y1, opt_marker, colour) elif opt_line == '--': create_segmented_line(p1, p2, self.scene, 0.3, colour=colour, thickness=thickness) # Only one marker to avoid double-ups create_marker(self.scene, x1, y1, opt_marker, colour) elif opt_line == ':': create_segmented_line(p1, p2, self.scene, 0.05, colour=colour, thickness=thickness) # Only one marker to avoid double-ups create_marker(self.scene, x1, y1, opt_marker, colour) elif opt_line == '-.': raise NotImplementedError("Other line types not implemented") else: raise ValueError("Invalid line type given") def plot(self, x_coords, y_coords=None, options=''): """ Same usage as MATLAB's plot. If given one list of coordinates, plots against index If given two lists of coordinates, plots both (1st = x, 2nd = y) Options string is identical to MATLAB's input string If you do not specify a marker type, plot uses no marker. If you do not specify a line style, plot uses a solid line. b blue . point - solid g green o circle : dotted r red x x-mark -. dashdot c cyan + plus -- dashed m magenta * star (none) no line y yellow s square k black d diamond w white v triangle (down) ^ triangle (up) < triangle (left) > triangle (right) p pentagram h hexagram :param x_coords: The first plane of coordinates to plot :type x_coords: `list` :param y_coords: The second plane of coordinates to plot with. :type y_coords: `list`, `str`, optional :param options: A string of options to plot with :type options: `str`, optional :raises ValueError: Number of X and Y coordinates must be equal """ # TODO # add options for line width, marker size # If y-vector is str, then only x vector given if isinstance(y_coords, str): options = y_coords y_coords = None one_set_data = False # Set y-vector to default if None if y_coords is None: one_set_data = True y_coords = [*range(0, len(x_coords))] # Verify x, y coords have same length if len(x_coords) != len(y_coords): raise ValueError("Number of X coordinates does not equal number of Y coordinates.") # Verify options given (and save settings to be applied) verified_options = self.__verify_plot_options(options) if one_set_data: # Draw plot for one list of data self.__draw_path( y_coords, # Y is default x-coords in one data set x_coords, # User input verified_options[0], # Line verified_options[1], # Marker verified_options[2], # Colour ) else: # Draw plot for two lists of data self.__draw_path( x_coords, # User input y_coords, # User input verified_options[0], # Line verified_options[1], # Marker verified_options[2], # Colour ) def __verify_plot_options(self, options_str): """ Verify that the given options are usable. :param options_str: The given options from the plot command to verify user input :type options_str: `str` :raises ValueError: Unknown character entered :raises ValueError: Too many line segments used :raises ValueError: Too many marker segments used :raises ValueError: Too many colour segments used :returns: List of options to plot with :rtype: `list` """ default_line = '-' default_marker = '' default_colour = 'k' # Split str into chars list options_split = list(options_str) # If 0, set defaults and return early if len(options_split) == 0: return [default_line, default_marker, default_colour] # If line_style given, join the first two options if applicable (some types have 2 characters) for char in range(0, len(options_split)-1): # If char is '-' (only leading character in double length option) if options_split[char] == '-' and len(options_split) > 1: # If one of the leading characters is valid if options_split[char+1] == '-' or options_split[char+1] == '.': # Join the two into the first options_split[char] = options_split[char] + options_split[char+1] # Shuffle down the rest for idx in range(char+2, len(options_split)): options_split[idx - 1] = options_split[idx] # Remove duplicate extra options_split.pop() # If any unknown, throw error for option in options_split: if option not in self.__line_styles and \ option not in self.__marker_styles and \ option not in self.__colour_styles: error_string = "Unknown character entered: '{0}'" raise ValueError(error_string.format(option)) ############################## # Verify Line Style ############################## line_style_count = 0 # Count of options used line_style_index = 0 # Index position of index used (only used when count == 1) for option in options_split: if option in self.__line_styles: line_style_count = line_style_count + 1 line_style_index = self.__line_styles.index(option) # If more than one, throw error if line_style_count > 1: raise ValueError("Too many line style arguments given. Only one allowed") # If none, set as solid elif line_style_count == 0 or not any(item in options_split for item in self.__line_styles): output_line = default_line # If one, set as given else: output_line = self.__line_styles[line_style_index] ############################## ############################## # Verify Marker Style ############################## marker_style_count = 0 # Count of options used marker_style_index = 0 # Index position of index used (only used when count == 1) for option in options_split: if option in self.__marker_styles: marker_style_count = marker_style_count + 1 marker_style_index = self.__marker_styles.index(option) # If more than one, throw error if marker_style_count > 1: raise ValueError("Too many marker style arguments given. Only one allowed") # If none, set as no-marker elif marker_style_count == 0 or not any(item in options_split for item in self.__marker_styles): output_marker = default_marker # If one, set as given else: output_marker = self.__marker_styles[marker_style_index] # If marker set and no line given, turn line to no-line if line_style_count == 0 or not any(item in options_split for item in self.__line_styles): output_line = '' ############################## ############################## # Verify Colour Style ############################## colour_style_count = 0 # Count of options used colour_style_index = 0 # Index position of index used (only used when count == 1) for option in options_split: if option in self.__colour_styles: colour_style_count = colour_style_count + 1 colour_style_index = self.__colour_styles.index(option) # If more than one, throw error if colour_style_count > 1: raise ValueError("Too many colour style arguments given. Only one allowed") # If none, set as black elif colour_style_count == 0 or not any(item in options_split for item in self.__colour_styles): output_colour = default_colour # If one, set as given else: output_colour = self.__colour_styles[colour_style_index] ############################## return [output_line, output_marker, output_colour] def __get_colour_from_string(self, colour_string): """ Using the colour plot string input, return an rgb array of the colour selected :param colour_string: The colour string option :type colour_string: `str` :returns: List of RGB values for the representative colour :rtype: `list` """ # Return the RGB list (black if not in dictionary) return self.__colour_dictionary.get(colour_string, color.black.value)
class GraphicsCanvas2D: """ Set up the scene with initial conditions. - White background - Width, height - Title, caption - Axes drawn (if applicable) :param height: Height of the canvas on screen (Pixels), defaults to 500. :type height: `int`, optional :param width: Width of the canvas on screen (Pixels), defaults to 1000. :type width: `int`, optional :param title: Title of the plot. Gets displayed above canvas, defaults to ''. :type title: `str`, optional :param caption: Caption (subtitle) of the plot. Gets displayed below the canvas, defaults to ''. :type caption: `str`, optional :param grid: Whether a grid should be displayed in the plot, defaults to `True`. :type grid: `bool`, optional """ def __init__(self, height=500, width=1000, title='', caption='', grid=True): # Create a new independent scene self.scene = canvas() # Apply the settings self.scene.background = color.white self.scene.width = width self.scene.height = height self.scene.autoscale = False # Disable default controls self.scene.userpan = False # Remove shift+mouse panning (key overwritten) self.scene.userzoom = True # Keep zoom controls (scrollwheel) self.scene.userspin = False # Remove ctrl+mouse enabled to rotate self.__grid_visibility = grid self.__camera_lock = False # Apply HTML title/caption if title != '': self.scene.title = title self.__default_caption = caption if caption != '': self.scene.caption = caption # Rotate the camera # convert_grid_to_z_up(self.scene) # Any time a key or mouse is held down, run the callback function rate(30) # 30Hz self.scene.bind('keydown', self.__handle_keyboard_inputs) # Create the grid, and display if wanted self.__graphics_grid = GraphicsGrid(self.scene) # Toggle grid to 2D self.__graphics_grid.toggle_2d_3d() # Lock the grid self.__graphics_grid.set_relative(False) # Turn off grid if applicable if not self.__grid_visibility: self.__graphics_grid.set_visibility(False) # Reset the camera to known spot self.__reset_camera() self.__graphics_grid.update_grid() ############################ # Canvas Management ############################ # TODO def clear_scene(self): pass def grid_visibility(self, is_visible): """ Update the grid visibility in the scene :param is_visible: Whether the grid should be visible or not :type is_visible: `bool` """ self.__graphics_grid.set_visibility(is_visible) ############################ # UI Management ############################ # TODO def __setup_ui_controls(self): pass def __handle_keyboard_inputs(self): """ Pans amount dependent on distance between camera and focus point. Closer = smaller pan amount A = move left (pan) D = move right (pan) W = move forward (pan) S = move backward (pan) <- = rotate left along camera axes (rotate) -> = rotate right along camera axes (rotate) ^ = rotate up along camera axes (rotate) V = rotate down along camera axes (rotate) Q = roll left (rotate) E = roll right (rotate) space = move up (pan) shift = move down (pan) ctrl + LMB = rotate (Default Vpython) """ # If camera lock, just skip the function if self.__camera_lock: return # Constants pan_amount = 0.02 # units rot_amount = 1.0 # deg # Current settings cam_distance = self.scene.camera.axis.mag cam_pos = vector(self.scene.camera.pos) cam_focus = vector(self.scene.center) # Weird manipulation to get correct vector directions. (scene.camera.up always defaults to world up) cam_axis = (vector(self.scene.camera.axis)) # X cam_side_axis = self.scene.camera.up.cross(cam_axis) # Y cam_up = cam_axis.cross(cam_side_axis) # Z cam_up.mag = cam_axis.mag # Get a list of keys keys = keysdown() # Userspin uses ctrl, so skip this check to avoid changing camera pose while ctrl is held if 'ctrl' in keys: return ################################################################################################################ # PANNING # Check if the keys are pressed, update vectors as required # Changing camera position updates the scene center to follow same changes if 'w' in keys: cam_pos = cam_pos + cam_axis * pan_amount if 's' in keys: cam_pos = cam_pos - cam_axis * pan_amount if 'a' in keys: cam_pos = cam_pos + cam_side_axis * pan_amount if 'd' in keys: cam_pos = cam_pos - cam_side_axis * pan_amount if ' ' in keys: cam_pos = cam_pos + cam_up * pan_amount if 'shift' in keys: cam_pos = cam_pos - cam_up * pan_amount # Update camera position before rotation (to keep pan and rotate separate) self.scene.camera.pos = cam_pos ################################################################################################################ # Camera Roll # If only one rotation key is pressed if 'q' in keys and 'e' not in keys: # Rotate camera up cam_up = cam_up.rotate(angle=-radians(rot_amount), axis=cam_axis) # Set magnitude as it went to inf cam_up.mag = cam_axis.mag # Set self.scene.up = cam_up # If only one rotation key is pressed if 'e' in keys and 'q' not in keys: # Rotate camera up cam_up = cam_up.rotate(angle=radians(rot_amount), axis=cam_axis) # Set magnitude as it went to inf cam_up.mag = cam_axis.mag # Set self.scene.up = cam_up ################################################################################################################ # CAMERA ROTATION d = cam_distance move_dist = sqrt(d**2 + d**2 - 2 * d * d * cos(radians(rot_amount))) # SAS Cosine # If only left not right key if 'left' in keys and 'right' not in keys: # Calculate distance to translate cam_pos = cam_pos + norm(cam_side_axis) * move_dist # Calculate new camera axis cam_axis = -(cam_pos - cam_focus) if 'right' in keys and 'left' not in keys: cam_pos = cam_pos - norm(cam_side_axis) * move_dist cam_axis = -(cam_pos - cam_focus) if 'up' in keys and 'down' not in keys: cam_pos = cam_pos + norm(cam_up) * move_dist cam_axis = -(cam_pos - cam_focus) if 'down' in keys and 'up' not in keys: cam_pos = cam_pos - norm(cam_up) * move_dist cam_axis = -(cam_pos - cam_focus) # Update camera position and axis self.scene.camera.pos = cam_pos self.scene.camera.axis = cam_axis def __reset_camera(self): """ Reset the camera to a known position """ # Reset Camera self.scene.camera.pos = vector(5, 5, 12) # Hover above (5, 5, 0) # Ever so slightly off focus, to ensure grid is rendered in the right region # (if directly at, draws numbers wrong spots) self.scene.camera.axis = vector(-0.001, -0.001, -12) # Focus on (5, 5, 0) self.scene.up = y_axis_vector ############################ # Drawing Functions ############################ def draw_path(self, xy_path, colour=None, thickness=0.05): """ Draw a line from point to point in the 2D path :param xy_path: The path to draw on the canvas :type xy_path: `list` :param colour: RGB list to colour the line to :type colour: `list` :param thickness: Thickness of the line :type thickness: `float` """ # Default colour to black if colour is None: colour = [0, 0, 0] if colour[0] > 1.0 or colour[1] > 1.0 or colour[2] > 1.0 or \ colour[0] < 0.0 or colour[1] < 0.0 or colour[2] < 0.0: raise ValueError("RGB values must be normalised between 0 and 1") # For every point in the list, draw a line to the next one (excluding last point) for point in range(0, len(xy_path) - 1): x1 = xy_path[point][0] y1 = xy_path[point][1] p1 = vector(x1, y1, 0) x2 = xy_path[point + 1][0] y2 = xy_path[point + 1][1] p2 = vector(x2, y2, 0) create_line(p1, p2, self.scene, colour=colour, thickness=thickness)