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
5 years ago
|
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(), event.y(), 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)
|
||
|
|
||
|
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
|
||
|
}
|