"""The Canvas is a class for storing a two-dimensional grid of colors for matrix devices.""" from __future__ import annotations import random from collections.abc import Iterable, Iterator from .theme import Theme, ThemeColor def color_weighting( distances: list[tuple[int, ThemeColor]], ) -> Iterable[ThemeColor]: """Return an array of colors where there is more of a color the closer it is.""" greatest_distance = max(dist for dist, _ in distances) for dist, color in distances: if dist == 0: for _ in range(int(greatest_distance)): yield color else: for _ in range(int(greatest_distance / dist)): yield color def shuffle_point(i: int, j: int) -> tuple[int, int]: """Return a new (i, j) value that is the current (i, j) value plus or minus a random amount.""" new_x = random.randint(i - 3, i + 3) new_y = random.randint(j - 3, j + 3) return new_x, new_y def surrounding_points(i: int, j: int) -> list[tuple[int, int]]: """Return the points that surround the specified point.""" return [ (i - 1, j + 1), (i, j + 1), (i + 1, j + 1), (i - 1, j), (i + 1, j), (i - 1, j - 1), (i, j - 1), (i + 1, j - 1), ] class Canvas: """ A Canvas is a collection of points with methods for interacting with those points. The points are stored as (i, j) in a dictionary. The value for each point is a ThemeColor object. """ def __init__(self) -> None: """Initialize the canvas.""" self.points: dict[tuple[int, int], ThemeColor] = {} def add_points_for_tile( self, left_x: int, top_y: int, tile_width: int, tile_height: int, theme: Theme ) -> None: """ Create points on the canvas around where a tile is. We create an area that's half the tile width/height beyond the boundary of the tile. We also spread the points out in a random manner and try to avoid having points next to each other. Multiple calls to this function will not override existing points on the canvas """ from_x = int(left_x - tile_width * 1.5) to_x = int(left_x + tile_width * 1.5) from_y = int(top_y - tile_height * 1.5) to_y = int(top_y + tile_height * 1.5) i = from_x while i < to_x: j = from_y while j < to_y: if (i, j) not in self.points: if not self.has_neighbour(i, j): random_color = theme.random() self[(i, j)] = random_color j += random.choice([i + 1 for i in range(3)]) i += random.choice([i + 1 for i in range(3)]) def surrounding_colors(self, i: int, j: int) -> list[ThemeColor]: """ Return the colors that surround this (i, j) point. This will only return points that exist. """ return [self[(x, y)] for x, y in surrounding_points(i, j) if (x, y) in self] def has_neighbour(self, i: int, j: int) -> bool: """Return whether there are any points around this (i, j) position""" return any(self.surrounding_colors(i, j)) def shuffle_points(self) -> None: """ Take all the points and move them around a random amount """ new_points = {} for (i, j), color in self: new_points[shuffle_point(i, j)] = color self.points = new_points def blur(self) -> None: """ For each point, find the average colour of that point plus all surrounding points. """ new_points = {} for (i, j), original in self: colors = [original for _ in range(2)] for color in self.surrounding_colors(i, j): colors.append(color) new_points[(i, j)] = ThemeColor.average(colors) self.points = new_points def blur_by_distance(self) -> None: """ Similar to blur but will find the 8 closest points as opposed to the 8 surrounding points. """ new_points = {} for (i, j), original in self: distances = self.closest_points(i, j, 8) weighted = list(color_weighting(distances)) new_points[(i, j)] = ThemeColor.average(weighted) self.points = new_points def points_for_tile( self, left_x: int, top_y: int, tile_width: int, tile_height: int ) -> list[ThemeColor]: """ Return a list of 64 hsbk values for this tile For any point on the tile that doesn't have a corresponding point on the canvas return a grey value. This is useful for when we tell the applier to not fill in the gaps. """ result = [] grey = ThemeColor(0, 0, 0.3, 3500) for j in range(top_y, top_y - tile_height, -1): for i in range(left_x, left_x + tile_width): result.append(self.get((i, j), grey)) return result def fill_in_points( self, canvas: Canvas, left_x: int, top_y: int, tile_width: int, tile_height: int ) -> None: """ Fill in the gaps on this canvas by blurring the points on the provided canvas around where our tile is. We blur by finding the 4 closest points for each point on our tile and averaging them. """ for j in range(top_y, top_y - tile_height, -1): for i in range(left_x, left_x + tile_width): distances = canvas.closest_points(i, j, 4) weighted = list(color_weighting(distances)) self[(i, j)] = ThemeColor.average(weighted) def closest_points( self, i: int, j: int, consider: int ) -> list[tuple[int, ThemeColor]]: """ Return ``[(distance, color), ...]`` for the closest consider amount of points to (i, j) """ distances: list[tuple[int, ThemeColor]] = [] for (x, y), color in self: distances.append(((x - i) ** 2 + (y - j) ** 2, color)) def get_key( dc: tuple[int, ThemeColor], ) -> tuple[int, tuple[float, float, float, int]]: return ( dc[0], (dc[1].hue, dc[1].saturation, dc[1].brightness, dc[1].kelvin), ) distances = sorted(distances, key=get_key) return distances[:consider] def __iter__(self) -> Iterator[tuple[tuple[int, int], ThemeColor]]: """Yield ``((i, j), color)`` pairs for all our points""" yield from self.points.items() def get(self, point: tuple[int, int], default_color: ThemeColor) -> ThemeColor: """ Get a point or the passed in default_color value if the point doesn't exist """ return self.points.get(point, default_color) def __getitem__(self, point: tuple[int, int]) -> ThemeColor: """Return the color at ``point`` where ``point`` is ``(i, j)``""" return self.points[point] def __setitem__(self, key: tuple[int, int], color: ThemeColor) -> None: """Set the color at ``point`` where ``point`` is ``(i, j)``""" self.points[key] = color def __contains__(self, point: tuple[int, int]) -> bool: """Return whether this ``point`` has a color where ``point`` is ``(i, j)``""" return point in self.points