You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
379 lines
12 KiB
379 lines
12 KiB
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 |
|
}
|
|
|