Taylor Bockman
5 years ago
12 changed files with 575 additions and 187 deletions
@ -0,0 +1,364 @@
|
||||
from math import floor |
||||
|
||||
from .colors import Color |
||||
from .exceptions import ExceededWindowBoundsError |
||||
|
||||
# TODO: THIS WILL NEED TO BE MODIFIED TO INHEIRIT THE KMEANS POINT CLASS. |
||||
|
||||
class Point: |
||||
""" |
||||
A class representing a point. A point |
||||
has a point_size bounding box around |
||||
it. |
||||
""" |
||||
|
||||
def __init__(self, x, y, color, point_size, |
||||
viewport_width, viewport_height): |
||||
""" |
||||
Initializes a new point with a point_size bounding box, viewport |
||||
awareness, and a color. |
||||
|
||||
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 color The color of the point. |
||||
@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. |
||||
""" |
||||
|
||||
if not isinstance(color, Color): |
||||
raise ValueError("Point must be initialized with a color of " + |
||||
"type Color.") |
||||
|
||||
self.__point_size = point_size |
||||
self.__x = x |
||||
self.__y = y |
||||
|
||||
self.__color = color |
||||
|
||||
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 color(self): |
||||
return self.__color |
||||
|
||||
@color.setter |
||||
def color(self, color): |
||||
if not isinstance(color, Color): |
||||
raise ValueError('Point color must be of type Color.') |
||||
|
||||
self.__color = color |
||||
|
||||
@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.__color == other.color and |
||||
self.__attributes == other.attributes 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 += f"X: {self.__x} | Y: {self.__y} | " |
||||
s += f"SIZE: {self.__point_size} | " |
||||
s += f"COLOR: {self.__color} | " |
||||
s += f"VIEWPORT_WIDTH: {self.__viewport_width} | " |
||||
s += f"VIEWPORT_HEIGHT: {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): |
||||
other_points = list(other.points) |
||||
|
||||
return (self.__points == other_points 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) |
||||
|
||||
def clear(self): |
||||
self.__points = [] |
||||
|
||||
@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, color, 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 color The color of the point. |
||||
@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.") |
||||
|
||||
if not isinstance(color, Color): |
||||
raise ValueError("Point color must be a Color enum.") |
||||
|
||||
point = Point(x, y, color, 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) |
||||
|
||||
def groups(self): |
||||
""" |
||||
Returns a map from color to point representing each point's group |
||||
membership based on color. |
||||
""" |
||||
g = {} |
||||
|
||||
for p in self.__points: |
||||
if p.color not in g: |
||||
# Create the key for the group color since it does |
||||
# not exist. |
||||
g[p.color] = [] |
||||
|
||||
g[p.color].append(p) |
||||
|
||||
return g |
Loading…
Reference in new issue