|
|
|
from math import floor
|
|
|
|
|
|
|
|
from .exceptions import ExceededWindowBoundsError
|
|
|
|
|
|
|
|
class Point:
|
|
|
|
"""
|
|
|
|
A class representing a point. A point
|
|
|
|
has a point_size bounding box around
|
|
|
|
it.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, x, y, point_size, viewport_width, viewport_height):
|
|
|
|
"""
|
|
|
|
Initializes a new point with a point_size bounding box.
|
|
|
|
|
|
|
|
Initialized with additional viewport data to make sure the
|
|
|
|
move function refuses to move a point outside the screen.
|
|
|
|
|
|
|
|
@param x The x-coordinate.
|
|
|
|
@param y The y-coordinate.
|
|
|
|
@param point_size The size of the point in pixels.
|
|
|
|
@param viewport_width The width of the viewport.
|
|
|
|
@param viewport_height The height of the viewport.
|
|
|
|
"""
|
|
|
|
self.__point_size = point_size
|
|
|
|
self.__x = x
|
|
|
|
self.__y = y
|
|
|
|
|
|
|
|
self.__viewport_width = viewport_width
|
|
|
|
self.__viewport_height = viewport_height
|
|
|
|
|
|
|
|
self.__calculate_hitbox()
|
|
|
|
|
|
|
|
self.__check_window_bounds(x, y)
|
|
|
|
|
|
|
|
self.__selected = False
|
|
|
|
|
|
|
|
self.__attributes = []
|
|
|
|
|
|
|
|
@property
|
|
|
|
def x(self):
|
|
|
|
return self.__x
|
|
|
|
|
|
|
|
@property
|
|
|
|
def y(self):
|
|
|
|
return self.__y
|
|
|
|
|
|
|
|
@property
|
|
|
|
def point_size(self):
|
|
|
|
return self.__point_size
|
|
|
|
|
|
|
|
@property
|
|
|
|
def selected(self):
|
|
|
|
return self.__selected
|
|
|
|
|
|
|
|
@property
|
|
|
|
def attributes(self):
|
|
|
|
return self.__attributes
|
|
|
|
|
|
|
|
def add_attribute(self, attr):
|
|
|
|
self.__attributes.append(attr)
|
|
|
|
|
|
|
|
def __calculate_hitbox(self):
|
|
|
|
"""
|
|
|
|
Calculates the hit box for the point given the current
|
|
|
|
position (center) and the point size.
|
|
|
|
"""
|
|
|
|
half_point = floor(self.point_size / 2.0)
|
|
|
|
|
|
|
|
self.__top_left_corner = (self.__x - half_point,
|
|
|
|
self.__y + half_point)
|
|
|
|
|
|
|
|
self.__bottom_right_corner = (self.__x + half_point,
|
|
|
|
self.__y - half_point)
|
|
|
|
|
|
|
|
def __check_window_bounds(self, x, y):
|
|
|
|
"""
|
|
|
|
Simple window bound check that raises an exception when
|
|
|
|
the point (x, y) exceeds the known viewport bounds.
|
|
|
|
|
|
|
|
@param x The x-coordinate under test.
|
|
|
|
@param y The y-coordinate under test.
|
|
|
|
@raises ExceededWindowBoundsError If the viewport bounds are exceeded.
|
|
|
|
"""
|
|
|
|
half_point = floor(self.point_size / 2.0)
|
|
|
|
|
|
|
|
# Screen size in pixels is always positive
|
|
|
|
# We need to include the half point here because
|
|
|
|
# the (x, y) for a point is the center of the square and we
|
|
|
|
# do not want the EDGES to exceed the viewport bounds.
|
|
|
|
if (x > self.__viewport_width - half_point or
|
|
|
|
y > self.__viewport_height - half_point or
|
|
|
|
x < half_point or
|
|
|
|
y < half_point):
|
|
|
|
|
|
|
|
raise ExceededWindowBoundsError
|
|
|
|
|
|
|
|
def move(self, dx, dy):
|
|
|
|
"""
|
|
|
|
Adds the deltas dx and dy to the point.
|
|
|
|
|
|
|
|
@param dx The delta in the x direction.
|
|
|
|
@param dy The delta in the y direction.
|
|
|
|
"""
|
|
|
|
|
|
|
|
self.__check_window_bounds(self.__x + dx, self.__y + dy)
|
|
|
|
|
|
|
|
self.__x += dx
|
|
|
|
self.__y += dy
|
|
|
|
|
|
|
|
# It's important to note as we move the point we need to
|
|
|
|
# make sure we are constantly updating it's hitbox.
|
|
|
|
self.__calculate_hitbox()
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
"""
|
|
|
|
Override for class equality.
|
|
|
|
|
|
|
|
@param other The other object.
|
|
|
|
"""
|
|
|
|
return (self.__x == other.x and
|
|
|
|
self.__y == other.y and
|
|
|
|
self.__point_size == other.point_size)
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
|
|
|
# For some reason I had to split this instead of using one giant
|
|
|
|
# string chained with `+` inside of `()`.
|
|
|
|
s = "<POINT "
|
|
|
|
s += "X: {} | Y: {} | ".format(self.__x, self.__y)
|
|
|
|
s += "SIZE: {} | ".format(self.__point_size)
|
|
|
|
s += "VIEWPORT_WIDTH: {} | ".format(self.__viewport_width)
|
|
|
|
s += "VIEWPORT_HEIGHT: {}".format(self.__viewport_height)
|
|
|
|
s += ">"
|
|
|
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
def select(self):
|
|
|
|
"""
|
|
|
|
Selects the point.
|
|
|
|
"""
|
|
|
|
self.__selected = True
|
|
|
|
|
|
|
|
def unselect(self):
|
|
|
|
"""
|
|
|
|
Unselects the point.
|
|
|
|
"""
|
|
|
|
self.__selected = False
|
|
|
|
|
|
|
|
def hit(self, x, y):
|
|
|
|
"""
|
|
|
|
Determines if the point was hit inside of it's bounding box.
|
|
|
|
|
|
|
|
The condition for hit is simple - consider the following
|
|
|
|
bounding box:
|
|
|
|
-------------
|
|
|
|
| |
|
|
|
|
| (x,y) |
|
|
|
|
| |
|
|
|
|
-------------
|
|
|
|
|
|
|
|
Where the clicked location is in the center. Then the top
|
|
|
|
left corner is defined as (x - half_point_size, y + half_point_size)
|
|
|
|
and the bottom corner is (x + half_point_size, y - half_point_size)
|
|
|
|
|
|
|
|
So long as x and y are greater than the top left and less than the
|
|
|
|
top right it is considered a hit.
|
|
|
|
|
|
|
|
This function is necessary for properly deleting and selecting points.
|
|
|
|
"""
|
|
|
|
|
|
|
|
return (x >= self.__top_left_corner[0] and
|
|
|
|
x <= self.__bottom_right_corner[0] and
|
|
|
|
y <= self.__top_left_corner[1] and
|
|
|
|
y >= self.__bottom_right_corner[1])
|
|
|
|
|
|
|
|
class Attribute:
|
|
|
|
|
|
|
|
def __init__(self, name, value):
|
|
|
|
"""
|
|
|
|
Initializes an attribute.
|
|
|
|
"""
|
|
|
|
self.__name = name
|
|
|
|
self.__value = value
|
|
|
|
|
|
|
|
class PointSet:
|
|
|
|
"""
|
|
|
|
Useful container for points. Since points are not hashable (they are
|
|
|
|
modified in place by move) we are forced to back the PointSet with an
|
|
|
|
array. However, it is still a "set" in the "uniqueness among all points"
|
|
|
|
sense because `add_point` will reject a point with a duplicate center.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, point_size, viewport_width, viewport_height):
|
|
|
|
"""
|
|
|
|
Initializes a point container with points of size point_size.
|
|
|
|
|
|
|
|
@param point_size The size of the points.
|
|
|
|
@param viewport_width The width of the viewport for bounds
|
|
|
|
calculations.
|
|
|
|
@param viewport_height The height of the viewport for bounds
|
|
|
|
calculations.
|
|
|
|
"""
|
|
|
|
self.__points = []
|
|
|
|
self.__point_size = point_size
|
|
|
|
self.__viewport_width = viewport_width
|
|
|
|
self.__viewport_height = viewport_height
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
# We are forced to convert other.points from generator to a set to
|
|
|
|
# check equality on the sets. This could possibly get very slow
|
|
|
|
# for large numbers of points.
|
|
|
|
|
|
|
|
attributes_equal = True
|
|
|
|
|
|
|
|
other_points = list(other.points)
|
|
|
|
|
|
|
|
attributes_equal = (attributes_equal and
|
|
|
|
(len(self.__points) == len(other_points)))
|
|
|
|
|
|
|
|
# This is O(N^2) - can it be improved using some sort of
|
|
|
|
# find function?
|
|
|
|
for p in self.__points:
|
|
|
|
for i, op in enumerate(other_points):
|
|
|
|
if p == op:
|
|
|
|
attributes_equal = (attributes_equal and
|
|
|
|
(p.attributes == op.attributes))
|
|
|
|
continue
|
|
|
|
|
|
|
|
if i == len(other_points) - 1:
|
|
|
|
# In this case we have enumerated the entire second
|
|
|
|
# set and not found anything. We can safely say
|
|
|
|
# the two sets are not equal and return.
|
|
|
|
attribute_equals = False
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
return (self.__points == other_points and
|
|
|
|
attributes_equal and
|
|
|
|
self.__point_size == other.point_size and
|
|
|
|
self.__viewport_width == other.viewport_width and
|
|
|
|
self.__viewport_height == other.viewport_height)
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
s = []
|
|
|
|
|
|
|
|
for p in self.__points:
|
|
|
|
s.append(str(p))
|
|
|
|
|
|
|
|
return ",".join(s)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def points(self):
|
|
|
|
"""
|
|
|
|
Getter for points. Returns a generator for
|
|
|
|
looping.
|
|
|
|
"""
|
|
|
|
for point in self.__points:
|
|
|
|
yield point
|
|
|
|
|
|
|
|
@property
|
|
|
|
def point_size(self):
|
|
|
|
return self.__point_size
|
|
|
|
|
|
|
|
@property
|
|
|
|
def viewport_height(self):
|
|
|
|
return self.__viewport_height
|
|
|
|
|
|
|
|
@property
|
|
|
|
def viewport_width(self):
|
|
|
|
return self.__viewport_width
|
|
|
|
|
|
|
|
@viewport_height.setter
|
|
|
|
def viewport_height(self, height):
|
|
|
|
self.__viewport_height = height
|
|
|
|
|
|
|
|
@viewport_width.setter
|
|
|
|
def viewport_width(self, width):
|
|
|
|
self.__viewport_width = width
|
|
|
|
|
|
|
|
def empty(self):
|
|
|
|
return len(self.__points) == 0
|
|
|
|
|
|
|
|
def clear_selection(self):
|
|
|
|
"""
|
|
|
|
Handy helper function to clear all selected points.
|
|
|
|
"""
|
|
|
|
for p in self.__points:
|
|
|
|
p.unselect()
|
|
|
|
|
|
|
|
def add_point(self, x, y, attrs=[]):
|
|
|
|
"""
|
|
|
|
Adds a point in screen coordinates and an optional attribute to
|
|
|
|
the list.
|
|
|
|
|
|
|
|
@param x The x-coordinate.
|
|
|
|
@param y The y-coordinate.
|
|
|
|
@param attr An optional attribute.
|
|
|
|
@raises ExceededWindowBoundsError If the point could not be constructed
|
|
|
|
because it would be outside the window
|
|
|
|
bounds.
|
|
|
|
"""
|
|
|
|
|
|
|
|
if attrs != [] and not all(isinstance(x, Attribute) for x in attrs):
|
|
|
|
raise ValueError("Attributes in add_point must be an " +
|
|
|
|
"attribute array.")
|
|
|
|
|
|
|
|
point = Point(x, y, self.__point_size,
|
|
|
|
self.__viewport_width, self.__viewport_height)
|
|
|
|
|
|
|
|
for attr in attrs:
|
|
|
|
point.add_attribute(attr)
|
|
|
|
|
|
|
|
if point in self.__points:
|
|
|
|
# Silently reject a duplicate point (same center).
|
|
|
|
return
|
|
|
|
|
|
|
|
self.__points.append(point)
|
|
|
|
|
|
|
|
def remove_point(self, x, y):
|
|
|
|
"""
|
|
|
|
Removes a point from the point set based on a bounding
|
|
|
|
box calculation.
|
|
|
|
|
|
|
|
Removing a point is an exercise is determining which points
|
|
|
|
have been hit, and then pulling them out of the list.
|
|
|
|
|
|
|
|
If two points have a section overlapping, and the user clicks
|
|
|
|
the overlapped section, both points will be removed.
|
|
|
|
|
|
|
|
Currently O(n).
|
|
|
|
|
|
|
|
@param x The x-coordinate.
|
|
|
|
@param y The y-coordinate.
|
|
|
|
"""
|
|
|
|
for p in self.__points:
|
|
|
|
if p.hit(x, y):
|
|
|
|
self.__points.remove(p)
|