Add rotation

This commit is contained in:
Rebecca Breu 2021-04-05 19:07:31 +02:00
parent 8204d3141f
commit d58b46b08f
11 changed files with 198 additions and 44 deletions

View file

@ -16,11 +16,10 @@
# along with BeeRef. If not, see <https://www.gnu.org/licenses/>.
import logging
import os.path
import signal
import sys
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6 import QtCore, QtWidgets
from beeref.assets import BeeAssets
from beeref.config import CommandlineArgs

View file

@ -18,8 +18,7 @@
import logging
import os.path
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
from PyQt6 import QtGui
logger = logging.getLogger('BeeRef')
@ -27,7 +26,7 @@ logger = logging.getLogger('BeeRef')
class BeeAssets:
_instance = None
PATH = os.path.join( os.path.dirname(__file__), 'assets')
PATH = os.path.join(os.path.dirname(__file__), 'assets')
def __new__(cls, *args, **kwargs):
if not cls._instance:
@ -36,7 +35,6 @@ class BeeAssets:
return cls._instance
def on_new(self):
PATH = os.path.join(os.path.dirname(__file__), 'assets')
logger.debug(f'Assets path: {self.PATH}')
self.logo = QtGui.QIcon(os.path.join(self.PATH, 'logo.png'))

View file

@ -103,6 +103,35 @@ class ScaleItemsBy(QtGui.QUndoCommand):
item.setPos(data['orig_pos'])
class RotateItemsBy(QtGui.QUndoCommand):
"""Rotate items by a given deltan around the given anchor."""
def __init__(self, items, delta, anchor, ignore_first_redo=False):
super().__init__('Scale items')
self.ignore_first_redo = ignore_first_redo
self.items = items
self.delta = delta
self.anchor = anchor
def redo(self):
if self.ignore_first_redo:
self.ignore_first_redo = False
return
for item in self.items:
item.setRotation(item.rotation() + self.delta,
item.mapFromScene(self.anchor))
item.scene().on_selection_change()
def undo(self):
if self.ignore_first_redo:
self.ignore_first_redo = False
return
for item in self.items:
item.setRotation(item.rotation() - self.delta,
item.mapFromScene(self.anchor))
item.scene().on_selection_change()
class NormalizeItems(QtGui.QUndoCommand):
def __init__(self, items, scale_factors):

View file

@ -127,20 +127,21 @@ class SQLiteIO:
@handle_sqlite_errors
def read(self):
rows = self.fetchall(
'SELECT items.id, x, y, z, scale, filename, sqlar.data '
'SELECT items.id, x, y, z, scale, rotation, filename, sqlar.data '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
if self.progress:
self.progress.setMaximum(len(rows))
for i, row in enumerate(rows):
item = BeePixmapItem(QtGui.QImage(), filename=row[5])
item = BeePixmapItem(QtGui.QImage(), filename=row[6])
item.save_id = row[0]
item.pixmap_from_bytes(row[6])
item.pixmap_from_bytes(row[7])
item.setPos(row[1], row[2])
self.scene.addItem(item)
item.setZValue(row[3])
item.setScale(row[4])
item.setRotation(row[5])
if self.progress:
self.progress.setValue(i)
if self.progress.wasCanceled():
@ -187,10 +188,10 @@ class SQLiteIO:
def insert_item(self, item):
self.ex(
'INSERT INTO items (type, x, y, z, scale, filename) '
'VALUES (?, ?, ?, ?, ?, ?) ',
'INSERT INTO items (type, x, y, z, scale, rotation, filename) '
'VALUES (?, ?, ?, ?, ?, ?, ?) ',
('pixmap', item.pos().x(), item.pos().y(), item.zValue(),
item.scale(), item.filename))
item.scale(), item.rotation(), item.filename))
item.save_id = self.cursor.lastrowid
pixmap = item.pixmap_to_bytes()
@ -213,8 +214,8 @@ class SQLiteIO:
data never changes and is also time-consuming to save.
"""
self.ex(
'UPDATE items SET x=?, y=?, z=?, scale=?, filename=? '
'UPDATE items SET x=?, y=?, z=?, scale=?, rotation=?, filename=? '
'WHERE id=?',
(item.pos().x(), item.pos().y(), item.zValue(), item.scale(),
item.filename, item.save_id))
item.rotation(), item.filename, item.save_id))
self.connection.commit()

View file

@ -40,11 +40,11 @@ class BeePixmapItem(SelectableMixin, QtWidgets.QGraphicsPixmapItem):
def __str__(self):
return (f'Image "{self.filename}" {self.width} x {self.height}')
def set_pos_center(self, x, y):
def set_pos_center(self, pos):
"""Sets the position using the item's center as the origin point."""
self.setPos(x - self.width * self.scale() / 2,
y - self.height * self.scale() / 2)
center = self.mapToScene(QtCore.QPointF(self.width/2, self.height/2))
self.setPos(pos - center)
@property
def width(self):

View file

@ -16,6 +16,7 @@
"""Classes for items that draw and handle selection stuff."""
import logging
import math
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtCore import Qt
@ -50,6 +51,16 @@ class BaseItemMixin:
def bring_to_front(self):
self.setZValue(self.scene().max_z + 0.001)
def setRotation(self, rotation, anchor=None):
anchor = anchor or QtCore.QPointF(0, 0)
prev = self.mapToScene(anchor)
super().setRotation(rotation)
# Calculate where the anchor moved to so that we can shift the
# item back to keep the anchor fixed
diff = self.mapToScene(anchor) - prev
self.setPos(self.pos() - diff)
class SelectableMixin(BaseItemMixin):
"""Common code for selectable items: Selection outline, handles etc."""
@ -66,6 +77,7 @@ class SelectableMixin(BaseItemMixin):
| QtWidgets.QGraphicsItem.GraphicsItemFlags.ItemIsSelectable)
self.scale_active = False
self.rotate_active = False
self.viewport_scale = 1
self.conf_debug_shapes = commandline_args.draw_debug_shapes
@ -223,7 +235,7 @@ class SelectableMixin(BaseItemMixin):
if self.get_scale_bounds(corner).contains(event.pos()):
# Start scale action for this corner
self.scale_active = True
self.scale_start = event.scenePos()
self.event_start = event.scenePos()
self.scale_direction = self.get_corner_direction(corner)
for item in self.selection_action_items():
item.scale_anchor = self.get_scale_anchor(item, corner)
@ -231,12 +243,25 @@ class SelectableMixin(BaseItemMixin):
item.scale_orig_pos = item.pos()
event.accept()
return
# Check if we are in one of the corner's rotate areas
if self.get_rotate_bounds(corner).contains(event.pos()):
# Start rotate action
self.rotate_active = True
self.rotate_anchor = self.mapToScene(
QtCore.QPointF(self.width/2, self.height/2))
self.rotate_start_angle = self.get_rotate_angle(event)
for item in self.selection_action_items():
item.rotate_anchor = self.rotate_anchor
item.rotate_orig_degrees = item.rotation()
event.accept()
return
super().mousePressEvent(event)
def get_scale_factor(self, event):
"""Get the scale factor for the current mouse movement."""
imgsize = self.width + self.height
p = event.scenePos() - self.scale_start
p = event.scenePos() - self.event_start
direction = self.scale_direction
delta = QtCore.QPointF.dotProduct(direction, p) / imgsize
return (self.scale_orig_factor + delta) / self.scale_orig_factor
@ -265,6 +290,17 @@ class SelectableMixin(BaseItemMixin):
self.scale_orig_pos.y() - self.scale_anchor.y() * factor,
)
def get_rotate_angle(self, event):
"""Get the angle of the current mouse position towards the
scale center."""
diff = event.scenePos() - self.rotate_anchor
return -math.degrees(math.atan2(diff.x(), diff.y()))
def get_rotate_delta(self, event):
"""Get the rotate delta for the current mouse movement"""
return self.get_rotate_angle(event) - self.rotate_start_angle
def mouseMoveEvent(self, event):
if self.scale_active:
factor = self.get_scale_factor(event)
@ -272,6 +308,11 @@ class SelectableMixin(BaseItemMixin):
item.setScale(item.scale_orig_factor * factor)
item.translate_for_scale_anchor(factor)
event.accept()
elif self.rotate_active:
delta = self.get_rotate_delta(event)
for item in self.selection_action_items():
item.setRotation(item.rotate_orig_degrees + delta,
item.mapFromScene(self.rotate_anchor))
else:
super().mouseMoveEvent(event)
@ -284,6 +325,16 @@ class SelectableMixin(BaseItemMixin):
ignore_first_redo=True))
self.scale_active = False
event.accept()
elif self.rotate_active:
self.scene().on_selection_change()
self.scene().undo_stack.push(
commands.RotateItemsBy(
self.selection_action_items(),
self.get_rotate_delta(event),
self.rotate_anchor,
ignore_first_redo=True))
self.rotate_active = False
event.accept()
else:
super().mouseReleaseEvent(event)

View file

@ -323,7 +323,7 @@ class BeeGraphicsView(QtWidgets.QGraphicsView):
errors.append(filename)
continue
item = BeePixmapItem(img, filename)
item.set_pos_center(pos.x(), pos.y())
item.set_pos_center(pos)
items.append(item)
pos.setX(pos.x() + 50)
pos.setY(pos.y() + 50)

View file

@ -92,12 +92,13 @@ class SQLiteIOWriteTestCase(BeeTestCase):
item.setScale(1.3)
item.setPos(44, 55)
item.setZValue(0.22)
item.setRotation(33)
item.pixmap_to_bytes = MagicMock(return_value=b'abc')
self.io.write()
assert item.save_id == 1
result = self.io.fetchone(
'SELECT x, y, z, scale, filename, type, '
'SELECT x, y, z, scale, rotation, filename, type, '
'sqlar.data, sqlar.name '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
@ -105,10 +106,11 @@ class SQLiteIOWriteTestCase(BeeTestCase):
assert result[1] == 55.0
assert result[2] == 0.22
assert result[3] == 1.3
assert result[4] == 'bee.jpg'
assert result[5] == 'pixmap'
assert result[6] == b'abc'
assert result[7] == '0001-bee.png'
assert result[4] == 33
assert result[5] == 'bee.jpg'
assert result[6] == 'pixmap'
assert result[7] == b'abc'
assert result[8] == '0001-bee.png'
def test_inserts_new_item_without_filename(self):
item = BeePixmapItem(QtGui.QImage())
@ -128,12 +130,14 @@ class SQLiteIOWriteTestCase(BeeTestCase):
item.setScale(1.3)
item.setPos(44, 55)
item.setZValue(0.22)
item.setRotation(33)
item.save_id = 1
item.pixmap_to_bytes = MagicMock(return_value=b'abc')
self.io.write()
item.setScale(0.7)
item.setPos(20, 30)
item.setZValue(0.33)
item.setRotation(100)
item.filename = 'new.png'
item.pixmap_to_bytes.return_value = b'updated'
self.io.create_new = False
@ -141,15 +145,16 @@ class SQLiteIOWriteTestCase(BeeTestCase):
assert self.io.fetchone('SELECT COUNT(*) from items') == (1,)
result = self.io.fetchone(
'SELECT x, y, z, scale, filename, sqlar.data '
'SELECT x, y, z, scale, rotation, filename, sqlar.data '
'FROM items '
'INNER JOIN sqlar on sqlar.item_id = items.id')
assert result[0] == 20
assert result[1] == 30
assert result[2] == 0.33
assert result[3] == 0.7
assert result[4] == 'new.png'
assert result[5] == b'abc'
assert result[4] == 100
assert result[5] == 'new.png'
assert result[6] == b'abc'
def test_removes_nonexisting_item(self):
item = BeePixmapItem(QtGui.QImage(), filename='bee.png')
@ -200,9 +205,10 @@ class SQLiteIOReadTestCase(BeeTestCase):
fname = os.path.join(dirname, 'test.bee')
io = SQLiteIO(fname, self.scene, create_new=True)
io.create_schema_on_new()
io.ex('INSERT INTO items (type, x, y, z, scale, filename) '
'VALUES (?, ?, ?, ?, ?, ?) ',
('pixmap', 22.2, 33.3, 0.22, 3.4, 'bee.png'))
io.ex('INSERT INTO items '
'(type, x, y, z, scale, rotation, filename) '
'VALUES (?, ?, ?, ?, ?, ?, ?) ',
('pixmap', 22.2, 33.3, 0.22, 3.4, 45, 'bee.png'))
io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)',
(1, self.imgdata3x3))
io.connection.commit()
@ -217,6 +223,7 @@ class SQLiteIOReadTestCase(BeeTestCase):
assert item.pos().y() == 33.3
assert item.zValue() == 0.22
assert item.scale() == 3.4
assert item.rotation() == 45
assert item.filename == 'bee.png'
assert item.width == 3
assert item.height == 3

View file

@ -1,5 +1,3 @@
from unittest.mock import patch
from beeref.config import CommandlineArgs

View file

@ -1,6 +1,6 @@
from unittest.mock import patch, MagicMock, PropertyMock
from PyQt6 import QtGui
from PyQt6 import QtCore, QtGui
from beeref.items import BeePixmapItem
from beeref.scene import BeeGraphicsScene
@ -29,7 +29,7 @@ class BeePixmapItemTestCase(BeeTestCase):
new_callable=PropertyMock, return_value=200):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=100):
item.set_pos_center(0, 0)
item.set_pos_center(QtCore.QPointF(0, 0))
assert item.pos().x() == -100
assert item.pos().y() == -50

View file

@ -60,6 +60,27 @@ class BaseItemMixinTestCase(BeeTestCase):
assert item2.zValue() > item1.zValue()
assert item2.zValue() == self.scene.max_z
def test_set_rotation_anchor_topleft(self):
item = BeePixmapItem(QtGui.QImage())
item.setRotation(45)
assert item.rotation() == 45
assert item.pos().x() == 0
assert item.pos().y() == 0
def test_set_rotation_anchor_bottomright_cw(self):
item = BeePixmapItem(QtGui.QImage())
item.setRotation(90, QtCore.QPointF(100, 100))
assert item.rotation() == 90
assert item.pos().x() == 200
assert item.pos().y() == 0
def test_set_rotation_anchor_bottomright_ccw(self):
item = BeePixmapItem(QtGui.QImage())
item.setRotation(-90, QtCore.QPointF(100, 100))
assert item.rotation() == -90
assert item.pos().x() == 0
assert item.pos().y() == 200
class SelectableMixinBaseTestCase(BeeTestCase):
@ -307,7 +328,7 @@ class SelectableMixinTestCase(SelectableMixinBaseTestCase):
class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase):
def test_get_scale_factor_bottomright(self):
self.item.scale_start = QtCore.QPointF(10, 10)
self.item.event_start = QtCore.QPointF(10, 10)
self.item.scale_direction = QtCore.QPointF(1, 1)
self.item.scale_orig_factor = 1
event = MagicMock()
@ -315,7 +336,7 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase):
assert self.item.get_scale_factor(event) == 1.5
def test_get_scale_factor_topleft(self):
self.item.scale_start = QtCore.QPointF(10, 10)
self.item.event_start = QtCore.QPointF(10, 10)
self.item.scale_direction = QtCore.QPointF(-1, -1)
self.item.scale_orig_factor = 0.5
event = MagicMock()
@ -373,6 +394,19 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase):
assert self.item.pos().x() == -150
assert self.item.pos().y() == -90
def test_get_rotate_angle(self):
self.item.rotate_anchor = QtCore.QPointF(10, 20)
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25))
assert self.item.get_rotate_angle(event) == -45
def test_get_rotate_delta(self):
self.item.rotate_anchor = QtCore.QPointF(10, 20)
self.item.rotate_start_angle = -3
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25))
assert self.item.get_rotate_delta(event) == -42
class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
@ -380,6 +414,8 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
super().setUp()
self.event = MagicMock()
self.item.setCursor = MagicMock()
self.item.SELECT_RESIZE_SIZE = 10
self.item.SELECT_ROTATE_SIZE = 10
def test_hover_move_event_no_selection(self):
self.event.pos = MagicMock(return_value=QtCore.QPointF(0, 0))
@ -409,8 +445,6 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
def test_hover_move_event_rotate(self):
self.item.setSelected(True)
self.item.SELECT_RESIZE_SIZE = 10
self.item.SELECT_ROTATE_SIZE = 10
self.event.pos = MagicMock(return_value=QtCore.QPointF(110, 90))
self.item.hoverMoveEvent(self.event)
self.item.setCursor.assert_called_once_with(BeeAssets().cursor_rotate)
@ -441,7 +475,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
return_value=Qt.MouseButtons.LeftButton)
self.item.mousePressEvent(self.event)
assert self.item.scale_active is True
assert self.item.scale_start == QtCore.QPointF(66, 99)
assert self.item.event_start == QtCore.QPointF(66, 99)
assert self.item.scale_direction == QtCore.QPointF(-1, -1)
assert self.item.scale_orig_factor == 1
assert self.item.scale_orig_pos == QtCore.QPointF(0, 0)
@ -455,10 +489,21 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
self.item.mousePressEvent(self.event)
assert self.item.scale_active is True
assert self.item.scale_direction == QtCore.QPointF(1, 1)
assert self.item.scale_start == QtCore.QPointF(66, 99)
assert self.item.event_start == QtCore.QPointF(66, 99)
assert self.item.scale_orig_factor == 1
assert self.item.scale_orig_pos == QtCore.QPointF(0, 0)
def test_mouse_press_event_rotate(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(111, 91))
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99))
self.event.button = MagicMock(
return_value=Qt.MouseButtons.LeftButton)
self.item.mousePressEvent(self.event)
assert self.item.rotate_active is True
assert self.item.rotate_anchor == QtCore.QPointF(50, 40)
assert self.item.rotate_orig_degrees == 0
def test_mouse_press_event_not_selected(self):
self.item.setSelected(False)
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem.mousePressEvent') as m:
@ -481,18 +526,27 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
self.item.mouseMoveEvent(self.event)
m.assert_called_once_with(self.event)
def test_move_event_when_scale_action(self):
def test_mouse_move_event_when_scale_action(self):
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90))
self.item.scale_active = True
self.item.scale_direction = QtCore.QPointF(1, 1)
self.item.scale_anchor = QtCore.QPointF(100, 80)
self.item.scale_start = QtCore.QPointF(10, 10)
self.item.event_start = QtCore.QPointF(10, 10)
self.item.scale_orig_factor = 1
self.item.scale_orig_pos = QtCore.QPointF(0, 0)
self.item.mouseMoveEvent(self.event)
assert self.item.scale() == 1.5
def test_mouse_move_event_when_rotate_action(self):
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25))
self.item.rotate_active = True
self.item.rotate_orig_degrees = 0
self.item.rotate_start_angle = -3
self.item.rotate_anchor = QtCore.QPointF(10, 20)
self.item.mouseMoveEvent(self.event)
assert self.item.rotation() == -42
def test_mouse_release_event_when_no_action(self):
with patch('PyQt6.QtWidgets.QGraphicsPixmapItem'
'.mouseReleaseEvent') as m:
@ -504,7 +558,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
self.item.scale_active = True
self.item.scale_direction = QtCore.QPointF(1, 1)
self.item.scale_anchor = QtCore.QPointF(100, 80)
self.item.scale_start = QtCore.QPointF(10, 10)
self.item.event_start = QtCore.QPointF(10, 10)
self.item.scale_orig_factor = 1
self.item.scale_orig_pos = QtCore.QPointF(0, 0)
self.scene.undo_stack = MagicMock(push=MagicMock())
@ -522,6 +576,23 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
assert cmd.ignore_first_redo is True
assert self.item.scale_active is False
def test_mouse_release_event_when_rotate_action(self):
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25))
self.item.rotate_active = True
self.item.rotate_orig_degrees = 0
self.item.rotate_start_angle = -3
self.item.rotate_anchor = QtCore.QPointF(10, 20)
self.scene.undo_stack = MagicMock(push=MagicMock())
self.item.mouseReleaseEvent(self.event)
args = self.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert cmd.items == [self.item]
assert cmd.delta == -42
assert cmd.anchor == QtCore.QPointF(10, 20)
assert cmd.ignore_first_redo is True
assert self.item.rotate_active is False
class MultiSelectItemTestCase(BeeTestCase):