import random from PyQt5.QtCore import QEvent, Qt from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QErrorMessage, QInputDialog from scipy.spatial import Voronoi from voronoiview.colors import Color from voronoiview.exceptions import ExceededWindowBoundsError from voronoiview.mode import Mode from voronoiview.ui.opengl_widget import (set_drawing_event, set_move_bb_top_left, set_move_bb_bottom_right, reset_move_bbs, viewport_height, viewport_width) from voronoiview.point_manager import PointManager class _ClickFlag: # This is the first stage. On mouse release it goes to # SELECTION_MOVE. NONE = 0 # We are now in selection box mode. SELECTION_BOX = 1 # Second stage - we have selected a number of points # and now we are going to track the left mouse button # to translate those points. After a left click # this moves to SELECTED_MOVED. SELECTION_MOVE = 2 # Any subsequent click in this mode will send it back # to NONE - we are done. SELECTED_MOVED = 3 # GLOBALS # Canvas pixel border - empirical, not sure where this is stored officially _CANVAS_BORDER = 1 # Module level flag for left click events (used to detect a left # click hold drag) _left_click_flag = _ClickFlag.NONE # Variable to track the mouse state during selection movement _last_mouse_pos = None # Used to implement mouse dragging when clicked _left_click_down = False # TODO: WHEN THE GROUPING ENDS AND THE USER CANCELS THE CENTROID COUNT # SHOULD BE ZEROED AND REMAINING COLORS SHOULD BE REPOPULATED. # Count of centroids for comparison with the spin widget _centroid_count = 0 _remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] def refresh_point_list(ctx): """ Refreshes the point list display. @param ctx A handle to the window context. """ # In order to make some guarantees and avoid duplicate # data we will clear the point list widget and re-populate # it using the current _point_set. ctx.point_list_widget.clear() for p in PointManager.point_set.points: ctx.point_list_widget.addItem(f"({p.x}, {p.y}) | Weight: {p.weight}") ctx.point_list_widget.update() num_of_points = len(list(PointManager.point_set.points)) ctx.number_of_points_label.setText(str(num_of_points)) def _handle_add_point(ctx, event): """ Event handler for the add point mode. Sets the drawing mode for the OpenGL Widget using `set_drawing_mode`, converts a point to our point representation, and adds it to the list. @param ctx A context handle to the main window. @param event The click event. """ # Update information as needed _handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): # At this point we can be sure resize_gl has been called # at least once, so set the viewport properties of the # point set so it knows the canvas bounds. PointManager.point_set.viewport_width = viewport_width() PointManager.point_set.viewport_height = viewport_height() # Clear any existing selections PointManager.point_set.clear_selection() try: # No attribute at the moment, default point color is Color.GREY. PointManager.point_set.add_point(event.x() * ctx.devicePixelRatio(), event.y() * ctx.devicePixelRatio(), Color.GREY) except ExceededWindowBoundsError: # The user tried to place a point whos edges would be # on the outside of the window. We will just ignore it. return refresh_point_list(ctx) set_drawing_event(event) ctx.opengl_widget.update() ctx.point_list_widget.update() def _handle_edit_point(ctx, event): _handle_info_updates(ctx, event) PointManager.point_set.clear_selection() if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): # See if a point was hit point = None for p in PointManager.point_set.points: if p.hit(event.x(), event.y()): point = p break # Get point weight from user and assign it to the point. if point is not None: value, ok = QInputDialog.getDouble(None, 'Weight', 'Weight(Float): ', 1, 1, 3000, 1) if ok: if not isinstance(value, float): error_dialog = QErrorMessage() error_dialog.showMessage('Point weight must be a floating point value.') error_dialog.exec_() else: point.weight = value # Store old x, y from event set_drawing_event(event) ctx.update() refresh_point_list(ctx) def ogl_keypress_handler(ctx, event): """ A keypress handler attached to the OpenGL widget. It primarily exists to allow the user to cancel selection. Also allows users to escape from modes. @param ctx A handle to the window context. @param event The event associated with this handler. """ global _left_click_flag global _last_mouse_pos if event.key() == Qt.Key_Escape: if ctx.mode is Mode.MOVE: if _left_click_flag is not _ClickFlag.NONE: _last_mouse_pos = None _left_click_flag = _ClickFlag.NONE PointManager.point_set.clear_selection() reset_move_bbs() refresh_point_list(ctx) elif ctx.mode is not Mode.OFF: ctx.mode = Mode.OFF # Also change the mouse back to normal ctx.opengl_widget.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) ctx.status_bar.showMessage("") ctx.opengl_widget.update() def _handle_move_points(ctx, event): """ A relatively complicated state machine that handles the process of selection, clicking, and dragging. @param ctx The context to the window. @param event The event. """ global _left_click_flag global _left_mouse_down global _last_mouse_pos set_drawing_event(event) _handle_info_updates(ctx, event) # If we release the mouse, we want to quickly alert drag mode. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonRelease): _left_mouse_down = False # This if statement block is used to set the bounding box for # drawing and call the selection procedure. if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): _left_mouse_down = True if _left_click_flag is _ClickFlag.NONE: _left_click_flag = _ClickFlag.SELECTION_BOX set_move_bb_top_left(event.x(), event.y()) elif (_left_click_flag is _ClickFlag.SELECTION_BOX and _left_mouse_down): # We are now in the click-and-hold to signal move # tracking and translation _left_click_flag = _ClickFlag.SELECTION_MOVE _last_mouse_pos = (event.x(), event.y()) # Post-selection handlers if (_left_click_flag is _ClickFlag.SELECTION_BOX and event.type() == QEvent.MouseMove): set_move_bb_bottom_right(event.x(), event.y()) elif (_left_click_flag is _ClickFlag.SELECTION_MOVE and _last_mouse_pos is not None and _left_mouse_down and event.type() == QEvent.MouseMove): dx = abs(_last_mouse_pos[0] - event.x()) dy = abs(_last_mouse_pos[1] - event.y()) for p in PointManager.point_set.points: if p.selected: # Use the deltas to decide what direction to move. # We only want to move in small unit increments. # If we used the deltas directly the points would # fly off screen quickly as we got farther from our # start. try: if event.x() < _last_mouse_pos[0]: p.move(-dx, 0) if event.y() < _last_mouse_pos[1]: p.move(0, -dy) if event.x() > _last_mouse_pos[0]: p.move(dx, 0) if event.y() > _last_mouse_pos[1]: p.move(0, dy) except ExceededWindowBoundsError: # This point has indicated a move would exceed # it's bounds, so we'll just go to the next # point. continue _last_mouse_pos = (event.x(), event.y()) elif (_left_click_flag is not _ClickFlag.NONE and event.type() == QEvent.MouseButtonRelease): if _left_click_flag is _ClickFlag.SELECTION_BOX: set_move_bb_bottom_right(event.x(), event.y()) # Satisfy the post condition by resetting the bounding box reset_move_bbs() ctx.opengl_widget.update() def _handle_delete_point(ctx, event): _handle_info_updates(ctx, event) if (event.button() == Qt.LeftButton and event.type() == QEvent.MouseButtonPress): set_drawing_event(event) PointManager.point_set.remove_point(event.x(), event.y()) refresh_point_list(ctx) ctx.opengl_widget.update() ctx.point_list_widget.update() def _handle_info_updates(ctx, event): """ Updates data under the "information" header. @param ctx The context to the main window. @param event The event. """ if event.type() == QEvent.MouseMove: ctx.mouse_position_label.setText(f"{event.x(), event.y()}") def reset_colors(): global _remaining_colors _remaining_colors = [c for c in Color if c not in [Color.BLUE, Color.GREY]] for point in PointManager.point_set.points: point.color = Color.GREY def generate_random_points(point_count, x_bound, y_bound): """ Using the random module of python generate a unique set of xs and ys to use as points, bounded by the canvas edges. @param point_count The count of points to generate. @param x_bound The width bound. @param y_bound The height bound. """ # TODO: The window size should be increased slightly to # accomodate 3000 points (the maximum) given the point size. # Work out an algorithm and limit the number of points # selectable based on the maximum amount of points on the screen # given the point size. # First clear the point set PointManager.point_set.clear() point_size = PointManager.point_set.point_size # Sample without replacement so points are not duplicated. xs = random.sample(range(point_size, x_bound), point_count) ys = random.sample(range(point_size, y_bound), point_count) points = list(zip(xs, ys)) for point in points: PointManager.point_set.add_point(point[0], point[1], Color.GREY) def _handle_voronoi(ctx, _): if not ctx.voronoi_solved: points = list(PointManager.point_set.points) point_arr = [point.array for point in points] PointManager.voronoi_results = Voronoi(point_arr, qhull_options='Qbb Qz Qx Qi Qc QJ') ctx.opengl_widget.update() ctx.voronoi_solved = True # Simple dispatcher to make it easy to dispatch the right mode # function when the OpenGL window is acted on. MODE_HANDLER_MAP = { Mode.OFF: _handle_info_updates, Mode.LOADED: _handle_info_updates, Mode.ADD: _handle_add_point, Mode.EDIT: _handle_edit_point, Mode.MOVE: _handle_move_points, Mode.DELETE: _handle_delete_point, Mode.VORONOI: _handle_voronoi }