From d868328e9d7465b69f21c1e977cd8dde9117cb1d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Nov 2025 21:36:52 +0100 Subject: [PATCH] Simplified rotation to data level transpose --- pyPhotoAlbum/commands.py | 83 ++++++++++++++++++++-- pyPhotoAlbum/mixins/element_selection.py | 71 +++--------------- pyPhotoAlbum/mixins/mouse_interaction.py | 12 ---- pyPhotoAlbum/mixins/operations/edit_ops.py | 8 +-- pyPhotoAlbum/mixins/rendering.py | 10 +-- pyPhotoAlbum/models.py | 65 +++++++++++------ 6 files changed, 133 insertions(+), 116 deletions(-) diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py index d976387..1b29d68 100644 --- a/pyPhotoAlbum/commands.py +++ b/pyPhotoAlbum/commands.py @@ -297,20 +297,89 @@ class ResizeElementCommand(Command): class RotateElementCommand(Command): """Command for rotating an element""" - + def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float): self.element = element self.old_rotation = old_rotation self.new_rotation = new_rotation - + + # Store old position, size, and PIL rotation state + self.old_position = element.position + self.old_size = element.size + + # For ImageData, store the old PIL rotation state + if hasattr(element, 'pil_rotation_90'): + self.old_pil_rotation = element.pil_rotation_90 + else: + self.old_pil_rotation = None + def execute(self): - """Rotate element to new angle""" - self.element.rotation = self.new_rotation - + """Rotate element by physically rotating the PIL image data""" + from pyPhotoAlbum.models import ImageData + + # Calculate rotation delta + delta = (self.new_rotation - self.old_rotation) % 360 + + # For ImageData, rotate the actual PIL image + if isinstance(self.element, ImageData): + # Update PIL rotation counter + if delta == 90: + self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 1) % 4 + elif delta == 270: + self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 3) % 4 + elif delta == 180: + self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 2) % 4 + + # For 90° or 270° rotations, swap dimensions + if delta == 90 or delta == 270: + w, h = self.element.size + x, y = self.element.position + + # Swap dimensions + self.element.size = (h, w) + + # Adjust position to keep center in same place + center_x = x + w / 2 + center_y = y + h / 2 + self.element.position = (center_x - h / 2, center_y - w / 2) + + # Clear the texture so it will be reloaded with the new rotation + if hasattr(self.element, '_texture_id'): + del self.element._texture_id + if hasattr(self.element, '_async_load_requested'): + self.element._async_load_requested = False + + # Keep visual rotation at 0 + self.element.rotation = 0 + else: + # For non-image elements, use old visual rotation + if delta == 90 or delta == 270: + w, h = self.element.size + x, y = self.element.position + self.element.size = (h, w) + center_x = x + w / 2 + center_y = y + h / 2 + self.element.position = (center_x - h / 2, center_y - w / 2) + self.element.rotation = 0 + else: + self.element.rotation = self.new_rotation + def undo(self): - """Rotate element back to old angle""" + """Restore element back to old state""" + from pyPhotoAlbum.models import ImageData + + # Restore original rotation, position, and size self.element.rotation = self.old_rotation - + self.element.position = self.old_position + self.element.size = self.old_size + + # For ImageData, restore PIL rotation and clear texture + if isinstance(self.element, ImageData) and self.old_pil_rotation is not None: + self.element.pil_rotation_90 = self.old_pil_rotation + if hasattr(self.element, '_texture_id'): + self.element._texture_id = None + self.element._async_load_requested = False + def redo(self): """Rotate element to new angle again""" self.execute() diff --git a/pyPhotoAlbum/mixins/element_selection.py b/pyPhotoAlbum/mixins/element_selection.py index e607222..cbc6d75 100644 --- a/pyPhotoAlbum/mixins/element_selection.py +++ b/pyPhotoAlbum/mixins/element_selection.py @@ -72,42 +72,12 @@ class ElementSelectionMixin: ex, ey = element.position ew, eh = element.size - # Handle rotated elements - if hasattr(element, 'rotation') and element.rotation != 0: - # Transform click point through inverse rotation - import math - - # Get element center - center_x = ex + ew / 2 - center_y = ey + eh / 2 - - # Translate to origin - rel_x = page_x - center_x - rel_y = page_y - center_y - - # Apply inverse rotation - angle_rad = -math.radians(element.rotation) - cos_a = math.cos(angle_rad) - sin_a = math.sin(angle_rad) - - # Rotate the point - rotated_x = rel_x * cos_a - rel_y * sin_a - rotated_y = rel_x * sin_a + rel_y * cos_a - - # Check if rotated point is in unrotated bounds - if (-ew / 2 <= rotated_x <= ew / 2 and - -eh / 2 <= rotated_y <= eh / 2): - # Store the renderer with the element for later use - element._page_renderer = renderer - element._parent_page = page - return element - else: - # No rotation - simple bounds check - if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh: - # Store the renderer with the element for later use - element._page_renderer = renderer - element._parent_page = page - return element + # Simple bounds check (no rotation transformation needed - images are already rotated) + if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh: + # Store the renderer with the element for later use + element._page_renderer = renderer + element._parent_page = page + return element return None @@ -147,32 +117,7 @@ class ElementSelectionMixin: ew = elem_w * renderer.zoom eh = elem_h * renderer.zoom - # Calculate center point - center_x = ex + ew / 2 - center_y = ey + eh / 2 - - # If element is rotated, transform mouse coordinates through inverse rotation - test_x, test_y = x, y - if hasattr(self.selected_element, 'rotation') and self.selected_element.rotation != 0: - import math - # Translate mouse to origin (relative to center) - rel_x = x - center_x - rel_y = y - center_y - - # Apply inverse rotation - angle_rad = -math.radians(self.selected_element.rotation) - cos_a = math.cos(angle_rad) - sin_a = math.sin(angle_rad) - - # Rotate the point - rotated_x = rel_x * cos_a - rel_y * sin_a - rotated_y = rel_x * sin_a + rel_y * cos_a - - # Translate back - test_x = center_x + rotated_x - test_y = center_y + rotated_y - - # Now check handles in non-rotated coordinate system + # Check handles (no rotation transformation needed - images are already rotated) handles = { 'nw': (ex - handle_size/2, ey - handle_size/2), 'ne': (ex + ew - handle_size/2, ey - handle_size/2), @@ -181,7 +126,7 @@ class ElementSelectionMixin: } for name, (hx, hy) in handles.items(): - if hx <= test_x <= hx + handle_size and hy <= test_y <= hy + handle_size: + if hx <= x <= hx + handle_size and hy <= y <= hy + handle_size: return name return None diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py index c39aab7..c15c882 100644 --- a/pyPhotoAlbum/mixins/mouse_interaction.py +++ b/pyPhotoAlbum/mixins/mouse_interaction.py @@ -165,18 +165,6 @@ class MouseInteractionMixin: total_dx = screen_dx / self.zoom_level total_dy = screen_dy / self.zoom_level - if self.selected_element.rotation != 0: - import math - angle_rad = -math.radians(self.selected_element.rotation) - cos_a = math.cos(angle_rad) - sin_a = math.sin(angle_rad) - - rotated_dx = total_dx * cos_a - total_dy * sin_a - rotated_dy = total_dx * sin_a + total_dy * cos_a - - total_dx = rotated_dx - total_dy = rotated_dy - self._resize_element(total_dx, total_dy) else: # Move mode diff --git a/pyPhotoAlbum/mixins/operations/edit_ops.py b/pyPhotoAlbum/mixins/operations/edit_ops.py index b952177..fdfc920 100644 --- a/pyPhotoAlbum/mixins/operations/edit_ops.py +++ b/pyPhotoAlbum/mixins/operations/edit_ops.py @@ -103,9 +103,9 @@ class EditOperationsMixin: cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) self.project.history.execute(cmd) - + self.update_view() - self.show_status(f"Rotated to {new_rotation}° (Ctrl+Z to undo)", 2000) + self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000) print(f"Rotated element left: {old_rotation}° → {new_rotation}°") @ribbon_action( @@ -126,9 +126,9 @@ class EditOperationsMixin: cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) self.project.history.execute(cmd) - + self.update_view() - self.show_status(f"Rotated to {new_rotation}° (Ctrl+Z to undo)", 2000) + self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000) print(f"Rotated element right: {old_rotation}° → {new_rotation}°") @ribbon_action( diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py index 7cfffea..eb1abd2 100644 --- a/pyPhotoAlbum/mixins/rendering.py +++ b/pyPhotoAlbum/mixins/rendering.py @@ -129,12 +129,7 @@ class RenderingMixin: center_x = x + w / 2 center_y = y + h / 2 - if element.rotation != 0: - glPushMatrix() - glTranslatef(center_x, center_y, 0) - glRotatef(element.rotation, 0, 0, 1) - glTranslatef(-w / 2, -h / 2, 0) - x, y = 0, 0 + # No rotation transformation needed - images are already rotated at PIL level if self.rotation_mode: glColor3f(1.0, 0.5, 0.0) @@ -210,9 +205,6 @@ class RenderingMixin: glVertex2f(hx, hy + handle_size) glEnd() - if element.rotation != 0: - glPopMatrix() - def _render_text_overlays(self): """Render text content for TextBoxData elements using QPainter overlay""" if not hasattr(self, '_page_renderers') or not self._page_renderers: diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index e8c8944..09bd45d 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -68,6 +68,10 @@ class ImageData(BaseLayoutElement): # This allows correct rendering even while async loading is in progress self.image_dimensions = image_dimensions # (width, height) or None + # PIL-level rotation: number of 90° rotations to apply to the loaded image + # This is separate from the visual rotation field (which should stay at 0) + self.pil_rotation_90 = 0 # 0, 1, 2, or 3 (for 0°, 90°, 180°, 270°) + # If dimensions not provided and we have a path, try to extract them quickly if not self.image_dimensions and self.image_path: self._extract_dimensions_metadata() @@ -109,28 +113,19 @@ class ImageData(BaseLayoutElement): def render(self): """Render the image 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_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, - glDeleteTextures, glPushMatrix, glPopMatrix, glTranslatef, glRotatef) + glDeleteTextures) 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 + + # Note: Rotation is now handled at the PIL image level, not visually + # The image data itself is rotated, so we render it without transformation # Try to load and render the actual image texture_id = None @@ -251,10 +246,6 @@ 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""" @@ -265,7 +256,8 @@ class ImageData(BaseLayoutElement): "rotation": self.rotation, "z_index": self.z_index, "image_path": self.image_path, - "crop_info": self.crop_info + "crop_info": self.crop_info, + "pil_rotation_90": getattr(self, 'pil_rotation_90', 0) } # Include image dimensions metadata if available if self.image_dimensions: @@ -280,6 +272,23 @@ class ImageData(BaseLayoutElement): self.z_index = data.get("z_index", 0) self.image_path = data.get("image_path", "") self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1))) + self.pil_rotation_90 = data.get("pil_rotation_90", 0) + + # Backwards compatibility: convert old visual rotation to PIL rotation + if self.pil_rotation_90 == 0 and self.rotation != 0: + # Old project with visual rotation - convert to PIL rotation + # Round to nearest 90 degrees + normalized_rotation = round(self.rotation / 90) * 90 + if normalized_rotation == 90: + self.pil_rotation_90 = 1 + elif normalized_rotation == 180: + self.pil_rotation_90 = 2 + elif normalized_rotation == 270: + self.pil_rotation_90 = 3 + # Reset visual rotation + self.rotation = 0 + print(f"ImageData: Converted old visual rotation to pil_rotation_90={self.pil_rotation_90}") + # Load image dimensions metadata if available self.image_dimensions = data.get("image_dimensions", None) if self.image_dimensions: @@ -295,8 +304,22 @@ class ImageData(BaseLayoutElement): from OpenGL.GL import (glGenTextures, glBindTexture, glTexImage2D, GL_TEXTURE_2D, glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR, GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures) + from PIL import Image try: + # Apply PIL-level rotation if needed + if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0: + # Rotate counter-clockwise by 90° * pil_rotation_90 + # PIL.Image.ROTATE_90 rotates counter-clockwise + angle = self.pil_rotation_90 * 90 + if angle == 90: + pil_image = pil_image.transpose(Image.ROTATE_270) # CCW 90 = rotate right + elif angle == 180: + pil_image = pil_image.transpose(Image.ROTATE_180) + elif angle == 270: + pil_image = pil_image.transpose(Image.ROTATE_90) # CCW 270 = rotate left + print(f"ImageData: Applied PIL rotation {angle}° to {self.image_path}") + # Delete old texture if it exists if hasattr(self, '_texture_id') and self._texture_id: glDeleteTextures([self._texture_id])