Fix rotation

This commit is contained in:
Duncan Tourolle 2025-10-29 20:30:57 +01:00
parent 3e3b604d2f
commit 4bfaa63aae
4 changed files with 228 additions and 5 deletions

View File

@ -348,6 +348,49 @@ class RotateElementCommand(Command):
)
class AdjustImageCropCommand(Command):
"""Command for adjusting image crop/pan within frame"""
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
self.element = element
self.old_crop_info = old_crop_info
self.new_crop_info = new_crop_info
def execute(self):
"""Apply new crop info"""
self.element.crop_info = self.new_crop_info
def undo(self):
"""Restore old crop info"""
self.element.crop_info = self.old_crop_info
def redo(self):
"""Apply new crop info again"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "adjust_image_crop",
"element": self.element.serialize(),
"old_crop_info": self.old_crop_info,
"new_crop_info": self.new_crop_info
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
"""Deserialize from dictionary"""
elem_data = data["element"]
element = ImageData()
element.deserialize(elem_data)
return AdjustImageCropCommand(
element,
tuple(data["old_crop_info"]),
tuple(data["new_crop_info"])
)
class AlignElementsCommand(Command):
"""Command for aligning multiple elements"""
@ -717,6 +760,8 @@ class CommandHistory:
return ResizeElementsCommand.deserialize(data, project)
elif cmd_type == "change_zorder":
return ChangeZOrderCommand.deserialize(data, project)
elif cmd_type == "adjust_image_crop":
return AdjustImageCropCommand.deserialize(data, project)
else:
print(f"Warning: Unknown command type: {cmd_type}")
return None

View File

@ -41,6 +41,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.rotation_start_angle = None # Starting rotation angle
self.rotation_snap_angle = 15 # Default snap angle in degrees
# Image pan state (for panning image within frame with Control key)
self.image_pan_mode = False # True when Control+dragging an ImageData element
self.image_pan_start_crop = None # Starting crop_info when pan begins
# Zoom and pan state
self.zoom_level = 1.0
self.pan_offset = [0, 0]
@ -209,6 +213,16 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
center_x = x + w / 2
center_y = y + h / 2
# Apply rotation if element is rotated
from OpenGL.GL import glPushMatrix, glPopMatrix, glTranslatef, glRotatef
if self.selected_element.rotation != 0:
glPushMatrix()
glTranslatef(center_x, center_y, 0)
glRotatef(self.selected_element.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now draw as if at origin
x, y = 0, 0
# Draw selection border
if self.rotation_mode:
glColor3f(1.0, 0.5, 0.0) # Orange for rotation mode
@ -291,6 +305,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
glVertex2f(hx + handle_size, hy + handle_size)
glVertex2f(hx, hy + handle_size)
glEnd()
# Restore matrix if we applied rotation
if self.selected_element.rotation != 0:
glPopMatrix()
def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay"""
@ -448,12 +466,25 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
element = self._get_element_at(x, y)
if element:
if ctrl_pressed:
# Check if Control is pressed and element is ImageData - enter image pan mode
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.image_pan_mode = True
self.image_pan_start_crop = element.crop_info
self._begin_image_pan(element)
self.is_dragging = True
self.setCursor(Qt.CursorShape.SizeAllCursor) # Show move cursor
print(f"Entered image pan mode for {element}")
elif ctrl_pressed:
# Multi-select mode (for non-ImageData or when not dragging)
if element in self.selected_elements:
self.selected_elements.remove(element)
else:
self.selected_elements.add(element)
else:
# Normal drag mode
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
@ -493,7 +524,57 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
return
if self.selected_element:
if self.rotation_mode:
if self.image_pan_mode:
# Image pan mode - adjust crop_info to pan image within frame
if not isinstance(self.selected_element, ImageData):
return
# Calculate mouse movement in screen pixels
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
# Get element size in page-local coordinates
elem_w, elem_h = self.selected_element.size
# Convert screen movement to normalized crop coordinates
# Negative because moving mouse right should pan image left (show more of right side)
# Scale by zoom level and element size
crop_dx = -screen_dx / (elem_w * self.zoom_level)
crop_dy = -screen_dy / (elem_h * self.zoom_level)
# Get starting crop info
start_crop = self.image_pan_start_crop
if not start_crop:
start_crop = (0, 0, 1, 1)
# Calculate new crop_info
crop_width = start_crop[2] - start_crop[0]
crop_height = start_crop[3] - start_crop[1]
new_x_min = start_crop[0] + crop_dx
new_y_min = start_crop[1] + crop_dy
new_x_max = new_x_min + crop_width
new_y_max = new_y_min + crop_height
# Clamp to valid range (0-1) to prevent panning beyond image boundaries
if new_x_min < 0:
new_x_min = 0
new_x_max = crop_width
if new_x_max > 1:
new_x_max = 1
new_x_min = 1 - crop_width
if new_y_min < 0:
new_y_min = 0
new_y_max = crop_height
if new_y_max > 1:
new_y_max = 1
new_y_min = 1 - crop_height
# Update element's crop_info
self.selected_element.crop_info = (new_x_min, new_y_min, new_x_max, new_y_max)
elif self.rotation_mode:
# Calculate rotation angle from mouse position relative to element center
import math
@ -528,6 +609,7 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.selected_element.rotation = angle
# Show current angle in status bar
main_window = self.window()
if hasattr(main_window, 'show_status'):
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
@ -591,11 +673,14 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.drag_start_element_pos = None
self.resize_handle = None
self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
self.snap_state = {
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.setCursor(Qt.CursorShape.ArrowCursor) # Reset cursor
elif event.button() == Qt.MouseButton.MiddleButton:
self.is_panning = False

View File

@ -64,6 +64,24 @@ class UndoableInteractionMixin:
self._interaction_start_size = None
self._interaction_start_rotation = element.rotation
def _begin_image_pan(self, element):
"""
Begin tracking an image pan operation.
Args:
element: The ImageData element being panned
"""
from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData):
return
self._interaction_element = element
self._interaction_type = 'image_pan'
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
self._interaction_start_crop_info = element.crop_info
def _end_interaction(self):
"""
End the current interaction and create appropriate undo/redo command.
@ -142,6 +160,29 @@ class UndoableInteractionMixin:
)
print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°")
elif self._interaction_type == 'image_pan':
# Check if crop_info actually changed
from pyPhotoAlbum.models import ImageData
if isinstance(element, ImageData):
new_crop_info = element.crop_info
if hasattr(self, '_interaction_start_crop_info') and self._interaction_start_crop_info is not None:
# Check if crop changed significantly (more than 0.001 in any coordinate)
if new_crop_info != self._interaction_start_crop_info:
old_crop = self._interaction_start_crop_info
significant_change = any(
abs(new_crop_info[i] - old_crop[i]) > 0.001
for i in range(4)
)
if significant_change:
from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(
element,
self._interaction_start_crop_info,
new_crop_info
)
print(f"Image pan command created: {self._interaction_start_crop_info}{new_crop_info}")
# Execute the command through history if one was created
if command:
main_window.project.history.execute(command)
@ -156,6 +197,8 @@ class UndoableInteractionMixin:
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
if hasattr(self, '_interaction_start_crop_info'):
self._interaction_start_crop_info = None
def _cancel_interaction(self):
"""Cancel the current interaction without creating a command"""

View File

@ -44,13 +44,25 @@ class ImageData(BaseLayoutElement):
glEnable, glDisable, GL_TEXTURE_2D, glBindTexture, glTexCoord2f,
glGenTextures, glTexImage2D, GL_RGBA, GL_UNSIGNED_BYTE,
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR,
glDeleteTextures)
glDeleteTextures, glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
from PIL import Image
import os
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Try to load and render the actual image
texture_id = None
@ -191,6 +203,10 @@ class ImageData(BaseLayoutElement):
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
def serialize(self) -> Dict[str, Any]:
"""Serialize image data to dictionary"""
@ -223,11 +239,24 @@ class PlaceholderData(BaseLayoutElement):
def render(self):
"""Render the placeholder using OpenGL"""
from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple, glEnable, glDisable, GL_LINE_STIPPLE
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple,
glEnable, glDisable, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Draw a light gray rectangle as placeholder background
glColor3f(0.9, 0.9, 0.9) # Light gray
glBegin(GL_QUADS)
@ -248,6 +277,10 @@ class PlaceholderData(BaseLayoutElement):
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
def serialize(self) -> Dict[str, Any]:
"""Serialize placeholder data to dictionary"""
@ -282,11 +315,24 @@ class TextBoxData(BaseLayoutElement):
def render(self):
"""Render the text box using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
@ -311,6 +357,10 @@ class TextBoxData(BaseLayoutElement):
glVertex2f(x, y + h)
glEnd()
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
# Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()
def serialize(self) -> Dict[str, Any]: