Add action: Arrange Optimal

This commit is contained in:
Rebecca Breu 2021-04-19 19:49:56 +02:00
parent e8e7a0fc1e
commit 24599673ef
8 changed files with 145 additions and 0 deletions

View file

@ -98,6 +98,14 @@ actions = [
'group': 'active_when_selection',
'enabled': False,
},
{
'id': 'arrange_optimal',
'text': '&Optimal',
'shortcuts': ['Shift+O'],
'callback': 'on_action_arrange_optimal',
'group': 'active_when_selection',
'enabled': False,
},
{
'id': 'flip_horizontally',
'text': 'Flip &Horizontally',

View file

@ -73,6 +73,12 @@ menu_structure = [
'normalize_size',
],
},
{
'menu': '&Arrange',
'items': [
'arrange_optimal',
],
},
{
'menu': '&Help',
'items': [

View file

@ -241,3 +241,25 @@ class ResetTransforms(QtGui.QUndoCommand):
item.setRotation(old['rotation'], anchor=item.center)
if old['flip'] == -1:
item.do_flip(anchor=item.center)
class ArrangeItems(QtGui.QUndoCommand):
def __init__(self, scene, items, positions):
super().__init__('Arrange items')
self.scene = scene
self.items = items
self.positions = positions
def redo(self):
self.old_positions = []
for item, pos in zip(self.items, self.positions):
self.old_positions.append(item.pos())
orig_topleft = item.corners_scene_coords[0]
rect_topleft = self.scene.itemsBoundingRect(
items=[item]).topLeft()
item.setPos(pos + orig_topleft - rect_topleft)
def undo(self):
for item, pos in zip(self.items, self.old_positions):
item.setPos(pos)

View file

@ -20,6 +20,8 @@ import math
from PyQt6 import QtCore, QtWidgets
from PyQt6.QtCore import Qt
import rpack
from beeref import commands
from beeref.selection import MultiSelectItem, RubberbandItem
@ -99,6 +101,44 @@ class BeeGraphicsScene(QtWidgets.QGraphicsScene):
commands.NormalizeItems(
self.selectedItems(user_only=True), scale_factors))
def arrange_optimal(self):
items = self.selectedItems(user_only=True)
sizes = []
for item in items:
rect = self.itemsBoundingRect(items=[item])
sizes.append((round(rect.width()), round(rect.height())))
if not sizes:
return
center = self.get_selection_center()
# The minimal area the items need if they could be packed optimally;
# we use this as a starting shape for the packing algorithm
min_area = sum(map(lambda s: s[0] * s[1], sizes))
width = math.ceil(math.sqrt(min_area))
positions = None
while not positions:
try:
positions = rpack.pack(
sizes, max_width=width, max_height=width)
except rpack.PackingImpossibleError:
width = math.ceil(width * 1.2)
if rpack.overlapping(sizes, positions):
# Bug in rpack:
# https://github.com/Penlect/rectangle-packer/issues/4#issuecomment-822411097
positions = [(p[1], p[0]) for p in positions]
# We want the items to center around the selection's center,
# not (0, 0)
bounds = rpack.bbox_size(sizes, positions)
diff = center - QtCore.QPointF(bounds[0]/2, bounds[1]/2)
positions = [QtCore.QPointF(*pos) + diff for pos in positions]
self.undo_stack.push(commands.ArrangeItems(self, items, positions))
def flip_items(self, vertical=False):
"""Flip selected items."""
self.undo_stack.push(

View file

@ -196,6 +196,9 @@ class BeeGraphicsView(QtWidgets.QGraphicsView, ActionsMixin):
def on_action_normalize_size(self):
self.scene.normalize_size()
def on_action_arrange_optimal(self):
self.scene.arrange_optimal()
def on_action_flip_horizontally(self):
self.scene.flip_items(vertical=False)

View file

@ -10,6 +10,7 @@ setup(
description='A simple reference image viewer',
install_requires=[
'pyQt6',
'rectangle-packer=>2.0.0',
],
packages=['beeref'],
entry_points={

View file

@ -395,3 +395,30 @@ class ResetTransformsTestCase(BeeTestCase):
assert item2.flip() == 1
assert item2.pos().x() == 0
assert item2.pos().y() == 0
class ArrangeItemsTestCase(BeeTestCase):
def test_redo_undo(self):
scene = BeeGraphicsScene(None)
item1 = BeePixmapItem(QtGui.QImage())
item1.do_flip()
scene.addItem(item1)
item2 = BeePixmapItem(QtGui.QImage())
item2.setRotation(90)
scene.addItem(item2)
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
command = commands.ArrangeItems(
scene,
[item1, item2],
[QtCore.QPointF(1, 2), QtCore.QPointF(203, 204)])
command.redo()
assert item1.pos() == QtCore.QPointF(101, 2)
assert item2.pos() == QtCore.QPointF(283, 204)
command.undo()
assert item1.pos() == QtCore.QPointF(0, 0)
assert item2.pos() == QtCore.QPointF(0, 0)

View file

@ -144,6 +144,44 @@ class BeeGraphicsSceneTestCase(BeeTestCase):
def test_normalize_size_when_no_items(self):
self.scene.normalize_size()
def test_arrange_optimal(self):
for i in range(4):
item = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item)
item.setSelected(True)
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
self.scene.arrange_optimal()
expected_positions = {(-50, -40), (50, -40), (-50, 40), (50, 40)}
actual_positions = {
(i.pos().x(), i.pos().y())
for i in self.scene.selectedItems(user_only=True)}
assert expected_positions == actual_positions
def test_arrange_optimal_when_rotated(self):
for i in range(4):
item = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item)
item.setRotation(90)
item.setSelected(True)
with patch('beeref.items.BeePixmapItem.width',
new_callable=PropertyMock, return_value=100):
with patch('beeref.items.BeePixmapItem.height',
new_callable=PropertyMock, return_value=80):
self.scene.arrange_optimal()
expected_positions = {(-40, -50), (40, -50), (-40, 50), (40, 50)}
actual_positions = {
(i.pos().x(), i.pos().y())
for i in self.scene.selectedItems(user_only=True)}
assert expected_positions == actual_positions
def test_arrange_optimal_when_no_items(self):
self.scene.arrange_optimal()
def test_flip_items(self):
item = BeePixmapItem(QtGui.QImage())
self.scene.addItem(item)