Fix scaling for rotated items

This commit is contained in:
Rebecca Breu 2021-04-06 10:13:25 +02:00
parent 2d3e6613bf
commit a34231cd7d
4 changed files with 134 additions and 117 deletions

View file

@ -74,37 +74,31 @@ class MoveItemsBy(QtGui.QUndoCommand):
class ScaleItemsBy(QtGui.QUndoCommand):
"""Scale items by a given factor around the given anchor point."""
"""Scale items by a given factor around the given anchor."""
def __init__(self, items, factor, ignore_first_redo=False):
def __init__(self, items, factor, anchor, ignore_first_redo=False):
super().__init__('Scale items')
self.ignore_first_redo = ignore_first_redo
self.items = items
self.factor = factor
self.item_data = [
{'anchor': item.scale_anchor,
'orig_factor': item.scale_orig_factor,
'orig_pos': item.scale_orig_pos} for item in items]
self.anchor = anchor
def redo(self):
if self.ignore_first_redo:
self.ignore_first_redo = False
return
for item, data in zip(self.items, self.item_data):
item.scale_orig_factors = data['orig_factor']
item.scale_orig_pos = data['orig_pos']
item.scale_anchor = data['anchor']
item.setScale(item.scale() * self.factor)
item.translate_for_scale_anchor(self.factor)
for item in self.items:
item.setScale(item.scale() * self.factor,
item.mapFromScene(self.anchor))
def undo(self):
for item, data in zip(self.items, self.item_data):
item.setScale(item.scale() / self.factor)
item.setPos(data['orig_pos'])
for item in self.items:
item.setScale(item.scale() / self.factor,
item.mapFromScene(self.anchor))
class RotateItemsBy(QtGui.QUndoCommand):
"""Rotate items by a given deltan around the given anchor."""
"""Rotate items by a given delta around the given anchor."""
def __init__(self, items, delta, anchor, ignore_first_redo=False):
super().__init__('Scale items')

View file

@ -33,15 +33,35 @@ logger = logging.getLogger('BeeRef')
SELECT_COLOR = QtGui.QColor(116, 234, 231, 255)
def with_anchor(func):
"""Decorator that handles anchors for translate operations.
The anchor is given in item coordinates.
"""
def wrapper(self, value, anchor=None):
# We caculate where the anchor is before and after the transformation
# and then move the item accordingly to keep the anchor fixed
anchor = anchor if anchor else QtCore.QPointF(0, 0)
prev = self.mapToScene(anchor)
func(self, value, anchor)
diff = self.mapToScene(anchor) - prev
self.setPos(self.pos() - diff)
return wrapper
class BaseItemMixin:
def setScale(self, factor):
if factor <= 0:
@with_anchor
def setScale(self, value, anchor=None):
if value <= 0:
return
logger.debug(f'Setting scale for {self} to {factor}')
logger.debug(f'Setting scale for {self} to {value}')
self.prepareGeometryChange()
super().setScale(factor)
super().setScale(value)
def setZValue(self, value):
logger.debug(f'Setting z-value for {self} to {value}')
@ -51,15 +71,15 @@ 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)
@with_anchor
def setRotation(self, value, anchor):
logger.debug(f'Setting rotation for {self} to {value}')
super().setRotation(value)
# 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)
@property
def center_scene_coords(self):
"""The item's center in scene coordinates."""
return self.mapToScene(QtCore.QPointF(self.width/2, self.height/2))
class SelectableMixin(BaseItemMixin):
@ -237,22 +257,22 @@ class SelectableMixin(BaseItemMixin):
# Start scale action for this corner
self.scale_active = True
self.event_start = event.scenePos()
self.event_direction = self.get_mouse_event_direction(
event)
self.event_anchor = self.mapToScene(
self.get_scale_anchor(corner))
self.scale_direction = self.get_corner_direction(corner)
for item in self.selection_action_items():
item.scale_anchor = self.get_scale_anchor(item, corner)
item.scale_orig_factor = item.scale()
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.event_anchor = self.center_scene_coords
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
@ -261,16 +281,16 @@ class SelectableMixin(BaseItemMixin):
def get_scale_factor(self, event):
"""Get the scale factor for the current mouse movement."""
imgsize = self.width + self.height
imgsize = math.sqrt(self.width**2 + self.height**2)
p = event.scenePos() - self.event_start
direction = self.scale_direction
direction = self.event_direction
delta = QtCore.QPointF.dotProduct(direction, p) / imgsize
return (self.scale_orig_factor + delta) / self.scale_orig_factor
def get_scale_anchor(self, item, corner):
def get_scale_anchor(self, corner):
"""Get the anchor around which the scale for this corner operates."""
return item.mapFromScene(
self.mapToScene(self.width - corner.x(), self.height - corner.y()))
return QtCore.QPointF(self.width - corner.x(),
self.height - corner.y())
def get_corner_direction(self, corner):
"""Get the direction facing away from the center, e.g. the direction
@ -278,24 +298,18 @@ class SelectableMixin(BaseItemMixin):
return QtCore.QPointF(1 if corner.x() > 0 else -1,
1 if corner.y() > 0 else -1)
def translate_for_scale_anchor(self, scale_factor):
"""Adjust the item's position so that a scale with the given scale
factor appears to operate around the scale anchor. ``setScale``
needs to be called separately with ``scale_factor`` multiplied by
the item's current scale factor.
def get_mouse_event_direction(self, event):
"""The direction of a mouse event in relation to the item's center.
"""
factor = self.scale_orig_factor * (scale_factor - 1)
self.setPos(
self.scale_orig_pos.x() - self.scale_anchor.x() * factor,
self.scale_orig_pos.y() - self.scale_anchor.y() * factor,
)
diff = event.scenePos() - self.center_scene_coords
length = math.sqrt(QtCore.QPointF.dotProduct(diff, diff))
return diff / length
def get_rotate_angle(self, event):
"""Get the angle of the current mouse position towards the
scale center."""
diff = event.scenePos() - self.rotate_anchor
diff = event.scenePos() - self.event_anchor
return -math.degrees(math.atan2(diff.x(), diff.y()))
def get_rotate_delta(self, event):
@ -306,14 +320,14 @@ class SelectableMixin(BaseItemMixin):
if self.scale_active:
factor = self.get_scale_factor(event)
for item in self.selection_action_items():
item.setScale(item.scale_orig_factor * factor)
item.translate_for_scale_anchor(factor)
item.setScale(item.scale_orig_factor * factor,
item.mapFromScene(self.event_anchor))
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))
item.mapFromScene(self.event_anchor))
else:
super().mouseMoveEvent(event)
@ -323,6 +337,7 @@ class SelectableMixin(BaseItemMixin):
commands.ScaleItemsBy(
self.selection_action_items(),
self.get_scale_factor(event),
self.event_anchor,
ignore_first_redo=True))
self.scale_active = False
event.accept()
@ -332,7 +347,7 @@ class SelectableMixin(BaseItemMixin):
commands.RotateItemsBy(
self.selection_action_items(),
self.get_rotate_delta(event),
self.rotate_anchor,
self.event_anchor,
ignore_first_redo=True))
self.rotate_active = False
event.accept()

View file

@ -95,16 +95,11 @@ class ScaleItemsByTestCase(BeeTestCase):
def test_redo_undo(self):
item1 = BeePixmapItem(QtGui.QImage())
item1.setScale(1)
item1.scale_orig_factor = 1
item1.scale_anchor = QtCore.QPointF(100, 100)
item1.scale_orig_pos = QtCore.QPointF(0, 0)
item2 = BeePixmapItem(QtGui.QImage())
item2.setScale(3)
item2.scale_orig_factor = 3
item2.scale_anchor = QtCore.QPointF(0, 0)
item2.setPos(100, 100)
item2.scale_orig_pos = QtCore.QPointF(100, 100)
command = commands.ScaleItemsBy([item1, item2], 2)
command = commands.ScaleItemsBy([item1, item2], 2,
QtCore.QPointF(100, 100))
command.redo()
assert item1.scale() == 2
assert item1.pos().x() == -100
@ -123,22 +118,17 @@ class ScaleItemsByTestCase(BeeTestCase):
def test_ignore_first_redo(self):
item1 = BeePixmapItem(QtGui.QImage())
item1.setScale(1)
item1.scale_orig_factor = 1
item1.scale_anchor = QtCore.QPointF(100, 100)
item1.scale_orig_pos = QtCore.QPointF(0, 0)
item2 = BeePixmapItem(QtGui.QImage())
item2.setScale(3)
item2.scale_orig_factor = 3
item2.scale_anchor = QtCore.QPointF(0, 0)
item2.setPos(100, 100)
item2.scale_orig_pos = QtCore.QPointF(100, 100)
command = commands.ScaleItemsBy([item1, item2], 2,
QtCore.QPointF(100, 100),
ignore_first_redo=True)
command.redo()
assert item1.scale() == 1
assert item2.scale() == 3
assert item1.pos().x() == 0
assert item1.pos().y() == 0
assert item2.scale() == 3
assert item2.pos().x() == 100
assert item2.pos().y() == 100
command.redo()

View file

@ -1,3 +1,5 @@
import math
from pytest import approx
from unittest.mock import patch, MagicMock, PropertyMock
from PyQt6 import QtCore, QtGui
@ -35,6 +37,12 @@ class BaseItemMixinTestCase(BeeTestCase):
item.setScale(-0.1)
assert item.scale() == 1
def test_set_scale_with_anchor(self):
item = BeePixmapItem(QtGui.QImage())
item.setScale(2, anchor=QtCore.QPointF(100, 100))
assert item.scale() == 2
assert item.pos() == QtCore.QPointF(-100, -100)
def test_set_zvalue_sets_new_max(self):
item = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item)
@ -60,7 +68,7 @@ class BaseItemMixinTestCase(BeeTestCase):
assert item2.zValue() > item1.zValue()
assert item2.zValue() == self.scene.max_z
def test_set_rotation_anchor_topleft(self):
def test_set_rotation_anchor(self):
item = BeePixmapItem(QtGui.QImage())
item.setRotation(45)
assert item.rotation() == 45
@ -81,6 +89,17 @@ class BaseItemMixinTestCase(BeeTestCase):
assert item.pos().x() == 0
assert item.pos().y() == 200
def test_center_scene_coords(self):
item = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item)
item.setPos(5, 5)
item.setScale(2)
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
assert item.center_scene_coords == QtCore.QPointF(105, 85)
class SelectableMixinBaseTestCase(BeeTestCase):
@ -329,47 +348,40 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase):
def test_get_scale_factor_bottomright(self):
self.item.event_start = QtCore.QPointF(10, 10)
self.item.scale_direction = QtCore.QPointF(1, 1)
self.item.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2)
self.item.scale_orig_factor = 1
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(20, 90))
assert self.item.get_scale_factor(event) == 1.5
assert self.item.get_scale_factor(event) == approx(1.5, 0.01)
def test_get_scale_factor_topleft(self):
self.item.event_start = QtCore.QPointF(10, 10)
self.item.scale_direction = QtCore.QPointF(-1, -1)
self.item.event_direction = QtCore.QPointF(-1, -1) / math.sqrt(2)
self.item.scale_orig_factor = 0.5
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(-10, -60))
assert self.item.get_scale_factor(event) == 2
assert self.item.get_scale_factor(event) == approx(2, 0.01)
def test_get_scale_anchor_topleft(self):
anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(0, 0))
anchor = self.item.get_scale_anchor(QtCore.QPointF(0, 0))
assert anchor.x() == 100
assert anchor.y() == 80
def test_get_scale_anchor_bottomright(self):
anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(100, 80))
anchor = self.item.get_scale_anchor(QtCore.QPointF(100, 80))
assert anchor.x() == 0
assert anchor.y() == 0
def test_get_scale_anchor_topright(self):
anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(100, 0))
anchor = self.item.get_scale_anchor(QtCore.QPointF(100, 0))
assert anchor.x() == 0
assert anchor.y() == 80
def test_get_scale_anchor_bottomleft(self):
anchor = self.item.get_scale_anchor(self.item, QtCore.QPointF(0, 80))
anchor = self.item.get_scale_anchor(QtCore.QPointF(0, 80))
assert anchor.x() == 100
assert anchor.y() == 0
def test_get_scale_anchor_topleft_when_anchor_other_item(self):
item2 = BeePixmapItem(QtGui.QImage())
item2.setPos(22, 33)
anchor = self.item.get_scale_anchor(item2, QtCore.QPointF(0, 0))
assert anchor.x() == 78
assert anchor.y() == 47
def test_get_corner_direction_topleft(self):
assert self.item.get_corner_direction(
QtCore.QPointF(0, 0)) == QtCore.QPointF(-1, -1)
@ -386,22 +398,33 @@ class SelectableMixinScalingTestCase(SelectableMixinBaseTestCase):
assert self.item.get_corner_direction(
QtCore.QPointF(0, 80)) == QtCore.QPointF(-1, 1)
def test_translate_for_scale_anchor(self):
self.item.scale_orig_pos = QtCore.QPoint(50, 70)
self.item.scale_anchor = QtCore.QPoint(100, 80)
self.item.scale_orig_factor = 1
self.item.translate_for_scale_anchor(3)
assert self.item.pos().x() == -150
assert self.item.pos().y() == -90
def test_get_mouse_event_direction_bottomright(self):
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(100, 90))
direction = self.item.get_mouse_event_direction(event)
assert direction == approx(QtCore.QPointF(1, 1) / math.sqrt(2))
def test_get_mouse_event_direction_topleft(self):
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(0, -10))
direction = self.item.get_mouse_event_direction(event)
assert direction == approx(QtCore.QPointF(-1, -1) / math.sqrt(2))
def test_get_mouse_event_direction_bottomright_when_rotated_180(self):
self.item.setRotation(180, QtCore.QPointF(50, 40))
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(100, 90))
direction = self.item.get_mouse_event_direction(event)
assert direction == approx(QtCore.QPointF(1, 1) / math.sqrt(2))
def test_get_rotate_angle(self):
self.item.rotate_anchor = QtCore.QPointF(10, 20)
self.item.event_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.event_anchor = QtCore.QPointF(10, 20)
self.item.rotate_start_angle = -3
event = MagicMock()
event.scenePos = MagicMock(return_value=QtCore.QPointF(15, 25))
@ -470,28 +493,28 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
def test_mouse_press_event_topleft_scale(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(2, 2))
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99))
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(-1, -1))
self.event.button = MagicMock(
return_value=Qt.MouseButtons.LeftButton)
self.item.mousePressEvent(self.event)
assert self.item.scale_active is True
assert self.item.event_start == QtCore.QPointF(66, 99)
assert self.item.scale_direction == QtCore.QPointF(-1, -1)
assert self.item.event_start == QtCore.QPointF(-1, -1)
assert self.item.event_direction.x() < 0
assert self.item.event_direction.y() < 0
assert self.item.scale_orig_factor == 1
assert self.item.scale_orig_pos == QtCore.QPointF(0, 0)
def test_mouse_press_event_bottomright_scale(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(99, 79))
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(66, 99))
self.event.scenePos = MagicMock(return_value=QtCore.QPointF(101, 81))
self.event.button = MagicMock(
return_value=Qt.MouseButtons.LeftButton)
self.item.mousePressEvent(self.event)
assert self.item.scale_active is True
assert self.item.scale_direction == QtCore.QPointF(1, 1)
assert self.item.event_start == QtCore.QPointF(66, 99)
assert self.item.event_start == QtCore.QPointF(101, 81)
assert self.item.event_direction.x() > 0
assert self.item.event_direction.y() > 0
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)
@ -501,7 +524,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
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.event_anchor == QtCore.QPointF(50, 40)
assert self.item.rotate_orig_degrees == 0
def test_mouse_press_event_not_selected(self):
@ -511,7 +534,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
m.assert_called_once_with(self.event)
assert self.item.scale_active is False
def test_mouse_press_not_in_handles(self):
def test_mouse_press_event_not_in_handles(self):
self.item.setSelected(True)
self.event.pos = MagicMock(return_value=QtCore.QPointF(50, 40))
self.event.button = MagicMock(
@ -529,21 +552,20 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
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.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2)
self.item.event_anchor = QtCore.QPointF(100, 80)
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
assert self.item.scale() == approx(1.5, 0.01)
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.event_anchor = QtCore.QPointF(10, 20)
self.item.mouseMoveEvent(self.event)
assert self.item.rotation() == -42
@ -556,11 +578,10 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
def test_mouse_release_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.event_direction = QtCore.QPointF(1, 1) / math.sqrt(2)
self.item.event_anchor = QtCore.QPointF(100, 80)
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())
self.item.mouseReleaseEvent(self.event)
@ -568,11 +589,8 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
args = self.scene.undo_stack.push.call_args_list[0][0]
cmd = args[0]
assert cmd.items == [self.item]
assert cmd.factor == 1.5
assert cmd.item_data == [{
'anchor': QtCore.QPointF(100, 80),
'orig_factor': 1,
'orig_pos': QtCore.QPointF(0, 0)}]
assert cmd.factor == approx(1.5, 0.01)
assert cmd.anchor == QtCore.QPointF(100, 80)
assert cmd.ignore_first_redo is True
assert self.item.scale_active is False
@ -581,7 +599,7 @@ class SelectableMixinMouseEventsTestCase(SelectableMixinBaseTestCase):
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.event_anchor = QtCore.QPointF(10, 20)
self.scene.undo_stack = MagicMock(push=MagicMock())
self.item.mouseReleaseEvent(self.event)