Fix rotation
This commit is contained in:
parent
3e3b604d2f
commit
4bfaa63aae
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -291,6 +305,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
|
|||||||
glVertex2f(hx + handle_size, hy + handle_size)
|
glVertex2f(hx + handle_size, hy + handle_size)
|
||||||
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"""
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
@ -191,6 +203,10 @@ class ImageData(BaseLayoutElement):
|
|||||||
glVertex2f(x + w, y + h)
|
glVertex2f(x + w, y + h)
|
||||||
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"""
|
||||||
@ -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)
|
||||||
@ -248,6 +277,10 @@ class PlaceholderData(BaseLayoutElement):
|
|||||||
glVertex2f(x, y + h)
|
glVertex2f(x, y + h)
|
||||||
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"""
|
||||||
@ -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]:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user