A computational geometry learning and experimentation tool.
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.

421 lines
13 KiB

import random
from PyQt5.QtCore import QEvent, Qt
from PyQt5.QtGui import QCursor
from .algorithms import Algorithms
from .colors import Color
from .exceptions import ExceededWindowBoundsError
from .mode import Mode
from .opengl_widget import (set_drawing_event, set_move_bb_top_left,
set_move_bb_bottom_right, reset_move_bbs,
viewport_height, viewport_width)
from .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
# 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("({}, {})".format(p.x, p.y))
ctx.point_list_widget.update()
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):
# TODO: This function and delete definitely need to make sure they are
# on a point we have.
#
# Since points are unique consider a hashmap of points to make O(1)
# lookups for addition and deletion. This list can be maintained here
# in this module. It should be a dictionary - from point to
# attributes in the case of algorithms that require points to have
# weights or something.
#
# Should move the associated point in the list to the new location if
# applicable.
__handle_info_updates(ctx, event)
PointManager.point_set.clear_selection()
# Store old x, y from event
set_drawing_event(event)
ctx.update()
# after this remove the point from the list
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 __handle_choose_centroids(ctx, event):
"""
Similar to move in terms of selecting points, however this
function assigns a random color up to the maximum number
of centroids, and after the maximum number has been selected it will
enable the group button.
"""
global __centroid_count
global __remaining_colors
__handle_info_updates(ctx, event)
if __centroid_count == ctx.number_of_centroids.value():
# We have specified the number of centroids required
return
if (event.button() == Qt.LeftButton and
event.type() == QEvent.MouseButtonPress):
point = None
for test_point in PointManager.point_set.points:
if test_point.hit(event.x(), event.y()):
point = test_point
if point is None:
# No point was found on the click, do nothing
return
if point in PointManager.centroids:
# Centroids must be unique
return
__centroid_count += 1
color = random.choice(__remaining_colors)
__remaining_colors.remove(color)
point.color = color
# Recolor the point and restash the point in centroids
PointManager.centroids.append(point)
if __centroid_count == ctx.number_of_centroids.value():
# Prevent the user from changing the centroids
ctx.number_of_centroids.setEnabled(False)
ctx.choose_centroids_button.setEnabled(False)
ctx.group_button.setEnabled(True)
ctx.opengl_widget.update()
def group(ctx):
"""
Group handler. Basically just a wrapper around the distance
grouping algorithm that recolors points.
This is one of the only functions that operates only on a button click
and as a result is made public since the dispatcher is not needed.
"""
groups = Algorithms.euclidean_grouping(PointManager.centroids,
PointManager.point_set)
for centroid_group in groups:
for point in centroid_group.points:
point.color = centroid_group.centroid.color
ctx.opengl_widget.update()
def reset_centroid_count_and_colors():
global __centroid_count
global __remaining_colors
__centroid_count = 0
__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)
# 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.CHOOSE_CENTROIDS: __handle_choose_centroids
}