A Voronoi Diagram viewer with an editable canvas.
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

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
}