Skip to content

Commit fd7a711

Browse files
committed
add rotation to the tileviewer dialog
1 parent 4a455e4 commit fd7a711

5 files changed

Lines changed: 252 additions & 28 deletions

File tree

‎src/backend/tile_connection.py‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
from src.globals import EIGHT_NEIGHBORS, NUM_NEIGHBOR_BITS, NUM_NEIGHBOR_STATES, NEIGHBOR_BIT_COMPARATOR
22
from typing import List, Set
33

4+
# relative (x, y) offsets of the 8-neighborhood, y positive means up (DDNet "Pos" convention)
5+
NEIGHBOR_OFFSETS = [
6+
(-1, 1), (0, 1), (1, 1),
7+
(-1, 0), (1, 0),
8+
(-1, -1), (0, -1), (1, -1),
9+
]
10+
NEIGHBOR_INDEX_BY_OFFSET = {offset: index for index, offset in enumerate(NEIGHBOR_OFFSETS)}
11+
412

513
class TileConnection:
614
def __init__(self, neighbors: list):
@@ -114,11 +122,34 @@ def getEmpty(self) -> "TileConnection":
114122
neighbors = self._neighbors
115123
return TileConnection([n % 2 for n in neighbors])
116124

125+
def getInverseNeighborhood(self, button_id: int, default: int = 2) -> "TileConnection":
126+
"""
127+
Builds the TileConnection a neighbor button sees, given this (center) connection.
128+
129+
The neighbor at ``button_id`` observes the 8-neighborhood around itself. Each slot is
130+
filled with the relationship this center tile stores for the tile sitting there, and the
131+
center itself is looked up at the slot facing button_id (its own relation, mirrored).
132+
slots that point outside the center's 3x3 grid are set to ``default``.
133+
"""
134+
if button_id < 0 or button_id >= EIGHT_NEIGHBORS:
135+
raise ValueError(f"Invalid neighbor ID {button_id}")
136+
if default < 0 or default > 2:
137+
raise ValueError(f"Invalid default {default}")
138+
ret = [default] * EIGHT_NEIGHBORS
139+
bx, by = NEIGHBOR_OFFSETS[button_id]
140+
for local_index, (lx, ly) in enumerate(NEIGHBOR_OFFSETS):
141+
gx, gy = bx + lx, by + ly
142+
if gx == 0 and gy == 0:
143+
ret[local_index] = self._neighbors[button_id]
144+
elif -1 <= gx <= 1 and -1 <= gy <= 1:
145+
ret[local_index] = self._neighbors[NEIGHBOR_INDEX_BY_OFFSET[(gx, gy)]]
146+
return TileConnection(ret)
147+
117148
def setNeighbor(self, neighbor_id, state):
118149
if state < 0 or state > 2:
119-
return ValueError(f"Invalid state {state}")
120-
if neighbor_id < 0 or neighbor_id > EIGHT_NEIGHBORS:
121-
return ValueError(f"Invalid neighbor ID {neighbor_id}")
150+
raise ValueError(f"Invalid state {state}")
151+
if neighbor_id < 0 or neighbor_id >= EIGHT_NEIGHBORS:
152+
raise ValueError(f"Invalid neighbor ID {neighbor_id}")
122153
self._neighbors[neighbor_id] = state
123154

124155

‎src/buttons/button_tile_connection.py‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from typing import TYPE_CHECKING, Any, Optional
22

3-
from PyQt6.QtCore import QPoint, Qt
4-
from PyQt6.QtGui import QPainter, QPolygon, QRegion, QPixmap, QColor, QPen
3+
from PyQt6.QtCore import Qt
4+
from PyQt6.QtGui import QPainter, QPixmap, QColor, QPen, QTransform
55
from PyQt6.QtWidgets import QAbstractButton
66

77
from src.images_helper import ImageHelper
88
from src.backend.tile_handler import TileHandler
9+
from src.backend.tile_status import TileStatus
910
from src.signals.signal_emitter import ConfigurationClickedEmitter
1011

1112
if TYPE_CHECKING:
@@ -26,6 +27,7 @@ def __init__(self, button_id, parent=None):
2627
self._state = 2 # Any
2728
self._num_states = 3
2829
self._tile = None
30+
self._tile_status = None
2931
self._main = False
3032
self.setMinimumSize(128, 128)
3133
self.setMaximumSize(128, 128)
@@ -47,18 +49,35 @@ def _paintTile(self, qp: QPainter):
4749
if self._state == 2:
4850
return
4951
size = self.size()
50-
# clip region for ANY in order to show state between empty and full
51-
# if not self._main and self._state == 2:
52-
# polygon = QPolygon()
53-
# polygon << QPoint(0, 0) << QPoint(size.width(), size.height()) << QPoint(0, size.height())
54-
# reg = QRegion(polygon)
55-
# qp.setClipRegion(reg)
5652

5753
# draw tile
5854
pm = self._findPixmap()
5955
if pm:
60-
qp.drawPixmap(0, 0, size.width(), size.height(), pm)
61-
# qp.setClipping(False)
56+
transform = QTransform()
57+
58+
if size.width() != pm.width() or size.height() != pm.height():
59+
factor_width = size.width() / pm.width()
60+
factor_height = size.height() / pm.height()
61+
transform.scale(factor_width, factor_height)
62+
63+
if self._tile_status:
64+
transform.translate(pm.width() // 2, pm.height() // 2)
65+
66+
# rotate first, because otherwise you might rotate your flip breaking this in the process
67+
if self._tile_status.rot:
68+
transform.rotate(90)
69+
70+
if self._tile_status.y_flip:
71+
transform = transform.scale(1, -1)
72+
73+
if self._tile_status.x_flip:
74+
transform = transform.scale(-1, 1)
75+
76+
transform.translate(-pm.width() // 2, -pm.height() // 2)
77+
78+
qp.setTransform(transform)
79+
qp.drawPixmap(0, 0, pm)
80+
qp.resetTransform()
6281

6382
# will be overwritten
6483
def _paintOutline(self, qp: QPainter):
@@ -113,15 +132,20 @@ def leaveEvent(self, e):
113132
self.update()
114133
super().leaveEvent(e)
115134

116-
def setTile(self, tile: Optional["Tile"], update_neighbors=True):
117-
if self._tile and tile and \
118-
(self._tile.getID() == tile.getID() or self._tile.tile_data == tile.tile_data):
119-
return
135+
def setTile(self, tile: Optional["Tile"], update_neighbors=True, tile_status: Optional[TileStatus] = None):
136+
if self._tile and tile:
137+
same_id = self._tile.getID() == tile.getID()
138+
same_status = self._tile_status is not None and tile_status is not None \
139+
and self._tile_status == tile_status
140+
if same_id and same_status:
141+
return
120142

121143
if tile is None:
122144
self._tile = tile
145+
self._tile_status = None
123146
else:
124147
self._tile = tile.__copy__()
148+
self._tile_status = tile_status.__copy__() if tile_status else None
125149

126150
if update_neighbors:
127151
self._update_neighborhood()

‎src/dialogs/dialog_tile_settings.py‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def onConnectionButtonClick(self, button_id: int):
103103
neighbor_buttons = self._getNeighborButtons(button_id)
104104
for i in range(EIGHT_NEIGHBORS):
105105
if neighbor_buttons[i] is not None:
106-
self._updateTile(i)
106+
self._updateTile(neighbor_buttons[i])
107107

108108
def onModificationChange(self, modification: int, value: bool):
109109
match modification:
@@ -126,22 +126,15 @@ def _updateTileData(self, button_id: int):
126126
self._tile_data.con.setNeighbor(button_id, state)
127127

128128
def _updateTile(self, button_id: int):
129-
neighbors = [1] * EIGHT_NEIGHBORS # all empty (for now) TODO change view with button
130-
neighbor_buttons = self._getNeighborButtons(button_id)
131-
for i in range(EIGHT_NEIGHBORS):
132-
if neighbor_buttons[i] is not None:
133-
neighbors[i] = self._tile_data.con.getNeighbors()[neighbor_buttons[i]]
134-
con = TileConnection(neighbors)
135-
136-
tile_connection = con.getEmpty()
129+
tile_connection = self._tile_data.con.getInverseNeighborhood(button_id, default=2)
137130
tile_id_list = TileHandler.instance().findTiles(tile_connection)
138131
if len(tile_id_list):
139132
# yay, I found a tile that connects in this location
140133
# use a random one, because this shouldn't matter
141134
rand_tile = random.randint(0, len(tile_id_list) - 1)
142-
tile_id, _ = tile_id_list[rand_tile]
135+
tile_id, tile_status = tile_id_list[rand_tile]
143136
tile = TileHandler.instance().getTile(tile_id)
144-
self.buttons[button_id].setTile(tile, False)
137+
self.buttons[button_id].setTile(tile, False, tile_status)
145138
else: # No tile? reset
146139
self.buttons[button_id].setTile(None, False)
147140

‎tests/logic/test_tile_connection.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,74 @@ def test_get_permutations(self, expected, neighbors):
189189
assert len(expected) == len(perms)
190190
for exp in expected:
191191
assert exp in perms_enc
192+
193+
# relative offsets of the 8-neighborhood, y positive = up, matching the dialog grid layout:
194+
# 0 1 2
195+
# 3 . 4
196+
# 5 6 7
197+
@staticmethod
198+
def _offset(index):
199+
return [
200+
(-1, 1), (0, 1), (1, 1),
201+
(-1, 0), (1, 0),
202+
(-1, -1), (0, -1), (1, -1),
203+
][index]
204+
205+
@staticmethod
206+
def _expected_inverse(neighbors, button_id, default):
207+
offsets = [(-1, 1), (0, 1), (1, 1), (-1, 0), (1, 0), (-1, -1), (0, -1), (1, -1)]
208+
index_by_offset = {o: i for i, o in enumerate(offsets)}
209+
bx, by = TestTileConnection._offset(button_id)
210+
ret = [default] * 8
211+
for local_index, (lx, ly) in enumerate(offsets):
212+
gx, gy = bx + lx, by + ly
213+
if gx == 0 and gy == 0:
214+
ret[local_index] = neighbors[button_id]
215+
elif -1 <= gx <= 1 and -1 <= gy <= 1:
216+
ret[local_index] = neighbors[index_by_offset[(gx, gy)]]
217+
return ret
218+
219+
@pytest.mark.parametrize("neighbors", [
220+
[0, 1, 1, 0, 1, 1, 0, 1],
221+
[1] * 8,
222+
[0] * 8,
223+
[2] * 8,
224+
[0, 1, 2, 0, 1, 2, 0, 1],
225+
])
226+
def test_get_inverse_neighborhood(self, neighbors):
227+
center = TileConnection(neighbors)
228+
for button_id in range(8):
229+
for default in [0, 1, 2]:
230+
inv = center.getInverseNeighborhood(button_id, default=default)
231+
assert inv.getNeighbors() == self._expected_inverse(neighbors, button_id, default)
232+
233+
@pytest.mark.parametrize("neighbors", [
234+
[0, 1, 1, 0, 1, 1, 0, 1],
235+
[2] * 8,
236+
])
237+
def test_inverse_neighborhood_contains_center_relation(self, neighbors):
238+
# the slot facing the center mirrors the center's own relation to that button
239+
center = TileConnection(neighbors)
240+
for button_id in range(8):
241+
inv = center.getInverseNeighborhood(button_id, default=2)
242+
assert inv.getNeighbors()[7 - button_id] == neighbors[button_id]
243+
244+
def test_get_inverse_neighborhood_invalid(self):
245+
center = TileConnection([0] * 8)
246+
with pytest.raises(ValueError):
247+
center.getInverseNeighborhood(8)
248+
with pytest.raises(ValueError):
249+
center.getInverseNeighborhood(-1)
250+
with pytest.raises(ValueError):
251+
center.getInverseNeighborhood(0, default=3)
252+
253+
@pytest.mark.parametrize("neighbor_id, state", [
254+
[8, 0],
255+
[-1, 0],
256+
[0, 3],
257+
[0, -1],
258+
])
259+
def test_set_neighbor_invalid(self, neighbor_id, state):
260+
t = TileConnection([0] * 8)
261+
with pytest.raises(ValueError):
262+
t.setNeighbor(neighbor_id, state)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import os
2+
3+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
4+
5+
import pytest # noqa: E402
6+
from PyQt6.QtGui import QColor, QPainter, QPixmap # noqa: E402
7+
8+
from src.buttons.button_tile_connection import TileConnectionButton # noqa: E402
9+
from src.backend.tile_data import TileData # noqa: E402
10+
from src.backend.tile_connection import TileConnection # noqa: E402
11+
from src.backend.tile_modificators import TileMods # noqa: E402
12+
from src.backend.tile_status import TileStatus # noqa: E402
13+
from src.widgets.widget_base_tile import BaseTile # noqa: E402
14+
15+
16+
def _make_tile(tile_id=1, status=None):
17+
tile_data = TileData(TileConnection([0] * 8), status or TileStatus(), TileMods(False, False, False))
18+
return BaseTile(tile_id, tile_data)
19+
20+
21+
def _make_arrow_pixmap():
22+
pm = QPixmap(2, 2)
23+
pm.fill(QColor(K_EMPTY))
24+
p = QPainter(pm)
25+
p.fillRect(0, 0, 1, 1, QColor("red"))
26+
p.fillRect(1, 0, 1, 1, QColor("green"))
27+
p.fillRect(0, 1, 1, 1, QColor("blue"))
28+
p.fillRect(1, 1, 1, 1, QColor("yellow"))
29+
p.end()
30+
return pm
31+
32+
33+
K_EMPTY = "#00000000"
34+
35+
36+
@pytest.mark.requires_q_app
37+
def test_set_tile_stores_status_copy(q_app):
38+
btn = TileConnectionButton(0)
39+
tile = _make_tile(7)
40+
base_status = TileStatus()
41+
rotated_status = TileStatus()
42+
rotated_status.rot = True
43+
44+
btn.setTile(tile, False, base_status)
45+
assert btn._tile_status == base_status
46+
47+
btn.setTile(tile, False, rotated_status)
48+
assert btn._tile_status == rotated_status
49+
assert btn._tile_status.rot
50+
51+
52+
@pytest.mark.requires_q_app
53+
def test_set_tile_dedups_only_when_status_is_equal(q_app):
54+
btn = TileConnectionButton(0)
55+
tile = _make_tile(7)
56+
base_status = TileStatus()
57+
rotated_status = TileStatus()
58+
rotated_status.rot = True
59+
60+
btn.setTile(tile, False, base_status)
61+
stored_before = btn._tile_status
62+
63+
# same tile + same status -> no re-render required
64+
btn.setTile(tile, False, base_status)
65+
assert btn._tile_status is stored_before
66+
67+
# same tile but different orientation -> must be re-rendered
68+
btn.setTile(tile, False, rotated_status)
69+
assert btn._tile_status is not stored_before
70+
assert btn._tile_status.rot
71+
72+
73+
@pytest.mark.requires_q_app
74+
def test_rotated_suggestion_is_drawn_rotated(q_app):
75+
btn = TileConnectionButton(0)
76+
tile = _make_tile(3)
77+
tile.setPixmap(_make_arrow_pixmap())
78+
status = TileStatus()
79+
status.rot = True
80+
81+
btn.setState(1) # FULL: the tile is rendered
82+
btn.setTile(tile, False, status)
83+
84+
img = btn.grab().toImage()
85+
assert img.pixelColor(0, 0).name() == "#0000ff" # BL -> TL
86+
assert img.pixelColor(127, 0).name() == "#ff0000" # TL -> TR
87+
assert img.pixelColor(127, 127).name() == "#008000" # TR -> BR
88+
assert img.pixelColor(0, 127).name() == "#ffff00" # BR -> BL
89+
90+
91+
@pytest.mark.requires_q_app
92+
def test_y_flipped_suggestion_is_drawn_mirrored(q_app):
93+
btn = TileConnectionButton(0)
94+
tile = _make_tile(3)
95+
tile.setPixmap(_make_arrow_pixmap())
96+
status = TileStatus()
97+
status.y_flip = True
98+
99+
btn.setState(1)
100+
btn.setTile(tile, False, status)
101+
102+
img = btn.grab().toImage()
103+
assert img.pixelColor(0, 0).name() == "#0000ff" # BL -> TL
104+
assert img.pixelColor(0, 127).name() == "#ff0000" # TL -> BL
105+
assert img.pixelColor(127, 0).name() == "#ffff00" # BR -> TR

0 commit comments

Comments
 (0)