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 = "= 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