Simplified rotation to data level transpose
This commit is contained in:
parent
950aa6bee9
commit
d868328e9d
@ -303,13 +303,82 @@ class RotateElementCommand(Command):
|
|||||||
self.old_rotation = old_rotation
|
self.old_rotation = old_rotation
|
||||||
self.new_rotation = new_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):
|
def execute(self):
|
||||||
"""Rotate element to new angle"""
|
"""Rotate element by physically rotating the PIL image data"""
|
||||||
self.element.rotation = self.new_rotation
|
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):
|
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.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):
|
def redo(self):
|
||||||
"""Rotate element to new angle again"""
|
"""Rotate element to new angle again"""
|
||||||
|
|||||||
@ -72,42 +72,12 @@ class ElementSelectionMixin:
|
|||||||
ex, ey = element.position
|
ex, ey = element.position
|
||||||
ew, eh = element.size
|
ew, eh = element.size
|
||||||
|
|
||||||
# Handle rotated elements
|
# Simple bounds check (no rotation transformation needed - images are already rotated)
|
||||||
if hasattr(element, 'rotation') and element.rotation != 0:
|
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
|
||||||
# Transform click point through inverse rotation
|
# Store the renderer with the element for later use
|
||||||
import math
|
element._page_renderer = renderer
|
||||||
|
element._parent_page = page
|
||||||
# Get element center
|
return element
|
||||||
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
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -147,32 +117,7 @@ class ElementSelectionMixin:
|
|||||||
ew = elem_w * renderer.zoom
|
ew = elem_w * renderer.zoom
|
||||||
eh = elem_h * renderer.zoom
|
eh = elem_h * renderer.zoom
|
||||||
|
|
||||||
# Calculate center point
|
# Check handles (no rotation transformation needed - images are already rotated)
|
||||||
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
|
|
||||||
handles = {
|
handles = {
|
||||||
'nw': (ex - handle_size/2, ey - handle_size/2),
|
'nw': (ex - handle_size/2, ey - handle_size/2),
|
||||||
'ne': (ex + ew - 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():
|
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 name
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -165,18 +165,6 @@ class MouseInteractionMixin:
|
|||||||
total_dx = screen_dx / self.zoom_level
|
total_dx = screen_dx / self.zoom_level
|
||||||
total_dy = screen_dy / 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)
|
self._resize_element(total_dx, total_dy)
|
||||||
else:
|
else:
|
||||||
# Move mode
|
# Move mode
|
||||||
|
|||||||
@ -105,7 +105,7 @@ class EditOperationsMixin:
|
|||||||
self.project.history.execute(cmd)
|
self.project.history.execute(cmd)
|
||||||
|
|
||||||
self.update_view()
|
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}°")
|
print(f"Rotated element left: {old_rotation}° → {new_rotation}°")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
@ -128,7 +128,7 @@ class EditOperationsMixin:
|
|||||||
self.project.history.execute(cmd)
|
self.project.history.execute(cmd)
|
||||||
|
|
||||||
self.update_view()
|
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}°")
|
print(f"Rotated element right: {old_rotation}° → {new_rotation}°")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
|
|||||||
@ -129,12 +129,7 @@ class RenderingMixin:
|
|||||||
center_x = x + w / 2
|
center_x = x + w / 2
|
||||||
center_y = y + h / 2
|
center_y = y + h / 2
|
||||||
|
|
||||||
if element.rotation != 0:
|
# No rotation transformation needed - images are already rotated at PIL level
|
||||||
glPushMatrix()
|
|
||||||
glTranslatef(center_x, center_y, 0)
|
|
||||||
glRotatef(element.rotation, 0, 0, 1)
|
|
||||||
glTranslatef(-w / 2, -h / 2, 0)
|
|
||||||
x, y = 0, 0
|
|
||||||
|
|
||||||
if self.rotation_mode:
|
if self.rotation_mode:
|
||||||
glColor3f(1.0, 0.5, 0.0)
|
glColor3f(1.0, 0.5, 0.0)
|
||||||
@ -210,9 +205,6 @@ class RenderingMixin:
|
|||||||
glVertex2f(hx, hy + handle_size)
|
glVertex2f(hx, hy + handle_size)
|
||||||
glEnd()
|
glEnd()
|
||||||
|
|
||||||
if 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"""
|
||||||
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
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
|
# This allows correct rendering even while async loading is in progress
|
||||||
self.image_dimensions = image_dimensions # (width, height) or None
|
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 dimensions not provided and we have a path, try to extract them quickly
|
||||||
if not self.image_dimensions and self.image_path:
|
if not self.image_dimensions and self.image_path:
|
||||||
self._extract_dimensions_metadata()
|
self._extract_dimensions_metadata()
|
||||||
@ -113,24 +117,15 @@ 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, glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
|
glDeleteTextures)
|
||||||
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
|
# Note: Rotation is now handled at the PIL image level, not visually
|
||||||
if self.rotation != 0:
|
# The image data itself is rotated, so we render it without transformation
|
||||||
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
|
||||||
@ -252,10 +247,6 @@ 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"""
|
||||||
data = {
|
data = {
|
||||||
@ -265,7 +256,8 @@ class ImageData(BaseLayoutElement):
|
|||||||
"rotation": self.rotation,
|
"rotation": self.rotation,
|
||||||
"z_index": self.z_index,
|
"z_index": self.z_index,
|
||||||
"image_path": self.image_path,
|
"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
|
# Include image dimensions metadata if available
|
||||||
if self.image_dimensions:
|
if self.image_dimensions:
|
||||||
@ -280,6 +272,23 @@ class ImageData(BaseLayoutElement):
|
|||||||
self.z_index = data.get("z_index", 0)
|
self.z_index = data.get("z_index", 0)
|
||||||
self.image_path = data.get("image_path", "")
|
self.image_path = data.get("image_path", "")
|
||||||
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
|
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
|
# Load image dimensions metadata if available
|
||||||
self.image_dimensions = data.get("image_dimensions", None)
|
self.image_dimensions = data.get("image_dimensions", None)
|
||||||
if self.image_dimensions:
|
if self.image_dimensions:
|
||||||
@ -295,8 +304,22 @@ class ImageData(BaseLayoutElement):
|
|||||||
from OpenGL.GL import (glGenTextures, glBindTexture, glTexImage2D, GL_TEXTURE_2D,
|
from OpenGL.GL import (glGenTextures, glBindTexture, glTexImage2D, GL_TEXTURE_2D,
|
||||||
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER,
|
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER,
|
||||||
GL_LINEAR, GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures)
|
GL_LINEAR, GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures)
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
try:
|
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
|
# Delete old texture if it exists
|
||||||
if hasattr(self, '_texture_id') and self._texture_id:
|
if hasattr(self, '_texture_id') and self._texture_id:
|
||||||
glDeleteTextures([self._texture_id])
|
glDeleteTextures([self._texture_id])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user