Simplified rotation to data level transpose
This commit is contained in:
parent
950aa6bee9
commit
d868328e9d
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user