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.
 

391 lines
11 KiB

from math import floor
from voronoiview.colors import Color
from voronoiview.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, color, point_size,
viewport_width, viewport_height, weight=1.0):
"""
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
# Unfortunately, it appears decorated property methods are not
# inheirited and instead of redoing everything we will just repeat
# the properties here.
self._x = x
self._y = y
self._weight = weight
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 array(self):
"""
Returns an array representation of the point for use in generating a voronoi diagram.
"""
return [self._x, self._y]
@property
def weight(self):
return self._weight
@weight.setter
def weight(self, weight):
self._weight = weight
@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"WEIGHT: {self._weight} | "
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
def clear_points(self):
self._points = []
@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, weight=1.0, 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 weight The point weight.
@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.")
if not isinstance(weight, float):
raise ValueError("Point weight must be a float.")
point = Point(x, y, color, self._point_size,
self._viewport_width, self._viewport_height, weight)
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