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): class AlignElementsCommand(Command):
"""Command for aligning multiple elements""" """Command for aligning multiple elements"""
@ -717,6 +760,8 @@ class CommandHistory:
return ResizeElementsCommand.deserialize(data, project) return ResizeElementsCommand.deserialize(data, project)
elif cmd_type == "change_zorder": elif cmd_type == "change_zorder":
return ChangeZOrderCommand.deserialize(data, project) return ChangeZOrderCommand.deserialize(data, project)
elif cmd_type == "adjust_image_crop":
return AdjustImageCropCommand.deserialize(data, project)
else: else:
print(f"Warning: Unknown command type: {cmd_type}") print(f"Warning: Unknown command type: {cmd_type}")
return None return None

View File

@ -41,6 +41,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.rotation_start_angle = None # Starting rotation angle self.rotation_start_angle = None # Starting rotation angle
self.rotation_snap_angle = 15 # Default snap angle in degrees 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 # Zoom and pan state
self.zoom_level = 1.0 self.zoom_level = 1.0
self.pan_offset = [0, 0] self.pan_offset = [0, 0]
@ -209,6 +213,16 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
center_x = x + w / 2 center_x = x + w / 2
center_y = y + h / 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 # Draw selection border
if self.rotation_mode: if self.rotation_mode:
glColor3f(1.0, 0.5, 0.0) # Orange for rotation mode glColor3f(1.0, 0.5, 0.0) # Orange for rotation mode
@ -292,6 +306,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
glVertex2f(hx, hy + handle_size) glVertex2f(hx, hy + handle_size)
glEnd() glEnd()
# Restore matrix if we applied rotation
if self.selected_element.rotation != 0:
glPopMatrix()
def _render_text_overlays(self): def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay""" """Render text content for TextBoxData elements using QPainter overlay"""
from PyQt6.QtGui import QPainter, QFont, QColor, QPen from PyQt6.QtGui import QPainter, QFont, QColor, QPen
@ -448,12 +466,25 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
element = self._get_element_at(x, y) element = self._get_element_at(x, y)
if element: 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: if element in self.selected_elements:
self.selected_elements.remove(element) self.selected_elements.remove(element)
else: else:
self.selected_elements.add(element) self.selected_elements.add(element)
else: else:
# Normal drag mode
self.selected_elements = {element} self.selected_elements = {element}
self.drag_start_pos = (x, y) self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position self.drag_start_element_pos = element.position
@ -493,7 +524,57 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
return return
if self.selected_element: 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 # Calculate rotation angle from mouse position relative to element center
import math import math
@ -528,6 +609,7 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.selected_element.rotation = angle self.selected_element.rotation = angle
# Show current angle in status bar # Show current angle in status bar
main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, 'show_status'):
main_window.show_status(f"Rotation: {angle:.1f}°", 100) main_window.show_status(f"Rotation: {angle:.1f}°", 100)
@ -591,11 +673,14 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.drag_start_element_pos = None self.drag_start_element_pos = None
self.resize_handle = None self.resize_handle = None
self.rotation_start_angle = None self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
self.snap_state = { self.snap_state = {
'is_snapped': False, 'is_snapped': False,
'last_position': None, 'last_position': None,
'last_size': None 'last_size': None
} }
self.setCursor(Qt.CursorShape.ArrowCursor) # Reset cursor
elif event.button() == Qt.MouseButton.MiddleButton: elif event.button() == Qt.MouseButton.MiddleButton:
self.is_panning = False self.is_panning = False

View File

@ -64,6 +64,24 @@ class UndoableInteractionMixin:
self._interaction_start_size = None self._interaction_start_size = None
self._interaction_start_rotation = element.rotation 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): def _end_interaction(self):
""" """
End the current interaction and create appropriate undo/redo command. 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}°") 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 # Execute the command through history if one was created
if command: if command:
main_window.project.history.execute(command) main_window.project.history.execute(command)
@ -156,6 +197,8 @@ class UndoableInteractionMixin:
self._interaction_start_pos = None self._interaction_start_pos = None
self._interaction_start_size = None self._interaction_start_size = None
self._interaction_start_rotation = None self._interaction_start_rotation = None
if hasattr(self, '_interaction_start_crop_info'):
self._interaction_start_crop_info = None
def _cancel_interaction(self): def _cancel_interaction(self):
"""Cancel the current interaction without creating a command""" """Cancel the current interaction without creating a command"""

View File

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