Simplified rotation to data level transpose
Some checks failed
Python CI / test (push) Successful in 1m16s
Lint / lint (push) Successful in 1m21s
Tests / test (3.10) (push) Failing after 1m2s
Tests / test (3.11) (push) Failing after 57s
Tests / test (3.9) (push) Failing after 59s

This commit is contained in:
Duncan Tourolle 2025-11-21 21:36:52 +01:00
parent 950aa6bee9
commit d868328e9d
6 changed files with 133 additions and 116 deletions

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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:

View File

@ -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])