diff --git a/data/img/tile_not_found_empty.png b/data/img/tile_not_found_empty.png new file mode 100644 index 0000000..8e53738 Binary files /dev/null and b/data/img/tile_not_found_empty.png differ diff --git a/pytest.ini b/pytest.ini index 0a471c9..62d8c4d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +qt_qapp_name = viewer-test minversion = 6.0 addopts = -ra -q testpaths = tests diff --git a/src/buttons/button_tile_connection.py b/src/buttons/button_tile_connection.py index 7a6d9c0..da712a5 100644 --- a/src/buttons/button_tile_connection.py +++ b/src/buttons/button_tile_connection.py @@ -6,7 +6,7 @@ from src.images_helper import ImageHelper from src.logic.tile_handler import TileHandler -from src.signals.signal_emitter import NeighborClickedEmitter +from src.signals.signal_emitter import ConfigurationClickedEmitter if TYPE_CHECKING: from src.widgets.widget_tile import Tile @@ -19,7 +19,7 @@ class TileConnectionButton(QAbstractButton): def __init__(self, button_id, parent=None): super().__init__(parent) - self.signal_emitter = NeighborClickedEmitter(parent) + self.signal_emitter = ConfigurationClickedEmitter(parent) self.button_id = button_id self._state = 2 # Any self._num_states = 3 @@ -86,7 +86,11 @@ def setTile(self, tile: Optional["Tile"], update_neighbors=True): (self._tile.getID() == tile.getID() or self._tile.tile_data == tile.tile_data): return - self._tile = tile + if tile is None: + self._tile = tile + else: + self._tile = tile.__copy__() + if update_neighbors: self._update_neighborhood() self.update() diff --git a/src/buttons/button_tile_connection_center.py b/src/buttons/button_tile_connection_center.py index f4fd5c0..2704a27 100644 --- a/src/buttons/button_tile_connection_center.py +++ b/src/buttons/button_tile_connection_center.py @@ -24,11 +24,24 @@ def __init__(self, tile: "Tile", button_id, parent=None): ["Rotate", "Allow center tile to be rotated"], ["Empty", "Allow center tile to be placed on empty blocks"], ] + self.checkboxes = [] for i, (name, tool_tip) in enumerate(checkbox_contents): widget = QCheckBox(name) widget.setToolTip(tool_tip) + self.checkboxes.append(widget) layout.addWidget(widget, i // 2, i % 2) + # these are maybe not required but nice to have + self.box_v_flip = self.checkboxes[0] + self.box_h_flip = self.checkboxes[1] + self.box_rot = self.checkboxes[2] + self.box_empty = self.checkboxes[3] + + self.box_v_flip.stateChanged.connect(self.updateVFlip) + self.box_h_flip.stateChanged.connect(self.updateHFlip) + self.box_rot.stateChanged.connect(self.updateRot) + self.box_empty.stateChanged.connect(self.updateEmpty) + self.setLayout(layout) # override @@ -46,6 +59,25 @@ def _paintText(self, qp: QPainter): pass def setTile(self, tile: Optional["Tile"], update_neighbors=True): - # TODO set checkboxes self._tile = tile + self.box_h_flip.setCheckState(tile.tile_data.mods.can_h_flip) + self.box_v_flip.setCheckState(tile.tile_data.mods.can_v_flip) + self.box_rot.setCheckState(tile.tile_data.mods.can_rot) + self.box_empty.setCheckState(tile.tile_data.status.empty) self.update() + + def updateVFlip(self, _): + value = self.box_v_flip.isChecked() + self.signal_emitter.modification_signal.emit(0, value) + + def updateHFlip(self, _): + value = self.box_h_flip.isChecked() + self.signal_emitter.modification_signal.emit(1, value) + + def updateRot(self, _): + value = self.box_rot.isChecked() + self.signal_emitter.modification_signal.emit(2, value) + + def updateEmpty(self, _): + value = self.box_empty.isChecked() + self.signal_emitter.modification_signal.emit(3, value) diff --git a/src/dialogs/dialog_check_map.py b/src/dialogs/dialog_check_map.py index 6da1870..318bcaa 100644 --- a/src/dialogs/dialog_check_map.py +++ b/src/dialogs/dialog_check_map.py @@ -1,22 +1,21 @@ import random -from typing import TYPE_CHECKING, List, Optional, Any +from typing import List, Optional, Tuple import numpy as np from PyQt5.QtCore import Qt, QSize -from PyQt5.QtGui import QPixmap, QPainter from PyQt5.QtWidgets import QDialog, QGridLayout, QDialogButtonBox, QLabel +from src.logic.tile_status import TileStatus from src.globals import EIGHT_NEIGHBORS from src.images_helper import ImageHelper from src.logic.tile_connection import TileConnection -from src.logic.tile_data import TileData -from src.logic.tile_handler import TileHandler -from widgets.widget_base_tile import BaseTile +from src.logic.tile_handler import TileHandler, NeighborhoodEntry +from src.widgets.widget_base_tile import BaseTile class CheckMapDialog(QDialog): def __init__(self, parent, *args, **kwargs): - super().__init__(parent) + super().__init__(parent, *args, **kwargs) self.setWindowTitle("Check tile configurations") self.layout = QGridLayout() @@ -39,12 +38,17 @@ def __init__(self, parent, *args, **kwargs): grid_y = map_y + 1 grid_x = map_x + 1 - widget = BaseTile() - widget.setFixedSize(QSize(64, 64)) - pm = CheckMapDialog._getMapTilePixmap(solid_map, map_x, map_y) - if pm: - widget.setPixmap(pm) - self.layout.addWidget(widget, grid_y, grid_x, 1, 1) + tile, status = CheckMapDialog._getMapTile(solid_map, map_x, map_y) + if not tile: + tile = QLabel() # Empty tile + if solid_map[map_y, map_x] == 0: + tile.setPixmap(ImageHelper.instance().NO_TILE_FOUND_EMPTY) + else: + tile.setPixmap(ImageHelper.instance().NO_TILE_FOUND) + else: + tile.tile_data.status = status + tile.setFixedSize(QSize(64, 64)) + self.layout.addWidget(tile, grid_y, grid_x, 1, 1) # add ok and cancel button q_btn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel @@ -83,14 +87,14 @@ def _getMapNeighborhood(solid_map: np.ndarray, x: int, y: int) -> List[int]: return neighbors @staticmethod - def _getMapTilePixmap(solid_map: np.ndarray, x, y) -> Optional[QPixmap]: + def _getMapTile(solid_map: np.ndarray, x, y) -> Tuple[Optional[BaseTile], Optional[TileStatus]]: if solid_map[y, x] == 0: - return None # TODO this isn't true + return None, None # TODO this isn't true neighbors = CheckMapDialog._getMapNeighborhood(solid_map, x, y) tc = TileConnection(neighbors) - tile_id_list = TileHandler.instance().findTiles(tc) + tile_id_list: List[NeighborhoodEntry] = TileHandler.instance().findTiles(tc) if len(tile_id_list): rand_tile_id = random.randint(0, len(tile_id_list) - 1) # I could make more sure that every tile is used - return TileHandler.instance().getPixmap(tile_id_list[rand_tile_id]) - else: - return ImageHelper.instance().NO_TILE_FOUND + tile_id, status = tile_id_list[rand_tile_id] + return TileHandler.instance().getTile(tile_id), status + return None, None diff --git a/src/dialogs/dialog_tile_settings.py b/src/dialogs/dialog_tile_settings.py index 282ec8c..81e0b9c 100644 --- a/src/dialogs/dialog_tile_settings.py +++ b/src/dialogs/dialog_tile_settings.py @@ -11,13 +11,15 @@ from src.logic.tile_connection import TileConnection from src.logic.tile_data import TileData from src.logic.tile_handler import TileHandler +from src.logic.tile_status import TileStatus +from src.logic.tile_modificators import TileMods if TYPE_CHECKING: from src.widgets.widget_tile import Tile class TileSettingsDialog(QDialog): - # noinspection PyUnresolvedReferences + def __init__(self, tile: "Tile", parent=None): super().__init__(parent) self.setWindowTitle("Configure Tile") @@ -36,10 +38,11 @@ def __init__(self, tile: "Tile", parent=None): if i == 4: widget = TileConnectionCenterButton(tile, i, self) self.center = widget + widget.signal_emitter.modification_signal.connect(self.onModificationChange) else: widget = TileConnectionButton(len(self.buttons), self) self.buttons.append(widget) - widget.signal_emitter.neighbor_signal.connect(self.onConnectionButtonClick) + widget.signal_emitter.neighbor_signal.connect(self.onConnectionButtonClick) self.layout.addWidget(widget, i // 3 + 1, i % 3 + 1, 1, 1) # self.center.setTile(tile) @@ -56,14 +59,13 @@ def __init__(self, tile: "Tile", parent=None): self.setModal(True) # TODO calculate smarter values with the tile itself - self._tile_data = TileData(TileConnection([2] * 8), False, False, False, False) + self._tile_data = TileData(TileConnection([2] * 8), TileStatus(), TileMods(False, False, False)) def getTileData(self): - return self._tile_data + return self._tile_data.__copy__() def setTileData(self, data: TileData): - self._tile_data = data - print(f"Dialog tile data:\n{self._tile_data.con}") + self._tile_data = data.__copy__() # update button states for i, button_state in enumerate(self._tile_data.con.getNeighbors()): @@ -76,7 +78,6 @@ def setTileData(self, data: TileData): def onConnectionButtonClick(self, button_id: int): # goal: update tile after a button was clicked # find TileConnections, then ask handler if any tiles exist - print(f"button ID {button_id} clicked") self._updateTileData(button_id) # update tile data self._updateTile(button_id) # update own tile @@ -86,6 +87,22 @@ def onConnectionButtonClick(self, button_id: int): if neighbor_buttons[i] is not None: self._updateTile(i) + def onModificationChange(self, modification: int, value: bool): + match modification: + case 0: + self._tile_data.mods.can_v_flip = value + return + case 1: + self._tile_data.mods.can_h_flip = value + return + case 2: + self._tile_data.mods.can_rot = value + return + case 3: + self._tile_data.status.empty = value + return + raise ValueError(f"Modification {modification} unknown") + def _updateTileData(self, button_id: int): state = self.buttons[button_id].checkStateSet() self._tile_data.con.setNeighbor(button_id, state) @@ -104,7 +121,7 @@ def _updateTile(self, button_id: int): # yay, I found a tile that connects in this location # use a random one, because this shouldn't matter rand_tile = random.randint(0, len(tile_id_list) - 1) - tile_id = tile_id_list[rand_tile] + tile_id, _ = tile_id_list[rand_tile] tile = TileHandler.instance().getTile(tile_id) self.buttons[button_id].setTile(tile, False) else: # No tile? reset @@ -141,7 +158,6 @@ def _find_index(num): raise ValueError(f"{num} not found") button_x, button_y = _find_index(button_id) - print(button_x, button_y) ret = [] for y in [-1, 0, 1]: for x in [-1, 0, 1]: diff --git a/src/images_helper.py b/src/images_helper.py index 95c78e5..43cf7cb 100644 --- a/src/images_helper.py +++ b/src/images_helper.py @@ -17,6 +17,7 @@ def instance(cls): def _init(self): self.CHECKER_IMAGE = QImage("data/img/checker.png").scaled(16, 16) self.NO_TILE_FOUND = QPixmap("data/img/tile_not_found.png") + self.NO_TILE_FOUND_EMPTY = QPixmap("data/img/tile_not_found_empty.png") def drawCheckerImage(self, qp: QPainter, width: int, height: int): for y in range(0, height, self.CHECKER_IMAGE.height()): diff --git a/src/logic/tile_connection.py b/src/logic/tile_connection.py index b00c5c7..fc6371b 100644 --- a/src/logic/tile_connection.py +++ b/src/logic/tile_connection.py @@ -34,7 +34,7 @@ def _encode(self, bits) -> int: Returns a list of TileConnections possible with the any connection """ - def getPermutations(self) -> List["TileConnection"]: + def getPossibleNeighborhoods(self) -> List["TileConnection"]: i = 0 while i < EIGHT_NEIGHBORS and self._neighbors[i] != 2: i += 1 @@ -44,13 +44,12 @@ def getPermutations(self) -> List["TileConnection"]: neighbors = self._neighbors.copy() for j in [0, 1]: neighbors[i] = j - ret.extend(TileConnection(neighbors).getPermutations()) + ret.extend(TileConnection(neighbors).getPossibleNeighborhoods()) return ret """ rotates 45 degree """ - def rotate45(self) -> "TileConnection": neighbors = [0] * EIGHT_NEIGHBORS diff --git a/src/logic/tile_data.py b/src/logic/tile_data.py index 890d221..240504e 100644 --- a/src/logic/tile_data.py +++ b/src/logic/tile_data.py @@ -1,44 +1,92 @@ -from src.logic.tile_connection import TileConnection, encodeListSmall -from typing import Set, List +from src.logic.tile_modificators import TileMods +from src.logic.tile_status import TileStatus +from src.logic.tile_connection import TileConnection +from typing import List, Tuple + +TileMapState = Tuple[TileConnection, TileStatus] class TileData: - def __init__(self, con: TileConnection, flip_h: bool, flip_v: bool, rot: bool, empty: bool): + """ + This class holds all tile data, which is not Qt related + """ + + def __init__(self, con: TileConnection, status: TileStatus, mods: TileMods): self.con = con - self.h_flip = flip_h - self.v_flip = flip_v - self.rot = rot - self.empty = empty - - def getAllRelations(self) -> List[TileConnection]: - relations = [self.con] - if self.h_flip: - relations.append(self.con.hFlip()) - if self.v_flip: - relations.append(self.con.vFlip()) - if self.rot: - rot1 = self.con.rotate90() - rot2 = rot1.rotate90() - rot3 = rot2.rotate90() - relations.append(rot1) - if not (self.v_flip and self.h_flip): - relations.append(rot2) - relations.append(rot3) - return relations - - def getPermutations(self) -> Set[int]: - relations = self.getAllRelations() - permutations = [] - for relation in relations: - permutations.extend(relation.getPermutations()) - return encodeListSmall(permutations) + self.status = status + self.mods = mods + + def getAllPossibleModifications(self) -> List[TileMapState]: + """ + Returns a list of all TileConnections that are possible with the current modifications + """ + # the modifications describe, how the tile connections can be manipulated + con_list = [(self.con.__copy__(), self.status.__copy__())] + + # add h flip variants + if self.mods.can_h_flip: + con_list.append((self.con.hFlip(), self.status.hFlip())) + # include h flip rotated + if self.mods.can_rot: + con_list.extend(self._getPossibleModificationsRotation(self.con.hFlip(), self.status.hFlip())) + + # add v flip variants + if self.mods.can_v_flip: + con_list.append((self.con.vFlip(), self.status.vFlip())) + # include v flip rotated + if self.mods.can_rot: + con_list.extend(self._getPossibleModificationsRotation(self.con.vFlip(), self.status.vFlip())) + + # add v flip and h flip + if self.mods.can_v_flip and self.mods.can_h_flip: + con_list.append((self.con.vFlip().hFlip(), self.status.vFlip().hFlip())) + + # add original rotations + con_list.extend(self._getPossibleModificationsRotation(self.con, self.status)) + + # remove duplicates TODO all of this can be optimized + unique_ids = set() + ret = [] + for con, status in con_list: + encoded_val = con.encode() + if encoded_val not in unique_ids: + ret.append((con, status)) + unique_ids.add(encoded_val) + + return ret + + def _getPossibleModificationsRotation(self, con: TileConnection, status: TileStatus): + con_list = [] + if self.mods.can_rot: + con_rot1, status_rot1 = con.rotate90(), status.rot90() + con_rot2, status_rot2 = con_rot1.rotate90(), status_rot1.rot90() + con_rot3, status_rot3 = con_rot2.rotate90(), status_rot2.rot90() + con_list.append((con_rot1, status_rot1)) + con_list.append((con_rot2, status_rot2)) + con_list.append((con_rot3, status_rot3)) + return con_list + + def getAllPossibleTileStates(self) -> List[TileMapState]: + all_possible_modifications = self.getAllPossibleModifications() + all_possible_tile_states = [] + for modified_state, status in all_possible_modifications: + + # every modification state, e.g. rotating flipping, might have other neighborhood combinations + neighborhoods = modified_state.getPossibleNeighborhoods() + for neighborhood in neighborhoods: + # note, that for every modified state, the tile status, e.g. is_rotated, is_v_flipped is the same + all_possible_tile_states.append((neighborhood, status.__copy__())) + return all_possible_tile_states def __copy__(self): td = self.con.__copy__() - return TileData(td, self.h_flip, self.v_flip, self.rot, self.empty) + ts = self.status.__copy__() + tm = self.mods.__copy__() + return TileData(td, ts, tm) def __eq__(self, other): if isinstance(other, TileData): - return self.con == other.con and self.h_flip == other.h_flip and self.v_flip == other.v_flip \ - and self.rot == other.rot and self.empty == other.empty + return self.con == other.con and \ + self.mods == other.mods and \ + self.status == other.status raise NotImplementedError diff --git a/src/logic/tile_handler.py b/src/logic/tile_handler.py index fd45035..fe9f0e9 100644 --- a/src/logic/tile_handler.py +++ b/src/logic/tile_handler.py @@ -1,12 +1,17 @@ -from typing import List, Dict, TYPE_CHECKING, Set +from typing import List, Dict, TYPE_CHECKING, Tuple + +from PyQt5.QtGui import QPixmap from src.globals import EIGHT_NEIGHBORS from src.logic.tile_connection import TileConnection -from src.logic.tile_data import TileData +from src.logic.tile_status import TileStatus +from src.widgets.widget_base_tile import BaseTile if TYPE_CHECKING: from src.widgets.widget_tile import Tile +NeighborhoodEntry = Tuple[int, TileStatus] + class TileHandler: _instance = None @@ -21,46 +26,54 @@ def _init(self): # Reverse Storage: # maps tile ID to storage locations in order to allow for removals storage_size = 2 ** EIGHT_NEIGHBORS - self.relation_storage: Dict[int, List[int]] = {} + # small_enc -> tile_id + self.neighborhood_map: Dict[int, List[NeighborhoodEntry]] = {} for i in range(storage_size): - self.relation_storage[i] = [] - self.tile_data_storage: Dict[int, TileData] = {} - self.id_map: Dict[int, "Tile"] = {} - - def addTileRelations(self, tile: "Tile"): - # A tile can have multiple relations due to rotation and flipping - permutations: Set[int] = tile.tile_data.getPermutations() - for neighbor_enc in permutations: - # add relation to storage - if tile.getID() not in self.relation_storage[neighbor_enc]: - self.relation_storage[neighbor_enc].append(tile.getID()) - - # add relation to reverse storage - self.tile_data_storage[tile.getID()] = tile.tile_data - - """ - removes tile relations - """ - - def removeTileRelations(self, tile_id: int): + self.neighborhood_map[i] = [] + + self.tile_id_map: Dict[int, BaseTile] = {} + + # stores simply all pixmaps for a tile_id + self.pix_map: Dict[int, QPixmap] = {} + + def addTileToStorage(self, tile: BaseTile): + if tile.tile_data is None: + raise ValueError("Tile data is none") + tile_states = tile.tile_data.getAllPossibleTileStates() + for tile_connection, tile_status in tile_states: + enc_value = tile_connection.encodeSmall() + + entry: NeighborhoodEntry = (tile.tile_id, tile_status) + self.neighborhood_map[enc_value].append(entry) # add tuple, tile with configuration + + self.tile_id_map[tile.getID()] = tile.__copy__() + + def removeTileFromStorage(self, tile_id: int): # tile not in or already removed - if tile_id not in self.tile_data_storage: + if tile_id not in self.tile_id_map: return + tile = self.tile_id_map[tile_id] - # remove from encodings - permutations: Set[int] = self.tile_data_storage[tile_id].getPermutations() - for neighbor_enc in permutations: - self.relation_storage[neighbor_enc].remove(tile_id) + # remove tile from neighborhood map + tile_states = tile.tile_data.getAllPossibleTileStates() + for tile_connection, tile_status in tile_states: + enc_value = tile_connection.encodeSmall() + to_delete = [] + for index, (entry_tile_id, _) in enumerate(self.neighborhood_map[enc_value]): + if entry_tile_id == tile_id: + to_delete.append(index) - # remove from reverse map - del self.tile_data_storage[tile_id] + # reverse, because the indices change with each deletion, this bug was ugly + to_delete.reverse() + for entry_tile_id in to_delete: + del self.neighborhood_map[enc_value][entry_tile_id] - def updateTileRelations(self, tile: "Tile"): - self.removeTileRelations(tile.getID()) - self.addTileRelations(tile) + # remove from tile id map + del self.tile_id_map[tile_id] - def addTile(self, tile: "Tile"): - self.id_map[tile.tile_id] = tile + def updateTileStorage(self, tile: BaseTile): + self.removeTileFromStorage(tile.tile_id) + self.addTileToStorage(tile) @classmethod def instance(cls): @@ -69,23 +82,31 @@ def instance(cls): cls._instance._init() return cls._instance + def addPixmap(self, tile: BaseTile): + if tile.tile_id in self.pix_map: + raise ValueError(f"ID {tile.tile_id} already in pixmap") # no overwrite by design + if tile.pixmap() is None: + raise ValueError("Pixmap is empty") + self.pix_map[tile.tile_id] = tile.pixmap() + def getPixmap(self, tile_id: int): - if tile_id not in self.id_map: - return ValueError(f"ID {tile_id} not known") - return self.id_map[tile_id].pixmap() - - def findTiles(self, tile_connection: TileConnection) -> List[int]: - tile_connections: List[TileConnection] = tile_connection.getPermutations() + if tile_id not in self.pix_map: + raise ValueError(f"ID {tile_id} not known") + return self.pix_map[tile_id] + + def findTiles(self, tile_connection: TileConnection) -> List[NeighborhoodEntry]: + """ + Returns all tiles, that match a neighborhood + """ + tile_connections: List[TileConnection] = tile_connection.getPossibleNeighborhoods() ret = [] for con in tile_connections: con_enc = con.encodeSmall() - assert con_enc in self.relation_storage - ret.extend(self.relation_storage[con_enc]) + # assert con_enc in self.neighborhood_map + ret.extend(self.neighborhood_map[con_enc]) return ret - - def getTile(self, tile_id: int) -> "Tile": - if tile_id not in self.id_map: + + def getTile(self, tile_id: int) -> BaseTile: + if tile_id not in self.tile_id_map: raise ValueError(f"Unknown tile ID {tile_id}") - return self.id_map[tile_id] - - + return self.tile_id_map[tile_id].__copy__() diff --git a/src/logic/tile_modificators.py b/src/logic/tile_modificators.py new file mode 100644 index 0000000..7435951 --- /dev/null +++ b/src/logic/tile_modificators.py @@ -0,0 +1,18 @@ +class TileMods: + """ + This class holds all properties, that describe how a tile can be modified + """ + def __init__(self, can_flip_h: bool, can_flip_v: bool, can_rot: bool): + self.can_h_flip = can_flip_h + self.can_v_flip = can_flip_v + self.can_rot = can_rot + + def __eq__(self, other): + if isinstance(other, TileMods): + return self.can_h_flip == other.can_h_flip and \ + self.can_v_flip == other.can_v_flip and \ + self.can_rot == other.can_rot + raise NotImplementedError + + def __copy__(self): + return TileMods(self.can_h_flip, self.can_v_flip, self.can_rot) diff --git a/src/logic/tile_status.py b/src/logic/tile_status.py new file mode 100644 index 0000000..0f2e36c --- /dev/null +++ b/src/logic/tile_status.py @@ -0,0 +1,48 @@ +class TileStatus: + """ + This class holds all properties of a tile, which describe how a tile IS modified + E.G the tile is flipped, rotated or set to empty + """ + def __init__(self): + self.v_flip = False + self.h_flip = False + self.rot = 0 + self.empty = False + + def __copy__(self) -> "TileStatus": + t = TileStatus() + t.v_flip = self.v_flip + t.h_flip = self.h_flip + t.rot = self.rot + t.empty = self.empty + return t + + def vFlip(self): + status = self.__copy__() + status.v_flip = not self.v_flip + return status + + def hFlip(self): + status = self.__copy__() + status.h_flip = not self.h_flip + return status + + def rot45(self): + status = self.__copy__() + status.rot += 45 + status.rot %= 360 + return status + + def rot90(self): + status = self.__copy__() + status.rot += 90 + status.rot %= 360 + return status + + def __eq__(self, other): + if isinstance(other, TileStatus): + return self.v_flip == other.v_flip and \ + self.h_flip == other.h_flip and \ + self.rot == other.rot and \ + self.empty == other.empty + raise NotImplementedError diff --git a/src/signals/signal_emitter.py b/src/signals/signal_emitter.py index 80e3d45..792198e 100644 --- a/src/signals/signal_emitter.py +++ b/src/signals/signal_emitter.py @@ -1,5 +1,6 @@ from PyQt5.QtCore import pyqtSignal, QObject -class NeighborClickedEmitter(QObject): +class ConfigurationClickedEmitter(QObject): neighbor_signal = pyqtSignal(int, name="NeighborClick") + modification_signal = pyqtSignal(int, bool, name="ModificationSignal") diff --git a/src/widgets/widget_base_tile.py b/src/widgets/widget_base_tile.py index d4192e1..fee4e61 100644 --- a/src/widgets/widget_base_tile.py +++ b/src/widgets/widget_base_tile.py @@ -1,19 +1,56 @@ -from typing import Any +from typing import Any, Optional -from PyQt5.QtGui import QPainter +from PyQt5.QtGui import QPainter, QTransform, QPixmap from PyQt5.QtWidgets import QLabel -from images_helper import ImageHelper +from src.images_helper import ImageHelper +from src.logic.tile_data import TileData class BaseTile(QLabel): - def __init__(self, *__args): + def __init__(self, tile_id: int, tile_data: Optional[TileData] = None, *__args): super().__init__(*__args) + self.tile_data = tile_data + self.tile_id = tile_id + + def getID(self) -> int: + return self.tile_id def paintEvent(self, e: Any, _: Any = None) -> None: qp = QPainter(self) # draw background ImageHelper.instance().drawCheckerImage(qp, self.size().width(), self.size().height()) + + pm: Optional[QPixmap] = self.pixmap() + if pm and self.tile_data: + # handle transformations + transform = QTransform() + + transform.translate(pm.width() // 2, pm.height() // 2) + if self.tile_data.status.h_flip: + transform = transform.scale(1, -1) + + if self.tile_data.status.v_flip: + transform = transform.scale(-1, 1) + + if self.tile_data.status.rot: + transform.rotate(self.tile_data.status.rot) + transform.translate(-pm.width() // 2, -pm.height() // 2) + + # draw tile + qp.setTransform(transform) + qp.drawPixmap(0, 0, pm) + + # qp.drawText(self, 0, pm.height() // 2, ) + qp.end() - super().paintEvent(e) + + def __copy__(self): + tile_data = None + if self.tile_data: + tile_data = self.tile_data.__copy__() + cp = BaseTile(self.tile_id, tile_data) + if self.pixmap(): + cp.setPixmap(self.pixmap()) + return cp diff --git a/src/widgets/widget_tile.py b/src/widgets/widget_tile.py index c224fa2..b2493e1 100644 --- a/src/widgets/widget_tile.py +++ b/src/widgets/widget_tile.py @@ -1,5 +1,8 @@ from PyQt5.QtGui import QPainter, QPen, QColor, QPixmap, QImage from PyQt5.QtCore import Qt + +from logic.tile_modificators import TileMods +from src.logic.tile_status import TileStatus from src.dialogs.dialog_tile_settings import TileSettingsDialog from src.logic.tile_connection import TileConnection from src.logic.tile_data import TileData @@ -11,8 +14,7 @@ class Tile(BaseTile): def __init__(self, tile_id: int, width=64, height=64) -> None: - super().__init__() - self.tile_id: int = tile_id + super().__init__(tile_id) self.width = width self.height = height self.setMaximumSize(width, height) @@ -21,16 +23,11 @@ def __init__(self, tile_id: int, width=64, height=64) -> None: self.hovered = False self.selected = False self.dialog = TileSettingsDialog(self) - self.tile_data: Optional[TileData] = None self.alpha = None self.setMouseTracking(True) self.lock = True # lock as long as empty self.data_checked = False # true after checked self.image: Optional[QImage] = None - TileHandler.instance().addTile(self) - - def getID(self) -> int: - return self.tile_id def paintEvent(self, e: Any, @@ -80,7 +77,7 @@ def openDialog(self, _): # update tile data in dialog # use a copy, because if you press cancel, this should still store the original values if self.tile_data: - self.dialog.setTileData(self.tile_data.__copy__()) + self.dialog.setTileData(self.tile_data) ret = self.dialog.exec() # ok pressed if ret == 1: @@ -88,7 +85,7 @@ def openDialog(self, _): self.tile_data = self.dialog.getTileData() print(self.tile_data.con) self.data_checked = True - TileHandler.instance().updateTileRelations(self) + TileHandler.instance().updateTileStorage(self) # mark tile as deselected after dialog self.selected = False @@ -104,6 +101,7 @@ def leaveEvent(self, event): def setPixmap(self, pixmap: QPixmap): super().setPixmap(pixmap) + TileHandler.instance().addPixmap(self) self.image = pixmap.toImage().convertToFormat(QImage.Format_ARGB32) self.scanImage() @@ -136,6 +134,10 @@ def _scanCornersAndEdges(self): has_alpha_neighbors.append(int(alpha > 0)) # todo autodetect symmetry for flip and rot - self.tile_data = TileData(TileConnection(has_alpha_neighbors), False, False, False, empty) + ts = TileStatus() + ts.empty = empty + tc = TileConnection(has_alpha_neighbors) + tm = TileMods(False, False, False) + self.tile_data = TileData(tc, ts, tm) # don't save them in the handler, only solve valid tiles there # TileHandler.instance().updateTileRelations(self) diff --git a/tests/logic/test_tile_connection.py b/tests/logic/test_tile_connection.py index 71e82a8..5059c5d 100644 --- a/tests/logic/test_tile_connection.py +++ b/tests/logic/test_tile_connection.py @@ -146,6 +146,21 @@ def test_rotate90(self, neighbors): assert t2 == t assert t2.rotate45().rotate45() == t.rotate90() + @pytest.mark.parametrize("rotated, neighbors", [ + [0b00100000, [1, 0, 0, 0, 0, 0, 0, 0]], + [0b00001000, [0, 1, 0, 0, 0, 0, 0, 0]], + [0b00000001, [0, 0, 1, 0, 0, 0, 0, 0]], + [0b01000000, [0, 0, 0, 1, 0, 0, 0, 0]], + [0b00000010, [0, 0, 0, 0, 1, 0, 0, 0]], + [0b10000000, [0, 0, 0, 0, 0, 1, 0, 0]], + [0b00010000, [0, 0, 0, 0, 0, 0, 1, 0]], + [0b00000100, [0, 0, 0, 0, 0, 0, 0, 1]], + ]) + def test_rotation_is_clockwise(self, rotated, neighbors): + t = TileConnection(neighbors) + rot = t.rotate90() + assert rot.encodeSmall() == rotated + @pytest.mark.parametrize("neighbors", correct_neighbors()) def test_v_flip(self, neighbors): t = TileConnection(neighbors) @@ -174,7 +189,7 @@ def test_flip_rot_sanity(self, neighbors): ]) def test_get_permutations(self, expected, neighbors): t = TileConnection(neighbors) - perms = t.getPermutations() + perms = t.getPossibleNeighborhoods() perms_enc = [p.encodeSmall() for p in perms] if expected == "all": assert len(perms) == 2 ** 8 diff --git a/tests/logic/test_tile_data.py b/tests/logic/test_tile_data.py new file mode 100644 index 0000000..d194962 --- /dev/null +++ b/tests/logic/test_tile_data.py @@ -0,0 +1,122 @@ +import pytest + +from src.logic.tile_connection import TileConnection +from src.logic.tile_modificators import TileMods +from src.logic.tile_status import TileStatus +from src.logic.tile_data import TileData + + +class TestTileData: + @staticmethod + def create_tile_data(neighbors, status, mods) -> TileData: + ts = TileStatus() + ts.v_flip = status[0] + ts.h_flip = status[1] + ts.rot = status[2] + ts.empty = status[3] + return TileData(TileConnection(neighbors), + ts, + TileMods(mods[0], mods[1], mods[2])) + + @staticmethod + def tile_data_list(create_tile_data): + return [ + create_tile_data([0, 1, 0, 0, 0, 0, 0, 0], [False, False, False, False], [False, False, False]), + create_tile_data([2, 1, 2, 0, 0, 2, 0, 2], [False, False, False, False], [False, False, True]), + create_tile_data([0, 1, 2, 0, 1, 2, 0, 1], [False, False, False, False], [True, True, False]), + create_tile_data([0, 1, 0, 0, 0, 0, 0, 0], [False, False, False, True], [True, True, False]), + create_tile_data([0, 1, 0, 0, 0, 0, 0, 0], [False, False, False, True], [False, True, False]), + create_tile_data([0, 0, 0, 1, 0, 0, 0, 0], [False, False, False, True], [True, False, False]), + create_tile_data([2, 1, 2, 0, 0, 2, 0, 2], [False, False, False, False], [False, False, False]), + create_tile_data([1, 0, 1, 0, 0, 1, 0, 1], [False, False, False, False], [False, False, True]), + create_tile_data([2, 2, 2, 2, 2, 2, 2, 2], [True, True, True, False], [True, True, True]), + create_tile_data([0, 1, 2, 1, 0, 2, 1, 0], [True, True, True, False], [True, True, True]), + ] + + @staticmethod + def tile_data_list_modifications(tile_data_list, create_tile_data): + ret = [] + td_list = tile_data_list(create_tile_data) + num_modifications = [ + 1, # no modifications + 4, # only rotations + 4, # fliph and flipv + 2, # only vflip does something + 1, # hflip doesn't do anything + 1, # vflip doesn't do anything + 1, # none + 1, # rotating doesn't do anything + 1, # fliph, flipv and rot, but nothing changes a thing + 8, # flips and rot + ] + + # pack for pytest + for i, td in enumerate(td_list): + ret.append([num_modifications[i], td]) + return ret + + @pytest.mark.parametrize("tile_data", tile_data_list(create_tile_data)) + def test_init(self, tile_data): + assert isinstance(tile_data, TileData) + + def test_equal(self): + td_list = self.tile_data_list(self.create_tile_data) + for i, td in enumerate(td_list): + assert td == td + assert td != td_list[(i + 1) % len(td_list)] + + @pytest.mark.parametrize("tile_data", tile_data_list(create_tile_data)) + def test_copy(self, tile_data): + t2 = tile_data.__copy__() + assert t2 == tile_data + assert t2.con == tile_data.con + assert t2.status == tile_data.status + assert t2.mods == tile_data.mods + assert id(t2) != id(tile_data) + # test deep copy + assert id(t2.con) != id(tile_data.con) + assert id(t2.status) != id(tile_data.status) + assert id(t2.mods) != id(tile_data.mods) + + @pytest.mark.parametrize("num_mods, tile_data", tile_data_list_modifications(tile_data_list, create_tile_data)) + def test_get_modifications(self, num_mods, tile_data): + mods = tile_data.getAllPossibleModifications() + assert len(mods) == num_mods + + # check, that I am working on copies + for tc, ts in mods: + assert id(tc) != id(tile_data.con) + assert id(ts) != id(tile_data.status) + + # check every tile status is only once available + ts_list = [ts for _, ts in mods] + for ts in ts_list: + # I call copy here explicitly to show, that count does go over value and not reference + assert ts_list.count(ts.__copy__()) == 1 + + @pytest.mark.parametrize("num_mods, tile_data", tile_data_list_modifications(tile_data_list, create_tile_data)) + def test_get_tile_states(self, num_mods, tile_data): + numb_changeable_states = tile_data.con.getNeighbors().count(2) + num_states = 2**numb_changeable_states + + possible_tile_states = tile_data.getAllPossibleTileStates() + + # sanity checks (no sanity found) + assert len(possible_tile_states) == num_mods * num_states + assert len(possible_tile_states) > 0 + + # check, that there is no ANY connection left + for tile_con, _ in possible_tile_states: + assert tile_con.getNeighbors().count(2) == 0 + + # check, that I am working on copies + for tc, ts in possible_tile_states: + assert id(tc) != id(tile_data.con) + assert id(ts) != id(tile_data.status) + + tc_list = [tc for tc, _ in possible_tile_states] + + # check easy combinations, that should be in + # Note, that multiple states can have the same connection + assert tc_list.count(tile_data.con.getFull()) >= 1 + assert tc_list.count(tile_data.con.getEmpty()) >= 1 diff --git a/tests/logic/test_tile_handler.py b/tests/logic/test_tile_handler.py new file mode 100644 index 0000000..93f95db --- /dev/null +++ b/tests/logic/test_tile_handler.py @@ -0,0 +1,115 @@ +from unittest.mock import patch, Mock + +import pytest +from PyQt5.QtGui import QPixmap + +from src.logic.tile_connection import TileConnection +from src.logic.tile_data import TileData +from src.logic.tile_modificators import TileMods +from src.logic.tile_status import TileStatus +from src.logic.tile_handler import TileHandler +from src.widgets.widget_base_tile import BaseTile + + +class TestTileHandler: + def test_init(self): + with pytest.raises(RuntimeError): + TileHandler() + + def test_instance(self): + assert isinstance(TileHandler.instance(), TileHandler) + + def test_reference(self): + th1 = TileHandler.instance() + th2 = TileHandler.instance() + + assert id(th1) == id(th2) + + @pytest.fixture + def tile(self, qtbot): + tc = TileConnection([0, 1, 2, 1, 0, 1, 0, 2]) # asymetrical + ts = TileStatus() + tm = TileMods(True, True, True) + td = TileData(tc, ts, tm) + return BaseTile(0, td) + + @pytest.fixture + def add_tile(self, tile): + TileHandler.instance().addTileToStorage(tile) + yield + TileHandler.instance().removeTileFromStorage(tile.tile_id) + + @pytest.fixture + def add_pixmap(self, tile): + m = QPixmap() + tile.setPixmap(m) + TileHandler.instance().addPixmap(tile) + yield + del TileHandler.instance().pix_map[tile.tile_id] + + def test_add_tile_to_storage(self, add_tile): + pass + + def test_get_tile(self): + with pytest.raises(ValueError): + TileHandler.instance().getTile(1) + + def test_get_pixmap(self, add_tile, add_pixmap): + assert TileHandler.instance().getPixmap(0) is not None + + def test_get_pixmap_invalid_id(self, qtbot): + with pytest.raises(ValueError): + TileHandler.instance().getPixmap(256) + + def test_add_pixmap_already_entered(self, tile, add_pixmap): + with pytest.raises(ValueError): + TileHandler.instance().addPixmap(tile) + + def test_add_pixmap_empty(self, tile): + with pytest.raises(ValueError): + TileHandler.instance().addPixmap(tile) + + def test_add_tile_to_storage_illegal_base_tile(self, qtbot): + with pytest.raises(ValueError): + b = BaseTile(0, None) + TileHandler.instance().addTileToStorage(b) + + def test_find_tiles(self, add_tile, tile): + tile_states = tile.tile_data.getAllPossibleTileStates() + states = [s for _, s in tile_states] + for tile_con, _ in tile_states: + neighborhood_entry_list = TileHandler.instance().findTiles(tile_con) + assert len(neighborhood_entry_list) > 0 + for tile_id, state in neighborhood_entry_list: + assert tile_id == tile.tile_id + assert state in states + + def test_remove_tile_from_storage(self, add_tile, tile): + TileHandler.instance().removeTileFromStorage(tile.tile_id) + tile_states = tile.tile_data.getAllPossibleTileStates() + for tile_con, _ in tile_states: + neighborhood_entry_list = TileHandler.instance().findTiles(tile_con) + assert len(neighborhood_entry_list) == 0 + + assert len(TileHandler.instance().tile_id_map) == 0 + + def test_update_storage(self, add_tile, tile): + neighborhood_entry_list = TileHandler.instance().findTiles(tile.tile_data.con) + num_matches = len(neighborhood_entry_list) + TileHandler.instance().updateTileStorage(tile) + + neighborhood_entry_list = TileHandler.instance().findTiles(tile.tile_data.con) + num_matches2 = len(neighborhood_entry_list) + assert num_matches2 == num_matches + + # disable rotating and flipping + tile.tile_data.mods.can_rot = not tile.tile_data.mods.can_rot + tile.tile_data.mods.can_v_flip = not tile.tile_data.mods.can_v_flip + tile.tile_data.mods.can_h_flip = not tile.tile_data.mods.can_h_flip + TileHandler.instance().updateTileStorage(tile) + neighborhood_entry_list = TileHandler.instance().findTiles(tile.tile_data.con) + num_matches3 = len(neighborhood_entry_list) + assert num_matches3 != num_matches + + # the only changes are coming from the tile connections + assert num_matches3 == 2 ** tile.tile_data.con.getNeighbors().count(2) diff --git a/tests/logic/test_tile_modificators.py b/tests/logic/test_tile_modificators.py new file mode 100644 index 0000000..a73d49d --- /dev/null +++ b/tests/logic/test_tile_modificators.py @@ -0,0 +1,41 @@ +import pytest + +from src.logic.tile_modificators import TileMods + + +class TestTileModificators: + @staticmethod + def modificators(): + return [ + [False, False, False], + [False, False, True], + [False, True, False], + [False, True, True], + [True, False, False], + [True, False, True], + [True, True, False], + [True, True, True], + ] + + @pytest.mark.parametrize("mod", modificators()) + def test_init(self, mod): + TileMods(mod[0], mod[1], mod[2]) + + def test_eq(self): + modies = self.modificators() + for i, mods in enumerate(modies): + mod = TileMods(mods[0], mods[1], mods[2]) + assert mod == mod + mods2 = modies[(i + 1) % len(modies)] + mod2 = TileMods(mods2[0], mods2[1], mods2[2]) + assert mod != mod2 + + @pytest.mark.parametrize("mods", modificators()) + def test_copy(self, mods): + mod = TileMods(mods[0], mods[1], mods[2]) + assert mod == mod + mod2 = mod.__copy__() + assert mod == mod2 + assert id(mod) != id(mod2) + mod2.can_h_flip = not mod2.can_h_flip + assert mod != mod2 diff --git a/tests/logic/test_tile_status.py b/tests/logic/test_tile_status.py new file mode 100644 index 0000000..4b2bd78 --- /dev/null +++ b/tests/logic/test_tile_status.py @@ -0,0 +1,66 @@ +import pytest + +from src.logic.tile_status import TileStatus + + +class TestTileStatus: + def test_init(self): + TileStatus() + + @staticmethod + def create_tile_status(v_flip, h_flip, rot, empty) -> TileStatus: + assert rot % 90 == 0 + t = TileStatus() + t.v_flip = v_flip + t.h_flip = h_flip + t.rot = rot + t.empty = empty + return t + + @staticmethod + def status_list(create_tile_status): + return [ + TileStatus(), + create_tile_status(True, False, 90, False), + create_tile_status(False, False, 180, True), + create_tile_status(True, True, 270, True), + create_tile_status(False, True, 90, False), + ] + + @pytest.mark.parametrize("status", status_list(create_tile_status)) + def test_copy_and_equal(self, status): + t2 = status.__copy__() + assert t2 == status + assert id(t2) != id(status) + t2.h_flip = not t2.h_flip + assert t2 != status + t2.h_flip = not t2.h_flip + assert t2 == status + + @pytest.mark.parametrize("status", status_list(create_tile_status)) + def test_rot90(self, status): + rot = status.rot + rot_status = status.rot90() + assert rot_status.rot % 90 == 0 + assert rot_status.rot < 360 + # make sure it's clockwise + assert rot_status.rot == 0 or rot_status.rot > rot + assert status == status.rot90().rot90().rot90().rot90() + + @pytest.mark.parametrize("status", status_list(create_tile_status)) + def test_rot45(self, status): + assert status.rot45().rot45() == status.rot90() + + @pytest.mark.parametrize("status", status_list(create_tile_status)) + def test_v_flip(self, status): + t = status.vFlip() + assert t != status + assert t.v_flip != status.v_flip + assert id(t) != id(status) + + @pytest.mark.parametrize("status", status_list(create_tile_status)) + def test_h_flip(self, status): + t = status.hFlip() + assert t != status + assert t.h_flip != status.h_flip + assert id(t) != id(status)